From 32c39cdcf0051f38ccf5476264d0fcc49bb1e798 Mon Sep 17 00:00:00 2001 From: Lewis Date: Sat, 2 May 2026 20:43:47 +0300 Subject: [PATCH] refactor(types): record decode, owner self-edges Lewis: May this revision serve well! --- crates/types/Cargo.toml | 4 - crates/types/src/edges.rs | 433 +++++++++++++++++++++++++++++++------- crates/types/src/ids.rs | 54 +++++ crates/types/src/lib.rs | 11 + 4 files changed, 418 insertions(+), 84 deletions(-) create mode 100644 crates/types/src/ids.rs diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 3569015..d863a38 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -13,7 +13,6 @@ jacquard-derive = { workspace = true } jacquard-lexicon = { workspace = true } miette = { workspace = true } serde = { workspace = true } -serde_ipld_dagcbor = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -27,6 +26,3 @@ default = ["sh_tangled"] sh_tangled = [] streaming = ["jacquard-common/websocket"] -[lints.rust] -non_snake_case = "allow" -unused_imports = "allow" diff --git a/crates/types/src/edges.rs b/crates/types/src/edges.rs index ce621aa..e6bc245 100644 --- a/crates/types/src/edges.rs +++ b/crates/types/src/edges.rs @@ -43,8 +43,8 @@ pub struct Edge { #[derive(Debug, thiserror::Error)] pub enum ExtractError { - #[error("decode CBOR record body: {0}")] - Decode(#[from] serde_ipld_dagcbor::DecodeError), + #[error("decode JSON record body: {0}")] + DecodeJson(#[from] serde_json::Error), #[error("invalid AT-URI synthesised from record subject: {0}")] InvalidAtUri(#[from] AtStrError), #[error("unknown collection NSID: {0}")] @@ -81,78 +81,42 @@ pub enum Record { } impl Record { - pub fn decode>( + pub fn from_json_value>( collection: &Nsid, - bytes: &[u8], + value: serde_json::Value, ) -> Result { + let json = serde_json::to_string(&value)?; + macro_rules! parse { + ($variant:ident) => { + Ok(Self::$variant(serde_json::from_str(&json)?)) + }; + } match collection.as_ref() { - "sh.tangled.actor.profile" => { - Ok(Self::Profile(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.feed.reaction" => { - Ok(Self::Reaction(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.feed.star" => Ok(Self::Star(serde_ipld_dagcbor::from_slice(bytes)?)), - "sh.tangled.git.refUpdate" => { - Ok(Self::RefUpdate(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.graph.follow" => { - Ok(Self::Follow(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.graph.vouch" => { - Ok(Self::Vouch(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.knot" => Ok(Self::Knot(serde_ipld_dagcbor::from_slice(bytes)?)), - "sh.tangled.knot.member" => { - Ok(Self::KnotMember(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.label.definition" => Ok(Self::LabelDefinition( - serde_ipld_dagcbor::from_slice(bytes)?, - )), - "sh.tangled.label.op" => { - Ok(Self::LabelOp(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.pipeline" => { - Ok(Self::Pipeline(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.pipeline.status" => Ok(Self::PipelineStatus( - serde_ipld_dagcbor::from_slice(bytes)?, - )), - "sh.tangled.publicKey" => { - Ok(Self::PublicKey(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo" => Ok(Self::Repo(serde_ipld_dagcbor::from_slice(bytes)?)), - "sh.tangled.repo.artifact" => { - Ok(Self::Artifact(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.collaborator" => { - Ok(Self::Collaborator(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.issue" => { - Ok(Self::Issue(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.issue.comment" => { - Ok(Self::IssueComment(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.issue.state" => { - Ok(Self::IssueState(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.pull" => Ok(Self::Pull(serde_ipld_dagcbor::from_slice(bytes)?)), - "sh.tangled.repo.pull.comment" => { - Ok(Self::PullComment(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.repo.pull.status" => { - Ok(Self::PullStatus(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.spindle" => { - Ok(Self::Spindle(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.spindle.member" => { - Ok(Self::SpindleMember(serde_ipld_dagcbor::from_slice(bytes)?)) - } - "sh.tangled.string" => { - Ok(Self::TangledString(serde_ipld_dagcbor::from_slice(bytes)?)) - } + "sh.tangled.actor.profile" => parse!(Profile), + "sh.tangled.feed.reaction" => parse!(Reaction), + "sh.tangled.feed.star" => parse!(Star), + "sh.tangled.git.refUpdate" => parse!(RefUpdate), + "sh.tangled.graph.follow" => parse!(Follow), + "sh.tangled.graph.vouch" => parse!(Vouch), + "sh.tangled.knot" => parse!(Knot), + "sh.tangled.knot.member" => parse!(KnotMember), + "sh.tangled.label.definition" => parse!(LabelDefinition), + "sh.tangled.label.op" => parse!(LabelOp), + "sh.tangled.pipeline" => parse!(Pipeline), + "sh.tangled.pipeline.status" => parse!(PipelineStatus), + "sh.tangled.publicKey" => parse!(PublicKey), + "sh.tangled.repo" => parse!(Repo), + "sh.tangled.repo.artifact" => parse!(Artifact), + "sh.tangled.repo.collaborator" => parse!(Collaborator), + "sh.tangled.repo.issue" => parse!(Issue), + "sh.tangled.repo.issue.comment" => parse!(IssueComment), + "sh.tangled.repo.issue.state" => parse!(IssueState), + "sh.tangled.repo.pull" => parse!(Pull), + "sh.tangled.repo.pull.comment" => parse!(PullComment), + "sh.tangled.repo.pull.status" => parse!(PullStatus), + "sh.tangled.spindle" => parse!(Spindle), + "sh.tangled.spindle.member" => parse!(SpindleMember), + "sh.tangled.string" => parse!(TangledString), other => Err(ExtractError::UnknownCollection(other.into())), } } @@ -209,15 +173,16 @@ impl Record { Self::PullComment(r) => pull_comment_edges(source, r), Self::PullStatus(r) => pull_status_edges(source, r), Self::SpindleMember(r) => spindle_member_edges(source, r), - Self::Profile(_) - | Self::Vouch(_) - | Self::Knot(_) - | Self::LabelDefinition(_) - | Self::Pipeline(_) - | Self::PublicKey(_) - | Self::Repo(_) - | Self::Spindle(_) - | Self::TangledString(_) => Ok(Vec::new()), + Self::Pipeline(r) => pipeline_edges(source, r), + Self::Knot(_) => Ok(owner_self_edges("sh.tangled.knot", source)), + Self::LabelDefinition(_) => { + Ok(owner_self_edges("sh.tangled.label.definition", source)) + } + Self::PublicKey(_) => Ok(owner_self_edges("sh.tangled.publicKey", source)), + Self::Repo(_) => Ok(owner_self_edges("sh.tangled.repo", source)), + Self::Spindle(_) => Ok(owner_self_edges("sh.tangled.spindle", source)), + Self::TangledString(_) => Ok(owner_self_edges("sh.tangled.string", source)), + Self::Profile(_) | Self::Vouch(_) => Ok(Vec::new()), } } } @@ -424,3 +389,311 @@ fn spindle_member_edges( source, )) } + +fn pipeline_edges( + source: &AtUri, + 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); + Ok(one_edge("sh.tangled.pipeline", did_to_aturi(repo_did)?, source)) +} + +fn owner_self_edges(kind: &'static str, source: &AtUri) -> Vec { + owner_did_subject(source) + .map(|subject| one_edge(kind, subject, 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::*; + 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) -> Vec { + let parsed = Record::from_json_value(&nsid(collection), body).expect("parse"); + parsed.extract_edges(&at(source)).expect("extract") + } + + #[test] + fn star_subject_repo_uses_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", + "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"), + source: at("at://did:plc:olaren/sh.tangled.feed.star/abcabcabcabcz"), + }]); + } + + #[test] + fn star_subject_string_uses_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": { + "$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:teq/sh.tangled.string/k1")); + } + + #[test] + fn follow_uses_subject_did() { + let edges = extract( + "sh.tangled.graph.follow", + "at://did:plc:olaren/sh.tangled.graph.follow/abcabcabcabcz", + json!({ + "$type": "sh.tangled.graph.follow", + "createdAt": "2026-05-01T00:00:00Z", + "subject": "did:plc:bailey" + }), + ); + 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")); + } + + #[test] + fn issue_uses_repo_did() { + let edges = extract( + "sh.tangled.repo.issue", + "at://did:plc:nel/sh.tangled.repo.issue/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo.issue", + "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")); + } + + #[test] + fn issue_comment_uses_issue_uri() { + let edges = extract( + "sh.tangled.repo.issue.comment", + "at://did:plc:nel/sh.tangled.repo.issue.comment/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo.issue.comment", + "issue": "at://did:plc:nel/sh.tangled.repo.issue/3lk1", + "body": "thoughts", + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].subject, at("at://did:plc:nel/sh.tangled.repo.issue/3lk1")); + } + + #[test] + fn pull_uses_target_repo_did() { + let edges = extract( + "sh.tangled.repo.pull", + "at://did:plc:nel/sh.tangled.repo.pull/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo.pull", + "title": "feature", + "createdAt": "2026-05-01T00:00:00Z", + "rounds": [], + "target": {"repo": "did:plc:abalone", "branch": "main"} + }), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].subject, at("at://did:plc:abalone")); + } + + fn artifact_body(repo: Option<&str>, repo_did: Option<&str>) -> serde_json::Value { + let mut body = json!({ + "$type": "sh.tangled.repo.artifact", + "createdAt": "2026-05-01T00:00:00Z", + "name": "out.bin", + "tag": {"$bytes": "AAAAAAAAAAAAAAAAAAAAAAAAAAA="}, + "artifact": { + "$type": "blob", + "ref": {"$link": "bafkreigh2akiscaildc7gnvtklbsfhdgwz72eolmpckbqr5ej26byp3uli"}, + "mimeType": "application/octet-stream", + "size": 12 + } + }); + let obj = body.as_object_mut().expect("artifact_body returns object"); + if let Some(r) = repo { obj.insert("repo".into(), json!(r)); } + if let Some(d) = repo_did { obj.insert("repoDid".into(), json!(d)); } + body + } + + #[test] + fn artifact_prefers_repo_uri_over_repo_did() { + let edges = extract( + "sh.tangled.repo.artifact", + "at://did:plc:nel/sh.tangled.repo.artifact/abcabcabcabcz", + artifact_body(Some("at://did:plc:abalone/sh.tangled.repo/r1"), Some("did:plc:lyna")), + ); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].subject, at("at://did:plc:abalone/sh.tangled.repo/r1")); + } + + #[test] + fn artifact_falls_back_to_repo_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")); + } + + #[test] + fn artifact_without_repo_emits_no_edge() { + let edges = extract( + "sh.tangled.repo.artifact", + "at://did:plc:nel/sh.tangled.repo.artifact/abcabcabcabcz", + artifact_body(None, None), + ); + assert!(edges.is_empty()); + } + + #[test] + fn repo_self_edge_keys_on_owner_did() { + let edges = extract( + "sh.tangled.repo", + "at://did:plc:teq/sh.tangled.repo/r1", + json!({ + "$type": "sh.tangled.repo", + "knot": "oyster.cafe", + "createdAt": "2026-05-01T00:00:00Z" + }), + ); + assert_eq!(edges, vec![Edge { + kind: nsid("sh.tangled.repo"), + subject: at("at://did:plc:teq"), + source: at("at://did:plc:teq/sh.tangled.repo/r1"), + }]); + } + + #[test] + fn knot_spindle_publickey_string_label_all_self_edge_to_owner() { + let cases = [ + ("sh.tangled.knot", json!({"$type": "sh.tangled.knot", "createdAt": "2026-05-01T00:00:00Z"})), + ("sh.tangled.spindle", json!({"$type": "sh.tangled.spindle", "createdAt": "2026-05-01T00:00:00Z"})), + ("sh.tangled.publicKey", json!({"$type": "sh.tangled.publicKey", "createdAt": "2026-05-01T00:00:00Z", "key": "ssh-ed25519 AAA", "name": "laptop"})), + ("sh.tangled.string", json!({"$type": "sh.tangled.string", "createdAt": "2026-05-01T00:00:00Z", "filename": "f.txt", "description": "x", "contents": "hi"})), + ( + "sh.tangled.label.definition", + json!({ + "$type": "sh.tangled.label.definition", + "createdAt": "2026-05-01T00:00:00Z", + "name": "bug", + "valueType": {"type": "boolean", "format": "any"}, + "scope": ["sh.tangled.repo.issue"] + }), + ), + ]; + cases.into_iter().for_each(|(collection, body)| { + let source = format!("at://did:plc:teq/{collection}/r1"); + 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")); + }); + } + + #[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( + "sh.tangled.actor.profile", + "at://did:plc:teq/sh.tangled.actor.profile/self", + json!({"$type": "sh.tangled.actor.profile", "bluesky": false}), + ); + assert!(profile.is_empty()); + let vouch = extract( + "sh.tangled.graph.vouch", + "at://did:plc:teq/sh.tangled.graph.vouch/r1", + json!({"$type": "sh.tangled.graph.vouch", "createdAt": "2026-05-01T00:00:00Z", "kind": "vouch"}), + ); + assert!(vouch.is_empty()); + } + + #[test] + fn unknown_collection_errors_with_unknown_collection() { + let err = Record::from_json_value( + &nsid("app.bsky.feed.post"), + json!({"$type": "app.bsky.feed.post", "text": "hi"}), + ) + .expect_err("foreign collection"); + assert!(matches!(err, ExtractError::UnknownCollection(_))); + } +} diff --git a/crates/types/src/ids.rs b/crates/types/src/ids.rs new file mode 100644 index 0000000..f47bcb9 --- /dev/null +++ b/crates/types/src/ids.rs @@ -0,0 +1,54 @@ +use jacquard_common::types::did::Did; +use jacquard_common::types::nsid::Nsid; +use jacquard_common::types::string::AtUri; +use jacquard_common::DefaultStr; + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct EdgeKey { + pub kind: Nsid, + pub subject: AtUri, +} + +impl EdgeKey { + pub fn new(kind: Nsid, subject: AtUri) -> Self { + Self { kind, subject } + } +} + +pub fn 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() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn did(s: &'static str) -> Did { + Did::new_static(s).unwrap() + } + + #[test] + fn extract_did_from_full_uri() { + assert_eq!( + 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() { + assert_eq!(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); + } + + #[test] + fn rejects_missing_scheme() { + assert_eq!(did_from_aturi("did:plc:nel"), None); + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 3147fd4..8e81fbf 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -1,8 +1,19 @@ extern crate alloc; #[path = "_lex/lib.rs"] +#[allow(non_snake_case, unused_imports)] +#[allow( + clippy::absurd_extreme_comparisons, + clippy::manual_strip, + clippy::needless_update, + clippy::new_ret_no_self, + clippy::new_without_default, + clippy::should_implement_trait, + clippy::type_complexity, +)] mod _lex; pub use _lex::*; pub mod edges; +pub mod ids; -- 2.51.2