diff --git a/bobbin/crates/types/src/edges.rs b/bobbin/crates/types/src/edges.rs --- a/bobbin/crates/types/src/edges.rs +++ b/bobbin/crates/types/src/edges.rs @@ -160,7 +160,10 @@ 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)) + let globals = global_edges(&primary); + let mut out = append_mirror_edges(primary, source); + out.extend(globals); + Ok(out) } pub fn sort_micros_for(&self, source: &AtUri) -> u64 { @@ -282,6 +285,28 @@ source: source.clone(), sort_micros: 0, }] +} + +const GLOBAL_KINDS: &[&str] = &[ + "sh.tangled.feed.star", + "sh.tangled.graph.follow", + "sh.tangled.repo", +]; + +/// One `(kind, Global)` edge per allowlisted primary kind, sharing the primary's +/// source + sort_micros. `seen` dedups if a record yields two primaries of a kind. +fn global_edges(primary: &[Edge]) -> Vec { + let mut seen = std::collections::HashSet::new(); + primary + .iter() + .filter(|e| GLOBAL_KINDS.contains(&e.kind.as_ref()) && seen.insert(e.kind.as_ref())) + .map(|e| Edge { + kind: e.kind.clone(), + subject: SubjectRef::Global, + source: e.source.clone(), + sort_micros: e.sort_micros, + }) + .collect() } const MIRROR_KINDS: &[(&str, &str)] = &[ @@ -603,6 +628,11 @@ parsed.primary_edges(&at(source)).expect("extract") } + fn extract_full(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") + } + fn repo_body(source: Option<&str>) -> serde_json::Value { let mut body = json!({ "$type": "sh.tangled.repo", @@ -674,6 +704,65 @@ .all(|e| e.kind.as_ref() != REPO_SOURCE_EDGE_KIND), "a clone url is not a repo we can count against", ); + } + + #[test] + fn global_edge_emitted_for_allowlisted_kinds() { + let cases = [ + ( + "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" } + }), + ), + ( + "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" + }), + ), + ( + "sh.tangled.repo", + "at://did:plc:olaren/sh.tangled.repo/abcabcabcabcz", + json!({ + "$type": "sh.tangled.repo", + "createdAt": "2026-05-01T00:00:00Z", + "knot": "knot.example.com", + "name": "myrepo" + }), + ), + ]; + for (collection, source, body) in cases { + let edges = extract_full(collection, source, body); + let global: Vec<&Edge> = edges + .iter() + .filter(|e| e.subject == SubjectRef::Global) + .collect(); + assert_eq!(global.len(), 1, "{collection} should emit one global edge"); + assert_eq!(global[0].kind, nsid(collection)); + assert_eq!(global[0].source, at(source)); + } + } + + #[test] + fn no_global_edge_for_non_allowlisted_kind() { + let edges = extract_full( + "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!(edges.iter().all(|e| e.subject != SubjectRef::Global)); } #[test] diff --git a/bobbin/crates/types/src/ids.rs b/bobbin/crates/types/src/ids.rs --- a/bobbin/crates/types/src/ids.rs +++ b/bobbin/crates/types/src/ids.rs @@ -8,6 +8,7 @@ pub enum SubjectRef { Did(Did), Uri(AtUri), + Global, } impl SubjectRef { @@ -23,20 +24,21 @@ match self { Self::Did(d) => d.as_ref(), Self::Uri(u) => u.as_ref(), + Self::Global => "*", } } pub fn as_did(&self) -> Option<&Did> { match self { Self::Did(d) => Some(d), - Self::Uri(_) => None, + Self::Uri(_) | Self::Global => None, } } pub fn as_uri(&self) -> Option<&AtUri> { match self { Self::Uri(u) => Some(u), - Self::Did(_) => None, + Self::Did(_) | Self::Global => None, } } } diff --git a/bobbin/crates/xrpc/src/enrich.rs b/bobbin/crates/xrpc/src/enrich.rs --- a/bobbin/crates/xrpc/src/enrich.rs +++ b/bobbin/crates/xrpc/src/enrich.rs @@ -133,7 +133,7 @@ } } if !per_ref.is_empty() { - stats.insert(subject_string(reference), Value::Object(per_ref)); + stats.insert(reference.as_str().to_owned(), Value::Object(per_ref)); } } @@ -205,13 +205,6 @@ (SubjectShape::BareDidOrOneOfCollections(_), SubjectRef::Did(_)) => true, (SubjectShape::AnyAtUri, SubjectRef::Uri(_)) => true, _ => false, - } -} - -fn subject_string(reference: &SubjectRef) -> String { - match reference { - SubjectRef::Did(d) => d.as_ref().to_owned(), - SubjectRef::Uri(u) => u.as_ref().to_owned(), } }