mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(nip-ar): do not send a sync heads of a type it never matched
A sync registers for change signals by channel, so a revision of any type in a named channel woke every sync there, and each one sent the head. A Work board that syncs its three record types got every change three times. A head that fails the filter on an immutable tag (type or d) was never in scope, so it is now skipped. Heads that stop matching a mutable tag are still sent, as rule 3 requires. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -18,6 +18,28 @@ pub struct ArtifactSyncFilter {
|
||||
pub tags: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
/// 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"]])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()])
|
||||
|
||||
Reference in New Issue
Block a user