refactor(nip-ar): request sync as a REQ with artifact current

A sync is the current-state filter of /query sent on a REQ. The REQ
transport already means stored set, EOSE, then live, so the filter needs
no separate "sync" value. Other artifact views stay HTTP-only.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-21 19:48:23 -04:00
parent 60fee4dfc3
commit bd2cdf0923
4 changed files with 26 additions and 23 deletions

View File

@@ -1,4 +1,6 @@
//! NIP-AR sync filter: `{"artifact":"sync", "#h":[...], ...}` on a `REQ`.
//! NIP-AR sync filter: `{"artifact":"current", "#h":[...], ...}` on a `REQ`.
//! The same current-state filter as HTTP `/query`; the `REQ` transport makes
//! it a sync (complete set, `EOSE`, then live heads).
use crate::artifact::{canonical_uuid, parse_predicates};
use uuid::Uuid;
@@ -16,9 +18,10 @@ pub struct ArtifactSyncFilter {
pub tags: Vec<(String, Vec<String>)>,
}
/// Whether a raw filter asks for sync rather than a one-shot artifact view.
/// 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 {
value.get("artifact").and_then(|v| v.as_str()) == Some("sync")
value.get("artifact").and_then(|v| v.as_str()) == Some("current")
}
/// Parse a sync filter. It requires `#h` with canonical channel UUIDs and
@@ -29,7 +32,7 @@ pub fn parse_sync(value: &serde_json::Value) -> Result<ArtifactSyncFilter, &'sta
.as_object()
.ok_or("artifact filter must be an object")?;
if !is_sync_filter(value) {
return Err("artifact must be \"sync\"");
return Err("artifact must be \"current\"");
}
let tags = parse_predicates(object, &["artifact", "kinds"])?;
let mut channels = Vec::new();
@@ -56,20 +59,20 @@ mod tests {
fn sync_filter_requires_channels_and_rejects_paging() {
let a = Uuid::new_v4().to_string();
let parsed = parse_sync(
&json!({"artifact":"sync","#h":[a, a],"#type":["buzz.task"],"kinds":[45010]}),
&json!({"artifact":"current","#h":[a, a],"#type":["buzz.task"],"kinds":[45010]}),
)
.unwrap();
assert_eq!(parsed.channels.len(), 1);
assert_eq!(parsed.tags.len(), 2);
for invalid in [
json!({"artifact":"sync"}),
json!({"artifact":"sync","#type":["buzz.task"]}),
json!({"artifact":"sync","#h":[a],"limit":10}),
json!({"artifact":"sync","#h":[a],"offset":0}),
json!({"artifact":"sync","#h":[a],"kinds":[45011]}),
json!({"artifact":"sync","#h":["not-a-uuid"]}),
json!({"artifact":"sync","#h":[a],"since":1}),
json!({"artifact":"current","#h":[a]}),
json!({"artifact":"current"}),
json!({"artifact":"current","#type":["buzz.task"]}),
json!({"artifact":"current","#h":[a],"limit":10}),
json!({"artifact":"current","#h":[a],"offset":0}),
json!({"artifact":"current","#h":[a],"kinds":[45011]}),
json!({"artifact":"current","#h":["not-a-uuid"]}),
json!({"artifact":"current","#h":[a],"since":1}),
json!({"artifact":"history","#h":[a]}),
] {
assert!(parse_sync(&invalid).is_err(), "{invalid}");
}

View File

@@ -183,7 +183,7 @@ async fn no_frame(rx: &mut mpsc::Receiver<WsMessage>) {
const PRIVATE: &[&[&str]] = &[&["visibility", "private"]];
fn sync_filter(channels: &[Uuid]) -> Value {
json!({"artifact": "sync", "#h": channels, "#type": ["buzz.task"]})
json!({"artifact": "current", "#h": channels, "#type": ["buzz.task"]})
}
/// Rules 2–4 over the real pipeline: the initial set in `d` order, `EOSE`,

View File

@@ -25,7 +25,7 @@ pub enum ClientMessage {
/// Optional per-filter composite cursor tiebreaks from raw extension fields.
before_ids: Vec<Option<Vec<u8>>>,
},
/// A REQ whose single filter is a NIP-AR sync (`"artifact":"sync"`).
/// A REQ whose single filter is a NIP-AR sync (`"artifact":"current"`).
/// An invalid sync filter is answered with `CLOSED`, so the parse error
/// travels to the handler.
ArtifactSync {
@@ -47,8 +47,8 @@ pub enum ClientMessage {
Auth(Event),
}
/// Artifact queries are HTTP-only; reject rather than silently drop their
/// predicates on the generic WebSocket path.
/// Artifact views other than `current` (a sync on `REQ`) are HTTP-only;
/// reject rather than silently drop their predicates on the generic path.
fn reject_artifact_query_filters(filters: &[serde_json::Value]) -> Result<()> {
use buzz_core::artifact::{route_filter, FilterRoute};
for filter in filters {
@@ -56,7 +56,7 @@ fn reject_artifact_query_filters(filters: &[serde_json::Value]) -> Result<()> {
FilterRoute::Generic => {}
FilterRoute::Artifact => {
return Err(RelayError::InvalidMessage(
"artifact queries require HTTP /query or /count".into(),
"only artifact \"current\" has a REQ form; other views require HTTP /query or /count".into(),
))
}
FilterRoute::Rejected(reason) => return Err(RelayError::InvalidMessage(reason.into())),
@@ -328,7 +328,7 @@ mod tests {
#[test]
fn sync_filters_route_to_artifact_sync() {
let h = uuid::Uuid::new_v4();
let sync = format!(r##"{{"artifact":"sync","#h":["{h}"]}}"##);
let sync = format!(r##"{{"artifact":"current","#h":["{h}"]}}"##);
let parsed = super::ClientMessage::parse(&format!(r#"["REQ","s",{sync}]"#)).unwrap();
assert!(matches!(
parsed,
@@ -336,7 +336,7 @@ mod tests {
));
for request in [
format!(r#"["REQ","s",{sync},{{"kinds":[1]}}]"#),
r#"["REQ","s",{"artifact":"sync"}]"#.to_owned(),
r#"["REQ","s",{"artifact":"current"}]"#.to_owned(),
] {
assert!(
matches!(

View File

@@ -111,7 +111,7 @@ Sync is additive. A client that does not use it keeps working unchanged, and a r
sequenceDiagram
participant C as Client
participant R as Relay
C->>R: REQ sub {"artifact":"sync", "#h":[...], "#type":[...]}
C->>R: REQ sub {"artifact":"current", "#h":[...], "#type":[...]}
Note over R: Register live interest first,<br/>then read all matching heads<br/>in one snapshot
R-->>C: EVENT sub head (0..n)
R-->>C: EOSE sub (the set is complete at one cut)
@@ -124,10 +124,10 @@ sequenceDiagram
### Sync subscription
**Request.** Sync is a NIP-01 `REQ` whose single filter has `"artifact": "sync"`:
**Request.** Sync is a NIP-01 `REQ` whose single filter has `"artifact": "current"`. It is the current-state filter of HTTP `/query`; the `REQ` transport adds the complete set, `EOSE` and live heads. The client does not page: the relay streams the whole set until `EOSE`. Other views (`history`, `head`) have no `REQ` form and stay HTTP-only:
```json
["REQ", "s1", {"artifact": "sync", "#h": ["<channel>"], "#type": ["buzz.task"]}]
["REQ", "s1", {"artifact": "current", "#h": ["<channel>"], "#type": ["buzz.task"]}]
```
The filter MUST include `#h` with 1 or more channels. It accepts the same `#name` predicates and limits as a NIP-AR current-state query, with the same AND/OR rules, and also `kinds: [45010]`. It accepts no `limit` or `offset`. A relay rejects a filter without `#h`, or with an unknown key, with `CLOSED`.