perf(desktop): batch workflows overview into one relay query (#h fanout)

L6 finding: WorkflowsView's allWorkflowsQuery issued one get_channel_workflows
relay POST per member channel (Promise.all fanout) — N round-trips scaling with
channel count on the Workflows overview.

A nostr #h filter matches ANY listed value, so one query with all channel ids
returns the identical set. Added get_channels_workflows(channel_ids) which
queries once; each WorkflowWire already carries its own channel_id (from the
event h tag), so WorkflowsView groups results client-side via a channelId->name
map. Neither the per-channel nor the batched command sets a limit, so batching
does not change result completeness (review-bar #3: no shared-limit truncation).
e2eBridge gains a matching get_channels_workflows mock so overview e2e still
resolves. Single-channel get_channel_workflows stays for useChannelWorkflowsQuery.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
2026-07-08 10:39:59 -04:00
co-authored by Tyler Longwell
parent af31c43860
commit af5d5e4087
5 changed files with 73 additions and 9 deletions
@@ -66,6 +66,36 @@ 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.
///
/// 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.
#[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 events = query_relay(
&state,
&[serde_json::json!({
"kinds": [30620],
"#h": channel_ids,
})],
)
.await?;
Ok(events.iter().map(workflow_from_event).collect())
}
#[tauri::command]
pub async fn get_workflow(
workflow_id: String,
+1
View File
@@ -569,6 +569,7 @@ pub fn run() {
parse_persona_files,
export_persona_to_json,
get_channel_workflows,
get_channels_workflows,
get_workflow,
create_workflow,
update_workflow,
@@ -10,7 +10,7 @@ import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
getChannelWorkflows,
getChannelsWorkflows,
triggerWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
@@ -83,15 +83,21 @@ export function WorkflowsView({
const allWorkflowsQuery = useQuery({
queryKey: allWorkflowsQueryKey(channelIdKey),
queryFn: async () => {
const results: WorkflowWithChannel[] = [];
await Promise.all(
memberChannels.map(async (channel) => {
const workflows = await getChannelWorkflows(channel.id);
for (const workflow of workflows) {
results.push({ workflow, channelName: channel.name });
}
}),
// Single batched relay query for all member channels, then group by the
// channel_id each workflow carries — replaces the per-channel fanout.
const channelNameById = new Map(
memberChannels.map((channel) => [channel.id, channel.name]),
);
const workflows = await getChannelsWorkflows(channelIds);
const results: WorkflowWithChannel[] = [];
for (const workflow of workflows) {
results.push({
workflow,
channelName: workflow.channelId
? (channelNameById.get(workflow.channelId) ?? "")
: "",
});
}
return results;
},
enabled: memberChannels.length > 0,
+16
View File
@@ -169,6 +169,22 @@ export async function getChannelWorkflows(
return raw.map(fromRawWorkflow);
}
/**
* 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.
*/
export async function getChannelsWorkflows(
channelIds: string[],
): Promise<Workflow[]> {
const raw = await invokeTauri<RawWorkflow[]>("get_channels_workflows", {
channelIds,
});
return raw.map(fromRawWorkflow);
}
export async function getWorkflow(workflowId: string): Promise<Workflow> {
const raw = await invokeTauri<RawWorkflow>("get_workflow", { workflowId });
return fromRawWorkflow(raw);
+11
View File
@@ -2368,6 +2368,13 @@ function handleGetChannelWorkflows(args: { channelId: string }) {
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
}
function handleGetChannelsWorkflows(args: { channelIds: string[] }) {
const ids = new Set(args.channelIds);
return mockWorkflows.filter(
(w) => w.channel_id != null && ids.has(w.channel_id),
);
}
function handleGetWorkflow(args: { workflowId: string }) {
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
@@ -8806,6 +8813,10 @@ export function maybeInstallE2eTauriMocks() {
return handleGetChannelWorkflows(
payload as Parameters<typeof handleGetChannelWorkflows>[0],
);
case "get_channels_workflows":
return handleGetChannelsWorkflows(
payload as Parameters<typeof handleGetChannelsWorkflows>[0],
);
case "get_workflow":
return handleGetWorkflow(
payload as Parameters<typeof handleGetWorkflow>[0],