diff --git a/crates/types/src/edges.rs b/crates/types/src/edges.rs index 62981a1..14dcf88 100644 --- a/crates/types/src/edges.rs +++ b/crates/types/src/edges.rs @@ -1,14 +1,13 @@ -use alloc::format; use alloc::vec; use alloc::vec::Vec; use jacquard_common::types::did::Did; use jacquard_common::types::nsid::Nsid; -use jacquard_common::types::string::AtStrError; -use jacquard_common::types::string::AtUri; +use jacquard_common::types::string::{AtStrError, AtUri, Datetime}; +use jacquard_common::types::tid::Tid; use jacquard_common::{BosStr, DefaultStr}; -use crate::ids::nsid_static; +use crate::ids::{SubjectRef, nsid_static}; use crate::sh_tangled::actor::profile::Profile; use crate::sh_tangled::feed::reaction::Reaction; use crate::sh_tangled::feed::star::Star; @@ -38,8 +37,9 @@ use crate::sh_tangled::string::TangledString; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct Edge { pub kind: Nsid, - pub subject: AtUri, + pub subject: SubjectRef, pub source: AtUri, + pub sort_micros: u64, } #[derive(Debug, thiserror::Error)] @@ -158,10 +158,57 @@ impl Record { } pub fn extract_edges(&self, source: &AtUri) -> Result, ExtractError> { - let primary = self.primary_edges(source)?; + let sort_micros = self.sort_micros_for(source); + let mut primary = self.primary_edges(source)?; + primary.iter_mut().for_each(|e| e.sort_micros = sort_micros); Ok(append_mirror_edges(primary, source)) } + pub fn sort_micros_for(&self, source: &AtUri) -> u64 { + if let Some(dt) = self.created_at() { + let micros = dt.timestamp_micros(); + if micros >= 0 { + return micros as u64; + } + } + if let Some(rkey) = source.rkey() + && let Ok(tid) = Tid::new(rkey.as_ref()) + { + return tid.timestamp(); + } + 0 + } + + fn created_at(&self) -> Option<&Datetime> { + match self { + Self::Profile(_) => None, + Self::Reaction(r) => Some(&r.created_at), + Self::Star(r) => Some(&r.created_at), + Self::RefUpdate(_) => None, + Self::Follow(r) => Some(&r.created_at), + Self::Vouch(r) => Some(&r.created_at), + Self::Knot(r) => Some(&r.created_at), + Self::KnotMember(r) => Some(&r.created_at), + Self::LabelDefinition(r) => Some(&r.created_at), + Self::LabelOp(r) => Some(&r.performed_at), + Self::Pipeline(_) => None, + Self::PipelineStatus(r) => Some(&r.created_at), + Self::PublicKey(r) => Some(&r.created_at), + Self::Repo(r) => Some(&r.created_at), + Self::Artifact(r) => Some(&r.created_at), + Self::Collaborator(r) => Some(&r.created_at), + Self::Issue(r) => Some(&r.created_at), + Self::IssueComment(r) => Some(&r.created_at), + Self::IssueState(_) => None, + Self::Pull(r) => Some(&r.created_at), + Self::PullComment(r) => Some(&r.created_at), + Self::PullStatus(_) => None, + Self::Spindle(r) => Some(&r.created_at), + Self::SpindleMember(r) => Some(&r.created_at), + Self::TangledString(r) => Some(&r.created_at), + } + } + fn primary_edges(&self, source: &AtUri) -> Result, ExtractError> { match self { Self::Star(r) => star_edges(source, r), @@ -193,38 +240,27 @@ impl Record { } } -fn did_to_aturi>(did: &Did) -> Result, AtStrError> { - AtUri::new_owned(format!("at://{}", did.as_ref())) -} - -fn aturi_to_owned>(uri: &AtUri) -> Result, AtStrError> { - AtUri::new_owned(uri.as_ref()) -} - -fn did_subject>( - uri: &Option>, - did: &Option>, -) -> Result>, AtStrError> { - match (did.as_ref(), uri.as_ref()) { - (Some(d), _) => did_to_aturi(d).map(Some), - (None, Some(u)) if uri_authority_is_did(u) => aturi_to_owned(u).map(Some), - _ => Ok(None), +fn repo_subject( + uri: &Option>, + did: &Option>, +) -> Option { + if let Some(d) = did.as_ref() { + return Some(SubjectRef::Did(d.clone())); } + uri_subject_for_record(uri.as_ref()?) } -fn uri_authority_is_did>(uri: &AtUri) -> bool { - crate::ids::did_from_aturi(uri.as_ref()).is_some() +fn uri_subject_for_record(uri: &AtUri) -> Option { + crate::ids::owner_did_from_aturi(uri.as_ref())?; + Some(SubjectRef::Uri(uri.clone())) } -fn one_edge( - kind: &'static str, - subject: AtUri, - source: &AtUri, -) -> Vec { +fn one_edge(kind: &'static str, subject: SubjectRef, source: &AtUri) -> Vec { vec![Edge { kind: nsid_static(kind), subject, source: source.clone(), + sort_micros: 0, }] } @@ -257,20 +293,19 @@ fn mirror_kind_for(kind: &str) -> Option<&'static str> { } fn append_mirror_edges(primary: Vec, source: &AtUri) -> Vec { - let Some(author) = crate::ids::did_from_aturi(source.as_ref()) else { - return primary; - }; - let Ok(author_uri) = did_to_aturi(&author) else { + let Some(author) = crate::ids::owner_did_from_aturi(source.as_ref()) else { return primary; }; + let author_subject = SubjectRef::Did(author); let mirrors: Vec = primary .iter() .filter_map(|edge| { let mirror_nsid = mirror_kind_for(edge.kind.as_ref())?; - (edge.subject != author_uri).then(|| Edge { + (edge.subject != author_subject).then(|| Edge { kind: nsid_static(mirror_nsid), - subject: author_uri.clone(), + subject: author_subject.clone(), source: edge.source.clone(), + sort_micros: edge.sort_micros, }) }) .collect(); @@ -281,8 +316,15 @@ fn star_edges( source: &AtUri, record: &Star, ) -> Result, ExtractError> { - let Some(subject) = did_subject(&record.subject, &record.subject_did)? else { - return Ok(Vec::new()); + use crate::sh_tangled::feed::star::StarSubject; + let subject = match &record.subject { + StarSubject::Repo(r) => SubjectRef::Did(r.did.clone()), + StarSubject::String(s) => { + let Some(uri_subject) = uri_subject_for_record(&s.uri) else { + return Ok(Vec::new()); + }; + uri_subject + } }; Ok(one_edge("sh.tangled.feed.star", subject, source)) } @@ -291,11 +333,10 @@ fn reaction_edges( source: &AtUri, record: &Reaction, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.feed.reaction", - aturi_to_owned(&record.subject)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.subject) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.feed.reaction", subject, source)) } fn follow_edges( @@ -304,7 +345,7 @@ fn follow_edges( ) -> Result, ExtractError> { Ok(one_edge( "sh.tangled.graph.follow", - did_to_aturi(&record.subject)?, + SubjectRef::Did(record.subject.clone()), source, )) } @@ -313,12 +354,9 @@ fn ref_update_edges( source: &AtUri, record: &RefUpdate, ) -> Result, ExtractError> { - let Some(did) = &record.repo_did else { - return Ok(Vec::new()); - }; Ok(one_edge( "sh.tangled.git.refUpdate", - did_to_aturi(did)?, + SubjectRef::Did(record.repo.clone()), source, )) } @@ -329,7 +367,7 @@ fn knot_member_edges( ) -> Result, ExtractError> { Ok(one_edge( "sh.tangled.knot.member", - did_to_aturi(&record.subject)?, + SubjectRef::Did(record.subject.clone()), source, )) } @@ -338,29 +376,27 @@ fn label_op_edges( source: &AtUri, record: &LabelOpRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.label.op", - aturi_to_owned(&record.subject)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.subject) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.label.op", subject, source)) } fn pipeline_status_edges( source: &AtUri, record: &PipelineStatusRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.pipeline.status", - aturi_to_owned(&record.pipeline)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.pipeline) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.pipeline.status", subject, source)) } fn artifact_edges( source: &AtUri, record: &Artifact, ) -> Result, ExtractError> { - let Some(subject) = did_subject(&record.repo, &record.repo_did)? else { + let Some(subject) = repo_subject(&record.repo, &record.repo_did) else { return Ok(Vec::new()); }; Ok(one_edge("sh.tangled.repo.artifact", subject, source)) @@ -370,74 +406,73 @@ fn collaborator_edges( source: &AtUri, record: &Collaborator, ) -> Result, ExtractError> { - let Some(subject) = did_subject(&record.repo, &record.repo_did)? else { - return Ok(Vec::new()); - }; - Ok(one_edge("sh.tangled.repo.collaborator", subject, source)) + Ok(one_edge( + "sh.tangled.repo.collaborator", + SubjectRef::Did(record.repo.clone()), + source, + )) } fn issue_edges( source: &AtUri, record: &Issue, ) -> Result, ExtractError> { - let Some(subject) = did_subject(&record.repo, &record.repo_did)? else { - return Ok(Vec::new()); - }; - Ok(one_edge("sh.tangled.repo.issue", subject, source)) + Ok(one_edge( + "sh.tangled.repo.issue", + SubjectRef::Did(record.repo.clone()), + source, + )) } fn issue_comment_edges( source: &AtUri, record: &IssueCommentRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.repo.issue.comment", - aturi_to_owned(&record.issue)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.issue) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.repo.issue.comment", subject, source)) } fn issue_state_edges( source: &AtUri, record: &IssueStateRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.repo.issue.state", - aturi_to_owned(&record.issue)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.issue) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.repo.issue.state", subject, source)) } fn pull_edges( source: &AtUri, record: &Pull, ) -> Result, ExtractError> { - let Some(subject) = did_subject(&record.target.repo, &record.target.repo_did)? else { - return Ok(Vec::new()); - }; - Ok(one_edge("sh.tangled.repo.pull", subject, source)) + Ok(one_edge( + "sh.tangled.repo.pull", + SubjectRef::Did(record.target.repo.clone()), + source, + )) } fn pull_comment_edges( source: &AtUri, record: &PullCommentRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.repo.pull.comment", - aturi_to_owned(&record.pull)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.pull) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.repo.pull.comment", subject, source)) } fn pull_status_edges( source: &AtUri, record: &PullStatusRecord, ) -> Result, ExtractError> { - Ok(one_edge( - "sh.tangled.repo.pull.status", - aturi_to_owned(&record.pull)?, - source, - )) + let Some(subject) = uri_subject_for_record(&record.pull) else { + return Ok(Vec::new()); + }; + Ok(one_edge("sh.tangled.repo.pull.status", subject, source)) } fn vouch_edges( @@ -452,7 +487,7 @@ fn vouch_edges( }; Ok(one_edge( "sh.tangled.graph.vouch", - did_to_aturi(&vouchee)?, + SubjectRef::Did(vouchee), source, )) } @@ -463,7 +498,7 @@ fn spindle_member_edges( ) -> Result, ExtractError> { Ok(one_edge( "sh.tangled.spindle.member", - did_to_aturi(&record.subject)?, + SubjectRef::Did(record.subject.clone()), source, )) } @@ -473,25 +508,24 @@ fn pipeline_edges( record: &Pipeline, ) -> Result, ExtractError> { let trigger_repo = &record.trigger_metadata.repo; - let repo_did = trigger_repo.repo_did.as_ref().unwrap_or(&trigger_repo.did); + let repo_did = trigger_repo + .repo_did + .as_ref() + .unwrap_or(&trigger_repo.did) + .clone(); Ok(one_edge( "sh.tangled.pipeline", - did_to_aturi(repo_did)?, + SubjectRef::Did(repo_did), source, )) } fn owner_self_edges(kind: &'static str, source: &AtUri) -> Vec { - owner_did_subject(source) - .map(|subject| one_edge(kind, subject, source)) + crate::ids::owner_did_from_aturi(source.as_ref()) + .map(|did| one_edge(kind, SubjectRef::Did(did), source)) .unwrap_or_default() } -fn owner_did_subject(source: &AtUri) -> Option> { - let did = crate::ids::did_from_aturi(source.as_ref())?; - AtUri::new_owned(format!("at://{}", did.as_ref())).ok() -} - #[cfg(test)] mod tests { use super::*; @@ -502,6 +536,18 @@ mod tests { AtUri::new_owned(s).unwrap() } + fn did(s: &str) -> Did { + Did::new_owned(s).unwrap() + } + + fn did_subj(s: &str) -> SubjectRef { + SubjectRef::Did(did(s)) + } + + fn uri_subj(s: &str) -> SubjectRef { + SubjectRef::Uri(at(s)) + } + fn nsid(s: &'static str) -> Nsid { Nsid::new_static(s).unwrap() } @@ -511,114 +557,69 @@ mod tests { parsed.primary_edges(&at(source)).expect("extract") } - fn extract_all(collection: &'static str, source: &str, body: serde_json::Value) -> Vec { - let parsed = Record::from_json_value(&nsid(collection), body).expect("parse"); - parsed.extract_edges(&at(source)).expect("extract") - } - #[test] - fn star_subject_did_keys_on_did_aturi() { + fn star_repo_variant_keys_on_did() { let edges = extract( "sh.tangled.feed.star", "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", json!({ "$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z", - "subjectDid": "did:plc:abalone" + "subject": { + "$type": "sh.tangled.feed.star#repo", + "did": "did:plc:abalone" + } }), ); assert_eq!( edges, vec![Edge { kind: nsid("sh.tangled.feed.star"), - subject: at("at://did:plc:abalone"), + subject: did_subj("did:plc:abalone"), source: at("at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz"), + sort_micros: 0, }] ); } #[test] - fn star_string_subject_uri_keeps_full_path() { + fn star_string_variant_keeps_full_path() { let edges = extract( "sh.tangled.feed.star", "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", json!({ "$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z", - "subject": "at://did:plc:teq/sh.tangled.string/k1" - }), - ); - assert_eq!(edges.len(), 1); - assert_eq!( - edges[0].subject, - at("at://did:plc:teq/sh.tangled.string/k1") - ); - } - - #[test] - fn star_repo_subject_uri_is_preserved_for_normalization() { - let edges = extract( - "sh.tangled.feed.star", - "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", - json!({ - "$type": "sh.tangled.feed.star", - "createdAt": "2026-05-01T00:00:00Z", - "subject": "at://did:plc:abalone/sh.tangled.repo/r1" + "subject": { + "$type": "sh.tangled.feed.star#string", + "uri": "at://did:plc:teq/sh.tangled.string/k1" + } }), ); assert_eq!(edges.len(), 1); assert_eq!( edges[0].subject, - at("at://did:plc:abalone/sh.tangled.repo/r1"), - "extract leaves repo URIs intact for the ingest-time normalization pass to resolve via slingshot or observed repoDID", - ); - } - - #[test] - fn star_bare_did_subject_uri_keeps_did_aturi() { - let edges = extract( - "sh.tangled.feed.star", - "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", - json!({ - "$type": "sh.tangled.feed.star", - "createdAt": "2026-05-01T00:00:00Z", - "subject": "at://did:plc:abalone" - }), + uri_subj("at://did:plc:teq/sh.tangled.string/k1") ); - assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); } #[test] - fn star_handle_authority_subject_emits_no_edge() { + fn star_string_variant_handle_authority_emits_no_edge() { let edges = extract( "sh.tangled.feed.star", "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", json!({ "$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z", - "subject": "at://oyster.cafe/sh.tangled.repo/r1" + "subject": { + "$type": "sh.tangled.feed.star#string", + "uri": "at://oyster.cafe/sh.tangled.repo/r1" + } }), ); assert!(edges.is_empty()); } - #[test] - fn star_subject_did_wins_over_subject_uri() { - let edges = extract( - "sh.tangled.feed.star", - "at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz", - json!({ - "$type": "sh.tangled.feed.star", - "createdAt": "2026-05-01T00:00:00Z", - "subject": "at://did:plc:teq/sh.tangled.string/k1", - "subjectDid": "did:plc:abalone" - }), - ); - assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); - } - #[test] fn follow_uses_subject_did() { let edges = extract( @@ -632,23 +633,23 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.graph.follow")); - assert_eq!(edges[0].subject, at("at://did:plc:bailey")); + assert_eq!(edges[0].subject, did_subj("did:plc:bailey")); } #[test] - fn issue_uses_repo_did() { + fn issue_keys_on_repo_did() { let edges = extract( "sh.tangled.repo.issue", "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", json!({ "$type": "sh.tangled.repo.issue", - "repoDid": "did:plc:abalone", + "repo": "did:plc:abalone", "title": "bug", "createdAt": "2026-05-01T00:00:00Z" }), ); assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } #[test] @@ -666,12 +667,12 @@ mod tests { assert_eq!(edges.len(), 1); assert_eq!( edges[0].subject, - at("at://did:plc:nel/sh.tangled.repo.issue/3lk1") + uri_subj("at://did:plc:nel/sh.tangled.repo.issue/3lk1") ); } #[test] - fn pull_uses_target_repo_did() { + fn pull_keys_on_target_repo_did() { let edges = extract( "sh.tangled.repo.pull", "at://did:plc:nel/sh.tangled.repo.pull/abcabcabcabcz", @@ -680,11 +681,11 @@ mod tests { "title": "feature", "createdAt": "2026-05-01T00:00:00Z", "rounds": [], - "target": {"repoDid": "did:plc:abalone", "branch": "main"} + "target": {"repo": "did:plc:abalone", "branch": "main"} }), ); assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } fn artifact_body(repo: Option<&str>, repo_did: Option<&str>) -> serde_json::Value { @@ -721,18 +722,18 @@ mod tests { ), ); assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:lyna")); + assert_eq!(edges[0].subject, did_subj("did:plc:lyna")); } #[test] - fn artifact_falls_back_to_repo_did() { + fn artifact_did_only_keys_on_did() { let edges = extract( "sh.tangled.repo.artifact", "at://did:plc:nel/sh.tangled.repo.artifact/abcabcabcabcz", artifact_body(None, Some("did:plc:abalone")), ); assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } #[test] @@ -755,7 +756,7 @@ mod tests { assert_eq!(edges.len(), 1); assert_eq!( edges[0].subject, - at("at://did:plc:abalone/sh.tangled.repo/r1"), + uri_subj("at://did:plc:abalone/sh.tangled.repo/r1"), ); } @@ -770,45 +771,43 @@ mod tests { } #[test] - fn issue_uri_only_is_preserved_for_normalization() { + fn collaborator_keys_on_repo_did() { let edges = extract( - "sh.tangled.repo.issue", - "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", + "sh.tangled.repo.collaborator", + "at://did:plc:nel/sh.tangled.repo.collaborator/abcabcabcabcz", json!({ - "$type": "sh.tangled.repo.issue", - "repo": "at://did:plc:abalone/sh.tangled.repo/r1", - "title": "bug", + "$type": "sh.tangled.repo.collaborator", + "repo": "did:plc:abalone", + "subject": "did:plc:lyna", "createdAt": "2026-05-01T00:00:00Z" }), ); assert_eq!(edges.len(), 1); - assert_eq!( - edges[0].subject, - at("at://did:plc:abalone/sh.tangled.repo/r1"), - ); + assert_eq!(edges[0].kind, nsid("sh.tangled.repo.collaborator")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } #[test] - fn pull_uri_only_is_preserved_for_normalization() { + fn ref_update_keys_on_repo_did() { let edges = extract( - "sh.tangled.repo.pull", - "at://did:plc:nel/sh.tangled.repo.pull/abcabcabcabcz", + "sh.tangled.git.refUpdate", + "at://did:plc:nel/sh.tangled.git.refUpdate/abcabcabcabcz", json!({ - "$type": "sh.tangled.repo.pull", - "title": "feature", - "createdAt": "2026-05-01T00:00:00Z", - "rounds": [], - "target": { - "repo": "at://did:plc:abalone/sh.tangled.repo/r1", - "branch": "main" + "$type": "sh.tangled.git.refUpdate", + "ref": "refs/heads/main", + "committerDid": "did:plc:lyna", + "repo": "did:plc:abalone", + "oldSha": "0000000000000000000000000000000000000000", + "newSha": "1111111111111111111111111111111111111111", + "meta": { + "isDefaultRef": true, + "commitCount": {} } }), ); assert_eq!(edges.len(), 1); - assert_eq!( - edges[0].subject, - at("at://did:plc:abalone/sh.tangled.repo/r1"), - ); + assert_eq!(edges[0].kind, nsid("sh.tangled.git.refUpdate")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } #[test] @@ -830,7 +829,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.label.op")); - assert_eq!(edges[0].subject, at(issue_uri)); + assert_eq!(edges[0].subject, uri_subj(issue_uri)); } #[test] @@ -848,7 +847,7 @@ mod tests { }), ); assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at(pull_uri)); + assert_eq!(edges[0].subject, uri_subj(pull_uri)); } #[test] @@ -872,7 +871,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.pipeline")); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); + assert_eq!(edges[0].subject, did_subj("did:plc:abalone")); } #[test] @@ -895,7 +894,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.pipeline")); - assert_eq!(edges[0].subject, at("at://did:plc:nel")); + assert_eq!(edges[0].subject, did_subj("did:plc:nel")); } #[test] @@ -914,7 +913,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.pipeline.status")); - assert_eq!(edges[0].subject, at(pipeline_uri)); + assert_eq!(edges[0].subject, uri_subj(pipeline_uri)); } #[test] @@ -931,7 +930,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.knot.member")); - assert_eq!(edges[0].subject, at("at://did:plc:nel")); + assert_eq!(edges[0].subject, did_subj("did:plc:nel")); } #[test] @@ -948,7 +947,7 @@ mod tests { ); assert_eq!(edges.len(), 1); assert_eq!(edges[0].kind, nsid("sh.tangled.spindle.member")); - assert_eq!(edges[0].subject, at("at://did:plc:olaren")); + assert_eq!(edges[0].subject, did_subj("did:plc:olaren")); } #[test] @@ -967,8 +966,9 @@ mod tests { edges, vec![Edge { kind: nsid("sh.tangled.repo"), - subject: at("at://did:plc:teq"), + subject: did_subj("did:plc:teq"), source: at("at://did:plc:teq/sh.tangled.repo/r1"), + sort_micros: 0, }] ); } @@ -1008,55 +1008,10 @@ mod tests { let edges = extract(collection, &source, body); assert_eq!(edges.len(), 1, "{collection} must produce one self-edge"); assert_eq!(edges[0].kind, nsid(collection)); - assert_eq!(edges[0].subject, at("at://did:plc:teq")); + assert_eq!(edges[0].subject, did_subj("did:plc:teq")); }); } - #[test] - fn pipeline_uses_repo_did_from_trigger_when_present() { - let edges = extract( - "sh.tangled.pipeline", - "at://did:plc:spindle/sh.tangled.pipeline/abcabcabcabcz", - json!({ - "$type": "sh.tangled.pipeline", - "workflows": [], - "triggerMetadata": { - "kind": "manual", - "repo": { - "did": "did:plc:teq", - "repoDid": "did:plc:abalone", - "knot": "nel.pet", - "defaultBranch": "main" - } - } - }), - ); - assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:abalone")); - } - - #[test] - fn pipeline_falls_back_to_owner_did_when_no_repo_did() { - let edges = extract( - "sh.tangled.pipeline", - "at://did:plc:spindle/sh.tangled.pipeline/abcabcabcabcz", - json!({ - "$type": "sh.tangled.pipeline", - "workflows": [], - "triggerMetadata": { - "kind": "manual", - "repo": { - "did": "did:plc:teq", - "knot": "nel.pet", - "defaultBranch": "main" - } - } - }), - ); - assert_eq!(edges.len(), 1); - assert_eq!(edges[0].subject, at("at://did:plc:teq")); - } - #[test] fn profile_and_vouch_emit_no_edges() { let profile = extract( diff --git a/crates/types/src/ids.rs b/crates/types/src/ids.rs index d3e0d15..04fbd59 100644 --- a/crates/types/src/ids.rs +++ b/crates/types/src/ids.rs @@ -4,14 +4,63 @@ use jacquard_common::types::nsid::Nsid; use jacquard_common::types::recordkey::Rkey; use jacquard_common::types::string::AtUri; +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum SubjectRef { + Did(Did), + Uri(AtUri), +} + +impl SubjectRef { + pub fn from_did(did: Did) -> Self { + Self::Did(did) + } + + pub fn from_uri(uri: AtUri) -> Self { + Self::Uri(uri) + } + + pub fn as_str(&self) -> &str { + match self { + Self::Did(d) => d.as_ref(), + Self::Uri(u) => u.as_ref(), + } + } + + pub fn as_did(&self) -> Option<&Did> { + match self { + Self::Did(d) => Some(d), + Self::Uri(_) => None, + } + } + + pub fn as_uri(&self) -> Option<&AtUri> { + match self { + Self::Uri(u) => Some(u), + Self::Did(_) => None, + } + } +} + +impl From> for SubjectRef { + fn from(did: Did) -> Self { + Self::Did(did) + } +} + +impl From> for SubjectRef { + fn from(uri: AtUri) -> Self { + Self::Uri(uri) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct EdgeKey { pub kind: Nsid, - pub subject: AtUri, + pub subject: SubjectRef, } impl EdgeKey { - pub fn new(kind: Nsid, subject: AtUri) -> Self { + pub fn new(kind: Nsid, subject: SubjectRef) -> Self { Self { kind, subject } } } @@ -32,7 +81,7 @@ pub fn nsid_static(s: &'static str) -> Nsid { Nsid::new_static(s).expect("compile-time NSID literal must validate") } -pub fn did_from_aturi(uri: &str) -> Option> { +pub fn owner_did_from_aturi(uri: &str) -> Option> { let rest = uri.strip_prefix("at://")?; let end = rest.find('/').unwrap_or(rest.len()); Did::new_owned(&rest[..end]).ok() @@ -47,28 +96,39 @@ mod tests { } #[test] - fn extract_did_from_full_uri() { + fn owner_did_from_full_uri() { assert_eq!( - did_from_aturi("at://did:plc:olaren/sh.tangled.feed.star/3lk1"), + owner_did_from_aturi("at://did:plc:olaren/sh.tangled.feed.star/3lk1"), Some(did("did:plc:olaren")), ); } #[test] - fn extract_did_from_did_only_uri() { + fn owner_did_from_did_only_uri() { assert_eq!( - did_from_aturi("at://did:plc:olaren"), + owner_did_from_aturi("at://did:plc:olaren"), Some(did("did:plc:olaren")) ); } #[test] - fn rejects_non_did_authority() { - assert_eq!(did_from_aturi("at://oyster.cafe/sh.tangled.repo/r1"), None); + fn owner_did_rejects_handle_authority() { + assert_eq!(owner_did_from_aturi("at://oyster.cafe/sh.tangled.repo/r1"), None); + } + + #[test] + fn owner_did_rejects_missing_scheme() { + assert_eq!(owner_did_from_aturi("did:plc:nel"), None); } #[test] - fn rejects_missing_scheme() { - assert_eq!(did_from_aturi("did:plc:nel"), None); + fn subject_ref_did_and_uri_hash_distinct() { + use std::collections::HashSet; + let mut s = HashSet::new(); + s.insert(SubjectRef::Did(did("did:plc:abalone"))); + s.insert(SubjectRef::Uri( + AtUri::new_owned("at://did:plc:abalone").unwrap(), + )); + assert_eq!(s.len(), 2, "did variant and uri variant must hash distinctly"); } } diff --git a/crates/types/src/legacy.rs b/crates/types/src/legacy.rs new file mode 100644 index 0000000..23133e7 --- /dev/null +++ b/crates/types/src/legacy.rs @@ -0,0 +1,162 @@ +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, Did, AtUri}; +use jacquard_common::types::value::Data; +use jacquard_common::{BosStr, DefaultStr}; +use serde::Deserialize; + +use crate::edges::ExtractError; +use crate::sh_tangled::repo::pull::Round as CanonRound; + +#[derive(Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename = "sh.tangled.repo.issue", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LegacyIssue { + pub created_at: Datetime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mentions: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub references: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_did: Option>, + pub title: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct LegacyTarget { + pub branch: S, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_did: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct LegacySource { + pub branch: S, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_did: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename = "sh.tangled.repo.pull", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LegacyPull { + pub created_at: Datetime, + pub title: S, + pub rounds: Vec>, + pub target: LegacyTarget, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mentions: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub references: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dependent_on: Option>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +#[derive(Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename = "sh.tangled.repo.collaborator", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LegacyCollaborator { + pub created_at: Datetime, + pub subject: Did, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_did: Option>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +#[derive(Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename = "sh.tangled.git.refUpdate", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LegacyRefUpdate { + pub committer_did: Did, + pub meta: crate::sh_tangled::git::ref_update::Meta, + pub new_sha: S, + pub old_sha: S, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_did: Option>, + pub r#ref: S, + pub repo_did: Did, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_name: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +#[derive(Debug, Deserialize)] +#[serde( + rename_all = "camelCase", + rename = "sh.tangled.feed.star", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LegacyStar { + pub created_at: Datetime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_did: Option>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +#[derive(Debug)] +pub enum LegacyRecord { + Issue(LegacyIssue), + Pull(LegacyPull), + Collaborator(LegacyCollaborator), + RefUpdate(LegacyRefUpdate), + Star(LegacyStar), +} + +impl LegacyRecord { + pub fn from_json_bytes(nsid: &str, bytes: &[u8]) -> Result { + match nsid { + "sh.tangled.repo.issue" => Ok(Self::Issue(serde_json::from_slice(bytes)?)), + "sh.tangled.repo.pull" => Ok(Self::Pull(serde_json::from_slice(bytes)?)), + "sh.tangled.repo.collaborator" => { + Ok(Self::Collaborator(serde_json::from_slice(bytes)?)) + } + "sh.tangled.git.refUpdate" => Ok(Self::RefUpdate(serde_json::from_slice(bytes)?)), + "sh.tangled.feed.star" => Ok(Self::Star(serde_json::from_slice(bytes)?)), + other => Err(ExtractError::UnknownCollection(other.into())), + } + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 1ca66d9..fd18c96 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -17,5 +17,6 @@ pub use _lex::*; pub mod edges; pub mod ids; +pub mod legacy; pub mod record; pub mod search; diff --git a/crates/types/src/search.rs b/crates/types/src/search.rs index a962377..317853e 100644 --- a/crates/types/src/search.rs +++ b/crates/types/src/search.rs @@ -173,10 +173,11 @@ fn repo_doc(source: &AtUri, r: &RepoRecord) -> SearchDoc if let Some(topics) = &r.topics { parts.extend(topics.iter().map(|t| t.as_str().to_owned())); } + let title = r.name.as_deref().unwrap_or(""); doc( source, "sh.tangled.repo", - r.name.as_str(), + title, parts, Some(r.created_at.timestamp()), r.repo_did.clone(), @@ -195,7 +196,7 @@ fn issue_doc(source: &AtUri, r: &Issue) -> SearchDoc { r.title.as_str(), body, Some(r.created_at.timestamp()), - r.repo_did.clone(), + Some(r.repo.clone()), ) } @@ -222,7 +223,7 @@ fn pull_doc(source: &AtUri, r: &Pull) -> SearchDoc { r.title.as_str(), body, Some(r.created_at.timestamp()), - r.target.repo_did.clone(), + Some(r.target.repo.clone()), ) } @@ -292,7 +293,7 @@ mod tests { "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", json!({ "$type": "sh.tangled.repo.issue", - "repoDid": "did:plc:abalone", + "repo": "did:plc:abalone", "title": "barnacle pagination overflow", "body": "scrolling resets when the cursor wraps", "createdAt": "2026-05-01T00:00:00Z" @@ -310,7 +311,7 @@ mod tests { "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", json!({ "$type": "sh.tangled.repo.issue", - "repoDid": "did:plc:abalone", + "repo": "did:plc:abalone", "title": "kelp ate my newline", "createdAt": "2026-05-01T00:00:00Z" }), @@ -346,7 +347,10 @@ mod tests { json!({ "$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z", - "subjectDid": "did:plc:abalone" + "subject": { + "$type": "sh.tangled.feed.star#repo", + "did": "did:plc:abalone" + } }), ) .expect("parse"); @@ -375,7 +379,7 @@ mod tests { fn from_json_bytes_round_trips_via_serialize() { let body = json!({ "$type": "sh.tangled.repo.issue", - "repoDid": "did:plc:abalone", + "repo": "did:plc:abalone", "title": "uni shell", "createdAt": "2026-05-01T00:00:00Z" }); @@ -389,7 +393,14 @@ mod tests { #[test] fn from_json_bytes_rejects_unknown_nsid() { - let body = json!({"$type": "sh.tangled.feed.star", "createdAt": "2026-05-01T00:00:00Z"}); + let body = json!({ + "$type": "sh.tangled.feed.star", + "createdAt": "2026-05-01T00:00:00Z", + "subject": { + "$type": "sh.tangled.feed.star#repo", + "did": "did:plc:abalone" + } + }); 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");