Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions crates/buzz-relay/src/handlers/req.rs
Original file line number Diff line number Diff line change
Expand Up @@ -855,18 +855,26 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool {
}

/// Extract a channel UUID from a single filter's `#h` tag.
///
/// Returns `Some` only when the filter has **exactly one** `#h` value that
/// parses as a UUID. Multi-value `#h` is NIP-01 OR ("match any of these
/// channels") and must **not** be collapsed into a single SQL `channel_id`
/// predicate — that would silently drop events in every other listed channel
/// (Desktop Workflows overview used one multi-`#h` filter for all member
/// channels and only received the first channel's workflows).
///
/// When this returns `None` for multi-`#h`, callers leave `channel_id` unset,
/// apply the accessible-channel scope, and rely on NIP-01 post-filter matching
/// (same shape as the WS REQ path's `per_filter_channel` for `vs.len() != 1`).
fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
for (tag_key, tag_values) in filter.generic_tags.iter() {
let key = tag_key.to_string();
if key == "h" {
for val in tag_values {
if let Ok(id) = val.parse::<uuid::Uuid>() {
return Some(id);
}
}
let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
filter.generic_tags.get(&h).and_then(|vs| {
if vs.len() == 1 {
vs.iter().next()?.parse::<uuid::Uuid>().ok()
} else {
None
}
}
None
})
}

/// Convert a single NIP-01 filter into an [`EventQuery`] for the database.
Expand Down Expand Up @@ -1586,6 +1594,44 @@ mod tests {
assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id));
}

#[test]
fn extract_channel_id_from_filter_single_h_pushes_channel() {
let channel_id = uuid::Uuid::new_v4();
let filter = filter_with_channel(channel_id);
assert_eq!(extract_channel_id_from_filter(&filter), Some(channel_id));
}

#[test]
fn extract_channel_id_from_filter_multi_h_does_not_collapse() {
// Regression: multi-value #h must not pick the first iterated UUID as
// SQL channel_id. That dropped every other channel's workflows from the
// Desktop overview's batched query.
let channel_a = uuid::Uuid::new_v4();
let channel_b = uuid::Uuid::new_v4();
let h = SingleLetterTag::lowercase(Alphabet::H);
let filter = Filter::new().custom_tags(
h,
[channel_a.to_string(), channel_b.to_string()],
);
assert_eq!(
extract_channel_id_from_filter(&filter),
None,
"multi-#h is NIP-01 OR and must leave channel_id unset"
);

// Downstream: no single-channel predicate → access scope can push the
// full accessible set; NIP-01 post-filter keeps the matching channels.
let community =
buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4());
let params = filter_to_query_params(&filter, None, community);
assert!(params.channel_id.is_none());
}

#[test]
fn extract_channel_id_from_filter_empty_h_returns_none() {
assert_eq!(extract_channel_id_from_filter(&Filter::new()), None);
}

#[test]
fn test_search_filter_detection() {
let search_filter = Filter::new().search("hello world");
Expand Down
43 changes: 28 additions & 15 deletions desktop/src-tauri/src/commands/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,34 @@ pub async fn get_channel_workflows(
Ok(events.iter().map(workflow_from_event).collect())
}

/// Build the NIP-01 filter list used by [`get_channels_workflows`].
///
/// One single-`#h` filter per channel (NIP-01 ORs them). Do not collapse into a
/// multi-value `#h` filter — unfixed relays SQL-scoped multi-`#h` to one channel.
fn channels_workflows_filters(channel_ids: &[String]) -> Vec<serde_json::Value> {
channel_ids
.iter()
.map(|channel_id| {
serde_json::json!({
"kinds": [30620],
"#h": [channel_id],
})
})
.collect()
}

/// Fetch workflows across many channels in a single relay round-trip.
///
/// 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.
/// The Workflows overview previously fanned out one `get_channel_workflows`
/// request per member channel (`Promise.all`). We still want one HTTP POST, but
/// we must not pack every channel into a single multi-value `#h` filter:
/// production relays historically collapsed multi-`#h` to one SQL `channel_id`
/// and dropped workflows from every other channel (only the DM-scoped card
/// appeared). NIP-01 OR of **N single-`#h` filters** is one round-trip and
/// returns the full set on both fixed and unfixed relays.
///
/// Each `WorkflowWire` carries its own `channel_id` (from the event's `h` tag)
/// so the frontend can still group by channel. No per-filter `limit` is set.
#[tauri::command]
pub async fn get_channels_workflows(
channel_ids: Vec<String>,
Expand All @@ -84,14 +103,8 @@ pub async fn get_channels_workflows(
return Ok(Vec::new());
}

let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [30620],
"#h": channel_ids,
})],
)
.await?;
let filters = channels_workflows_filters(&channel_ids);
let events = query_relay(&state, &filters).await?;

Ok(events.iter().map(workflow_from_event).collect())
}
Expand Down
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/commands/workflows_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ fn malformed_yaml_yields_empty_object_not_error() {
assert_eq!(wf.name, WF);
}

#[test]
fn channels_workflows_filters_emits_one_single_h_filter_per_channel() {
// Regression: must not pack all channels into one multi-value #h filter.
let a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string();
let b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string();
let filters = channels_workflows_filters(&[a.clone(), b.clone()]);
assert_eq!(filters.len(), 2);
assert_eq!(
filters[0],
serde_json::json!({"kinds": [30620], "#h": [a]})
);
assert_eq!(
filters[1],
serde_json::json!({"kinds": [30620], "#h": [b]})
);
}

#[test]
fn channels_workflows_filters_empty_input_is_empty() {
assert!(channels_workflows_filters(&[]).is_empty());
}
}

#[test]
fn scalar_yaml_document_yields_empty_object() {
// A bare scalar parses as valid YAML but isn't an object; treat as empty.
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/shared/api/tauriWorkflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,10 @@ export async function getChannelWorkflows(
* Fetch workflows across many channels in a single relay round-trip.
*
* Replaces the per-channel `Promise.all(getChannelWorkflows)` fanout on the
* Workflows overview: the backend `#h` filter matches any listed channel, and
* each returned workflow carries its own `channelId` so callers can group.
* Workflows overview. The Tauri command issues N single-`#h` filters (NIP-01
* OR) rather than one multi-value `#h` filter, so unfixed relays that collapse
* multi-`#h` to a single SQL channel still return every channel's workflows.
* Each result carries its own `channelId` so callers can group.
*/
export async function getChannelsWorkflows(
channelIds: string[],
Expand Down