mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(workflows): preserve multi-channel listing semantics (#6009)
**Category:** fix **User Impact:** Workflow listings reliably include every accessible channel, including for users with more than 128 memberships and when connected to older relays. **Problem:** Multi-value `#h` filters could lose live delivery, apply channel scoping after SQL limits, mishandle partial authorization or revocation, and permit unbounded membership work. Desktop also submitted every channel in one request, exceeding the relay's new 128-value safety bound. **Solution:** Preserve NIP-01 OR semantics across relay query, count, and live-subscription paths while enforcing authorization and bounded explicit-channel work before database or Redis operations. Desktop keeps the older-relay-compatible one-channel-per-filter shape, sends filters in bounded batches, combines responses, and deduplicates signed events by event ID. <details> <summary>File changes</summary> **crates/buzz-db/src/event.rs** Distinguishes authorization channel scopes from explicit `#h` scopes in list and count SQL so requested channels are applied before limits without implicitly including global rows. **crates/buzz-relay/src/handlers/req.rs** Shares explicit-channel scope extraction and limits, preserves valid OR siblings when malformed branches cannot match, repairs request-local membership misses, and registers authorized live subscriptions per channel. **crates/buzz-relay/src/handlers/count.rs** Applies the same bounded explicit-channel authorization to COUNT and preserves channel scope when a multi-channel request narrows to one authorized channel. **crates/buzz-relay/src/api/bridge.rs** Brings HTTP query and count behavior in line with WebSocket semantics before SQL execution and rejects over-limit explicit-channel requests before membership I/O. **crates/buzz-relay/src/subscription.rs** Indexes multi-channel subscriptions by every authorized channel and shrinks, rather than destroys, their scope when one channel is revoked. **crates/buzz-relay/src/handlers/side_effects.rs** Releases only revoked channel topics and sends terminal closure only when no authorized channel remains. **crates/buzz-test-client/tests/e2e_relay.rs** Adds ignored relay integration coverage for multi-channel delivery and valid historical/live behavior with malformed or empty OR siblings. **desktop/src-tauri/src/commands/workflows.rs** Builds one single-channel filter per membership, submits at most 128 per relay request, combines batches, and deduplicates by immutable signed event ID. **desktop/src-tauri/src/commands/workflows_tests.rs** Covers filter compatibility, malformed input, 129-channel batching, and cross-batch event-ID deduplication. </details> ## Reproduction steps 1. Join multiple channels containing workflows, open **Workflows**, and confirm workflows from every accessible channel appear. 2. Repeat with more than 128 memberships and confirm the listing remains complete rather than failing the relay request. 3. Send a multi-value `#h` query/count and confirm only requested authorized channels affect SQL limits and counts. 4. Subscribe to channels A and B, revoke A, and confirm B continues delivering live events. 5. Subscribe with a valid channel branch plus a malformed or empty `#h` sibling and confirm valid history, EOSE, and post-EOSE live delivery still occur. ## Validation At pushed head `c419a923f05e483ab26c006a0b3a80cfb3c73844`: - Relay request tests: 53 passed. - Desktop full Rust unit suite: 2,468 passed, 17 ignored. - Relay E2E target compiled with `--no-run`. - Strict relay clippy passed. - Desktop Tauri clippy/check passed. - Pre-push Rust tests and Desktop Tauri checks passed. - Rust formatting and `git diff --check` passed. --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::State;
|
||||
@@ -103,34 +105,73 @@ pub async fn get_channel_workflows(
|
||||
Ok(events.iter().map(workflow_from_event).collect())
|
||||
}
|
||||
|
||||
/// Fetch workflows across many channels in a single relay round-trip.
|
||||
// Keep this aligned with the relay's aggregate explicit-`#h` request bound.
|
||||
// Each filter below carries exactly one explicit value so old relays retain the
|
||||
// known-compatible shape while current relays cannot reject large memberships.
|
||||
const WORKFLOW_QUERY_CHANNEL_BATCH_SIZE: usize = 128;
|
||||
|
||||
/// Fetch workflows across many channels using bounded relay round-trips.
|
||||
///
|
||||
/// The Workflows overview screen previously issued one `get_channel_workflows`
|
||||
/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N
|
||||
/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one
|
||||
/// query with all channel ids returns the same set. Each `WorkflowWire` carries
|
||||
/// its own `channel_id` (from the event's `h` tag), so the frontend can still
|
||||
/// group results by channel. Neither this nor the per-channel command sets a
|
||||
/// `limit`, so batching does not change result completeness.
|
||||
/// relay POSTs. This sends one single-channel filter per channel, in requests of
|
||||
/// at most 128 filters. Using one multi-value `#h` filter is equivalent under
|
||||
/// NIP-01, but older relays incorrectly narrowed that shape to its first
|
||||
/// channel. Each `WorkflowWire` carries its own `channel_id` (from the event's
|
||||
/// `h` tag), so the frontend can still group results by channel. Neither this
|
||||
/// nor the per-channel command sets a `limit`, so batching does not change
|
||||
/// result completeness. Results are deduplicated by signed event ID in case a
|
||||
/// caller supplies duplicate channel IDs.
|
||||
#[tauri::command]
|
||||
pub async fn get_channels_workflows(
|
||||
channel_ids: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<WorkflowWire>, String> {
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
let filter_batches = channel_workflow_filter_batches(channel_ids)?;
|
||||
let mut seen_event_ids = HashSet::new();
|
||||
let mut workflows = Vec::new();
|
||||
|
||||
for filters in filter_batches {
|
||||
let events = query_relay(&state, &filters).await?;
|
||||
append_unique_workflows(&mut workflows, &mut seen_event_ids, &events);
|
||||
}
|
||||
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#h": channel_ids,
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
Ok(workflows)
|
||||
}
|
||||
|
||||
Ok(events.iter().map(workflow_from_event).collect())
|
||||
fn append_unique_workflows(
|
||||
workflows: &mut Vec<WorkflowWire>,
|
||||
seen_event_ids: &mut HashSet<nostr::EventId>,
|
||||
events: &[nostr::Event],
|
||||
) {
|
||||
workflows.extend(
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| seen_event_ids.insert(event.id))
|
||||
.map(workflow_from_event),
|
||||
);
|
||||
}
|
||||
|
||||
fn channel_workflow_filter_batches(channel_ids: Vec<String>) -> Result<Vec<Vec<Value>>, String> {
|
||||
let filters = channel_workflow_filters(channel_ids)?;
|
||||
Ok(filters
|
||||
.chunks(WORKFLOW_QUERY_CHANNEL_BATCH_SIZE)
|
||||
.map(<[Value]>::to_vec)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn channel_workflow_filters(channel_ids: Vec<String>) -> Result<Vec<Value>, String> {
|
||||
channel_ids
|
||||
.into_iter()
|
||||
.map(|channel_id| {
|
||||
let channel_id = uuid::Uuid::parse_str(channel_id.trim())
|
||||
.map_err(|_| "invalid channel id".to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#h": [channel_id.to_string()],
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -192,6 +192,81 @@ fn workflow_wire_serializes_with_snake_case_keys() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_channel_workflow_query_uses_one_filter_per_channel() {
|
||||
let other_channel = "33333333-3333-3333-3333-333333333333";
|
||||
let filters = channel_workflow_filters(vec![CHAN.to_string(), other_channel.to_string()])
|
||||
.expect("valid channels");
|
||||
|
||||
assert_eq!(filters.len(), 2);
|
||||
assert_eq!(
|
||||
filters[0],
|
||||
serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#h": [CHAN],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
filters[1],
|
||||
serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#h": [other_channel],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_queries_batch_above_relay_explicit_channel_limit() {
|
||||
let channel_ids = (0..WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1)
|
||||
.map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string())
|
||||
.collect();
|
||||
let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels");
|
||||
|
||||
assert_eq!(batches.len(), 2);
|
||||
assert_eq!(batches[0].len(), WORKFLOW_QUERY_CHANNEL_BATCH_SIZE);
|
||||
assert_eq!(batches[1].len(), 1);
|
||||
assert!(batches.iter().flatten().all(|filter| filter["#h"]
|
||||
.as_array()
|
||||
.is_some_and(|values| values.len() == 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_query_results_are_deduplicated_by_event_id() {
|
||||
let first = wf_event(WF, CHAN, YAML);
|
||||
let second_workflow = "33333333-3333-3333-3333-333333333333";
|
||||
let second = wf_event(second_workflow, CHAN, YAML);
|
||||
let mut workflows = Vec::new();
|
||||
let mut seen_event_ids = HashSet::new();
|
||||
|
||||
append_unique_workflows(
|
||||
&mut workflows,
|
||||
&mut seen_event_ids,
|
||||
&[first.clone(), second.clone()],
|
||||
);
|
||||
append_unique_workflows(&mut workflows, &mut seen_event_ids, &[first, second]);
|
||||
|
||||
assert_eq!(workflows.len(), 2);
|
||||
assert_eq!(workflows[0].id, WF);
|
||||
assert_eq!(workflows[1].id, second_workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_workflow_filters_reject_malformed_or_blank_channel_ids() {
|
||||
for channel_id in ["not-a-uuid", "", " "] {
|
||||
let error = channel_workflow_filters(vec![channel_id.to_string()])
|
||||
.expect_err("malformed channel id must fail before querying the relay");
|
||||
assert_eq!(error, "invalid channel id");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_workflow_filters_accepts_empty_input() {
|
||||
assert_eq!(
|
||||
channel_workflow_filters(Vec::new()).expect("empty input is valid"),
|
||||
Vec::<Value>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_response_uses_persisted_run_id_contract() {
|
||||
let wire = trigger_wire_from_message(
|
||||
|
||||
Reference in New Issue
Block a user