diff --git a/crates/record-lru/src/lib.rs b/crates/record-lru/src/lib.rs index e93246f..0141387 100644 --- a/crates/record-lru/src/lib.rs +++ b/crates/record-lru/src/lib.rs @@ -9,6 +9,17 @@ use quick_cache::sync::Cache; pub trait RecordStore: Send + Sync { fn get(&self, uri: &AtUri) -> Option>; fn put(&self, uri: AtUri, body: Arc); + fn remove(&self, uri: &AtUri); +} + +pub struct NoopRecordStore; + +impl RecordStore for NoopRecordStore { + fn get(&self, _uri: &AtUri) -> Option> { + None + } + fn put(&self, _uri: AtUri, _body: Arc) {} + fn remove(&self, _uri: &AtUri) {} } #[derive(Clone, Debug)] @@ -56,6 +67,9 @@ impl RecordStore for LruRecordStore { fn put(&self, uri: AtUri, body: Arc) { self.cache.insert(uri, body); } + fn remove(&self, uri: &AtUri) { + self.cache.remove(uri); + } } #[cfg(test)] @@ -89,6 +103,22 @@ mod tests { assert_eq!(got.value, b.value); } + #[test] + fn remove_evicts_entry() { + let store = LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024)); + let uri = at("at://did:plc:nel/sh.tangled.repo/r1"); + store.put(uri.clone(), body(uri.as_ref(), "{\"v\":1}")); + assert!(store.get(&uri).is_some()); + store.remove(&uri); + assert!(store.get(&uri).is_none()); + } + + #[test] + fn remove_missing_uri_is_a_noop() { + let store = LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024)); + store.remove(&at("at://did:plc:nel/sh.tangled.repo/never-cached")); + } + #[test] fn miss_returns_none() { let store = LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024)); diff --git a/crates/types/src/edges.rs b/crates/types/src/edges.rs index 62c4d9d..4ee0919 100644 --- a/crates/types/src/edges.rs +++ b/crates/types/src/edges.rs @@ -86,13 +86,17 @@ impl Record { collection: &Nsid, value: serde_json::Value, ) -> Result { - let json = serde_json::to_string(&value)?; + let bytes = serde_json::to_vec(&value)?; + Self::from_json_bytes(collection.as_ref(), &bytes) + } + + pub fn from_json_bytes(nsid_str: &str, bytes: &[u8]) -> Result { macro_rules! parse { ($variant:ident) => { - Ok(Self::$variant(serde_json::from_str(&json)?)) + Ok(Self::$variant(serde_json::from_slice(bytes)?)) }; } - match collection.as_ref() { + match nsid_str { "sh.tangled.actor.profile" => parse!(Profile), "sh.tangled.feed.reaction" => parse!(Reaction), "sh.tangled.feed.star" => parse!(Star), diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 4dead74..1ca66d9 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -18,3 +18,4 @@ pub use _lex::*; pub mod edges; pub mod ids; pub mod record; +pub mod search; diff --git a/crates/types/src/search.rs b/crates/types/src/search.rs new file mode 100644 index 0000000..2ad570f --- /dev/null +++ b/crates/types/src/search.rs @@ -0,0 +1,353 @@ +use alloc::string::String; +use alloc::vec::Vec; +use core::future::Future; + +use jacquard_common::DefaultStr; +use jacquard_common::types::nsid::Nsid; +use jacquard_common::types::string::AtUri; +use serde::Serialize; + +use crate::edges::{ExtractError, Record}; +use crate::ids::nsid_static; +use crate::sh_tangled::actor::profile::Profile; +use crate::sh_tangled::label::definition::Definition as LabelDefinitionRecord; +use crate::sh_tangled::repo::Repo as RepoRecord; +use crate::sh_tangled::repo::issue::Issue; +use crate::sh_tangled::repo::issue::comment::Comment as IssueCommentRecord; +use crate::sh_tangled::repo::pull::Pull; +use crate::sh_tangled::repo::pull::comment::Comment as PullCommentRecord; +use crate::sh_tangled::string::TangledString; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchDoc { + pub uri: AtUri, + pub nsid: Nsid, + pub title: String, + pub body: String, +} + +pub trait SearchSink: Send + Sync { + fn upsert(&self, doc: SearchDoc) -> impl Future + Send; + fn remove(&self, uri: &AtUri) -> impl Future + Send; +} + +pub struct NoopSearchSink; + +impl SearchSink for NoopSearchSink { + async fn upsert(&self, _doc: SearchDoc) {} + async fn remove(&self, _uri: &AtUri) {} +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum SearchableRecord { + Profile(Profile), + Repo(RepoRecord), + Issue(Issue), + IssueComment(IssueCommentRecord), + Pull(Pull), + PullComment(PullCommentRecord), + TangledString(TangledString), + LabelDefinition(LabelDefinitionRecord), +} + +impl SearchableRecord { + pub fn try_from_record(record: Record) -> Option { + match record { + Record::Profile(r) => Some(Self::Profile(r)), + Record::Repo(r) => Some(Self::Repo(r)), + Record::Issue(r) => Some(Self::Issue(r)), + Record::IssueComment(r) => Some(Self::IssueComment(r)), + Record::Pull(r) => Some(Self::Pull(r)), + Record::PullComment(r) => Some(Self::PullComment(r)), + Record::TangledString(r) => Some(Self::TangledString(r)), + Record::LabelDefinition(r) => Some(Self::LabelDefinition(r)), + Record::Reaction(_) + | Record::Star(_) + | Record::RefUpdate(_) + | Record::Follow(_) + | Record::Vouch(_) + | Record::Knot(_) + | Record::KnotMember(_) + | Record::LabelOp(_) + | Record::Pipeline(_) + | Record::PipelineStatus(_) + | Record::PublicKey(_) + | Record::Artifact(_) + | Record::Collaborator(_) + | Record::IssueState(_) + | Record::PullStatus(_) + | Record::Spindle(_) + | Record::SpindleMember(_) => None, + } + } + + pub fn from_json_bytes(nsid_str: &str, bytes: &[u8]) -> Result { + let parsed = Record::from_json_bytes(nsid_str, bytes)?; + Self::try_from_record(parsed) + .ok_or_else(|| ExtractError::UnknownCollection(nsid_str.into())) + } + + pub fn nsid(&self) -> Nsid { + let s: &'static str = match self { + Self::Profile(_) => "sh.tangled.actor.profile", + Self::Repo(_) => "sh.tangled.repo", + Self::Issue(_) => "sh.tangled.repo.issue", + Self::IssueComment(_) => "sh.tangled.repo.issue.comment", + Self::Pull(_) => "sh.tangled.repo.pull", + Self::PullComment(_) => "sh.tangled.repo.pull.comment", + Self::TangledString(_) => "sh.tangled.string", + Self::LabelDefinition(_) => "sh.tangled.label.definition", + }; + nsid_static(s) + } + + pub fn to_search_doc(&self, source: &AtUri) -> SearchDoc { + match self { + Self::Profile(r) => profile_doc(source, r), + Self::Repo(r) => repo_doc(source, r), + Self::Issue(r) => issue_doc(source, r), + Self::IssueComment(r) => issue_comment_doc(source, r), + Self::Pull(r) => pull_doc(source, r), + Self::PullComment(r) => pull_comment_doc(source, r), + Self::TangledString(r) => string_doc(source, r), + Self::LabelDefinition(r) => label_definition_doc(source, r), + } + } +} + +fn doc( + source: &AtUri, + nsid: &'static str, + title: &str, + body_parts: Vec, +) -> SearchDoc { + SearchDoc { + uri: source.clone(), + nsid: nsid_static(nsid), + title: title.to_owned(), + body: body_parts.join(" "), + } +} + +fn profile_doc(source: &AtUri, r: &Profile) -> SearchDoc { + let title = r + .preferred_handle + .as_ref() + .map(|h| h.as_str().to_owned()) + .unwrap_or_default(); + let mut parts: Vec = Vec::new(); + if let Some(d) = &r.description { + parts.push(d.as_str().to_owned()); + } + if let Some(loc) = &r.location { + parts.push(loc.as_str().to_owned()); + } + if let Some(p) = &r.pronouns { + parts.push(p.as_str().to_owned()); + } + doc(source, "sh.tangled.actor.profile", &title, parts) +} + +fn repo_doc(source: &AtUri, r: &RepoRecord) -> SearchDoc { + let mut parts: Vec = Vec::new(); + if let Some(d) = &r.description { + parts.push(d.as_str().to_owned()); + } + if let Some(topics) = &r.topics { + parts.extend(topics.iter().map(|t| t.as_str().to_owned())); + } + doc(source, "sh.tangled.repo", r.name.as_str(), parts) +} + +fn issue_doc(source: &AtUri, r: &Issue) -> SearchDoc { + let body = r + .body + .as_ref() + .map(|b| Vec::from([b.as_str().to_owned()])) + .unwrap_or_default(); + doc(source, "sh.tangled.repo.issue", r.title.as_str(), body) +} + +fn issue_comment_doc(source: &AtUri, r: &IssueCommentRecord) -> SearchDoc { + doc( + source, + "sh.tangled.repo.issue.comment", + "", + Vec::from([r.body.as_str().to_owned()]), + ) +} + +fn pull_doc(source: &AtUri, r: &Pull) -> SearchDoc { + let body = r + .body + .as_ref() + .map(|b| Vec::from([b.as_str().to_owned()])) + .unwrap_or_default(); + doc(source, "sh.tangled.repo.pull", r.title.as_str(), body) +} + +fn pull_comment_doc(source: &AtUri, r: &PullCommentRecord) -> SearchDoc { + doc( + source, + "sh.tangled.repo.pull.comment", + "", + Vec::from([r.body.as_str().to_owned()]), + ) +} + +fn string_doc(source: &AtUri, r: &TangledString) -> SearchDoc { + doc( + source, + "sh.tangled.string", + r.filename.as_str(), + Vec::from([ + r.description.as_str().to_owned(), + r.contents.as_str().to_owned(), + ]), + ) +} + +fn label_definition_doc( + source: &AtUri, + r: &LabelDefinitionRecord, +) -> SearchDoc { + doc( + source, + "sh.tangled.label.definition", + r.name.as_str(), + Vec::new(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use jacquard_common::types::nsid::Nsid; + use serde_json::json; + + fn at(s: &str) -> AtUri { + AtUri::new_owned(s).unwrap() + } + + fn nsid(s: &'static str) -> Nsid { + Nsid::new_static(s).unwrap() + } + + fn extract(collection: &'static str, source: &str, body: serde_json::Value) -> SearchDoc { + let parsed = Record::from_json_value(&nsid(collection), body).expect("parse"); + let searchable = SearchableRecord::try_from_record(parsed).expect("indexable record"); + searchable.to_search_doc(&at(source)) + } + + #[test] + fn issue_titles_and_body_indexable() { + let doc = extract( + "sh.tangled.repo.issue", + "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo.issue", + "repoDid": "did:plc:abalone", + "title": "barnacle pagination overflow", + "body": "scrolling resets when the cursor wraps", + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(doc.title, "barnacle pagination overflow"); + assert_eq!(doc.body, "scrolling resets when the cursor wraps"); + assert_eq!(doc.nsid, nsid("sh.tangled.repo.issue")); + } + + #[test] + fn issue_without_body_yields_empty_body() { + let doc = extract( + "sh.tangled.repo.issue", + "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo.issue", + "repoDid": "did:plc:abalone", + "title": "kelp ate my newline", + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(doc.title, "kelp ate my newline"); + assert!(doc.body.is_empty()); + } + + #[test] + fn repo_indexes_name_description_topics() { + let doc = extract( + "sh.tangled.repo", + "at://did:plc:teq/sh.tangled.repo/r1", + json!({ + "$type": "sh.tangled.repo", + "name": "abalone", + "knot": "oyster.cafe", + "description": "shell index for tide pools", + "topics": ["intertidal", "molluscs"], + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(doc.title, "abalone"); + assert!(doc.body.contains("shell index")); + assert!(doc.body.contains("intertidal")); + assert!(doc.body.contains("molluscs")); + } + + #[test] + fn non_text_records_have_no_searchable_view() { + let parsed = Record::from_json_value( + &nsid("sh.tangled.feed.star"), + json!({ + "$type": "sh.tangled.feed.star", + "createdAt": "2026-05-01T00:00:00Z", + "subjectDid": "did:plc:abalone" + }), + ) + .expect("parse"); + assert!(SearchableRecord::try_from_record(parsed).is_none()); + } + + #[test] + fn string_indexes_filename_description_contents() { + let doc = extract( + "sh.tangled.string", + "at://did:plc:teq/sh.tangled.string/k1", + json!({ + "$type": "sh.tangled.string", + "filename": "anemone.md", + "description": "field notes on tide pools", + "contents": "the limpet returned at dawn", + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(doc.title, "anemone.md"); + assert!(doc.body.contains("field notes")); + assert!(doc.body.contains("limpet returned at dawn")); + } + + #[test] + fn from_json_bytes_round_trips_via_serialize() { + let body = json!({ + "$type": "sh.tangled.repo.issue", + "repoDid": "did:plc:abalone", + "title": "uni shell", + "createdAt": "2026-05-01T00:00:00Z" + }); + let bytes = serde_json::to_vec(&body).unwrap(); + let view = SearchableRecord::from_json_bytes("sh.tangled.repo.issue", &bytes).unwrap(); + assert_eq!(view.nsid(), nsid("sh.tangled.repo.issue")); + let serialized = serde_json::to_value(&view).unwrap(); + assert_eq!(serialized["title"], json!("uni shell")); + assert_eq!(serialized["$type"], json!("sh.tangled.repo.issue")); + } + + #[test] + fn from_json_bytes_rejects_unknown_nsid() { + let body = json!({"$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z"}); + let bytes = serde_json::to_vec(&body).unwrap(); + let err = SearchableRecord::from_json_bytes("sh.tangled.feed.star", &bytes) + .expect_err("non-searchable nsid must be rejected at search hydration"); + assert!(matches!(err, ExtractError::UnknownCollection(_))); + } +}