diff --git a/crates/buzz-core/src/artifact_sync.rs b/crates/buzz-core/src/artifact_sync.rs index f59a17dbc7..769f624d95 100644 --- a/crates/buzz-core/src/artifact_sync.rs +++ b/crates/buzz-core/src/artifact_sync.rs @@ -18,6 +18,28 @@ pub struct ArtifactSyncFilter { pub tags: Vec<(String, Vec)>, } +/// Tags NIP-AR fixes at creation. A head that fails a predicate on one of +/// them was never in scope and never will be. +const IMMUTABLE_TAGS: [&str; 2] = ["d", "type"]; + +impl ArtifactSyncFilter { + /// Whether `head` fails a predicate on an immutable tag, so no change to + /// it can be in scope (NIP-AR sync rule 3). Mutable tags are left to the + /// client's match rule: a head that stopped matching must still be sent. + pub fn never_in_scope(&self, head: &nostr::Event) -> bool { + self.tags + .iter() + .filter(|(name, _)| IMMUTABLE_TAGS.contains(&name.as_str())) + .any(|(name, values)| { + let first = head.tags.iter().find_map(|tag| match tag.as_slice() { + [tag_name, value, ..] if tag_name == name => Some(value), + _ => None, + }); + !first.is_some_and(|value| values.contains(value)) + }) + } +} + /// Whether a raw `REQ` filter is a sync: the current-state view. Other /// artifact views have no live form and stay HTTP-only. pub fn is_sync_filter(value: &serde_json::Value) -> bool { @@ -77,4 +99,35 @@ mod tests { assert!(parse_sync(&invalid).is_err(), "{invalid}"); } } + + #[test] + fn never_in_scope_checks_only_immutable_tags() { + let h = Uuid::new_v4().to_string(); + let filter = parse_sync( + &json!({"artifact":"current","#h":[h],"#type":["work.set"],"#status":["open"]}), + ) + .unwrap(); + let keys = nostr::Keys::generate(); + let head = |tags: &[[&str; 2]]| { + nostr::EventBuilder::new(nostr::Kind::Custom(45010), "") + .tags(tags.iter().map(|t| nostr::Tag::parse(t.to_vec()).unwrap())) + .sign_with_keys(&keys) + .unwrap() + }; + // Another type: never in scope, whatever its other tags. + assert!(filter.never_in_scope(&head(&[ + ["h", &h], + ["type", "work.goals"], + ["status", "open"] + ]))); + assert!(filter.never_in_scope(&head(&[["h", &h]]))); + // Same type but a mutable tag no longer matches: still sent, so the + // client can drop it. + assert!(!filter.never_in_scope(&head(&[ + ["h", &h], + ["type", "work.set"], + ["status", "done"] + ]))); + assert!(!filter.never_in_scope(&head(&[["h", &h], ["type", "work.set"]]))); + } } diff --git a/crates/buzz-relay/src/handlers/artifact_sync.rs b/crates/buzz-relay/src/handlers/artifact_sync.rs index 5ae724a430..f7b639b97f 100644 --- a/crates/buzz-relay/src/handlers/artifact_sync.rs +++ b/crates/buzz-relay/src/handlers/artifact_sync.rs @@ -547,6 +547,14 @@ async fn run_live( return; } }; + // A head of another type (or `d`) was never in scope; the change + // signal is per channel, so it reaches every sync there. + if head + .as_ref() + .is_some_and(|head| filter.never_in_scope(&head.event)) + { + continue; + } // A head the reader cannot read leaves only the source removal. let Some(event) = head.map(|head| head.event).or(removal) else { continue; diff --git a/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs b/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs index 6ffb4879e9..f2855286f5 100644 --- a/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/artifact_sync_postgres_tests.rs @@ -65,7 +65,7 @@ async fn ingest(f: &Fixture, keys: &Keys, event: Event) { assert!(result.accepted, "kind:{kind} refused: {}", result.message); } -/// Accept one artifact revision and return it. +/// Accept one `buzz.task` revision and return it. async fn revision( f: &Fixture, keys: &Keys, @@ -73,12 +73,25 @@ async fn revision( d: Uuid, op: &str, prev: Option<&Event>, +) -> Event { + typed_revision(f, keys, home, d, "buzz.task", op, prev).await +} + +/// Accept one artifact revision of type `ty` and return it. +async fn typed_revision( + f: &Fixture, + keys: &Keys, + home: Uuid, + d: Uuid, + ty: &str, + op: &str, + prev: Option<&Event>, ) -> Event { let mut tags = vec![ vec!["ar".to_owned(), "1".into()], vec!["d".into(), d.to_string()], vec!["h".into(), home.to_string()], - vec!["type".into(), "buzz.task".into()], + vec!["type".into(), ty.into()], vec!["op".into(), op.into()], ]; if op != "delete" { @@ -219,6 +232,18 @@ async fn sync_sends_one_cut_then_current_heads() { (message[0].as_str(), message[2]["id"].as_str()), (Some("EVENT"), Some(deleted.id.to_hex().as_str())) ); + // An artifact of another type in the same channel is never in scope. + typed_revision( + &f, + &owner, + home, + Uuid::new_v4(), + "buzz.note", + "create", + None, + ) + .await; + no_frame(&mut rx).await; // An ordinary chat message in the channel is not a change. let chat = EventBuilder::new(Kind::Custom(9), "hi") .tags([Tag::parse(["h", &home.to_string()]).unwrap()])