Skip to content

Commit f752ec0

Browse files
committed
fix(queries): close React Query key/fetch-arg drift cache collisions
Several query hooks fetched with an identifier that was absent from their queryKey, so distinct fetch args shared one cache entry. Thread the missing args into the key factories and update all callsites/invalidations. - organization: useOrganization always fetched the ACTIVE org via getFullOrganization() while caching under detail(orgId). Pass orgId through to the better-auth call (query.organizationId); active-org behavior unchanged. - logs: logKeys.detail now keys on (workspaceId, logId) to prevent cross- workspace collision; updated useLogDetail, useLogByExecutionId, prefetchLogDetail, useCancelExecution optimistic path, and external callsites. - inbox: inboxKeys.taskList now includes cursor/limit (pagination args were sent but omitted from the key); keepPreviousData pagination UX preserved. - a2a: narrow create/update byWorkflows() invalidation to byWorkflow(ws, wf) since their responses reliably carry both ids; delete/publish stay broad. Not bugs (verified, left unchanged): - kb/connectors update/delete invalidate knowledgeKeys.detail(kbId), which is a prefix of connectorKeys.all(kbId) — connector list/detail are invalidated transitively by React Query prefix matching. Harness: add a key-fetch-arg-drift check to check-react-query-patterns.ts that flags a camelCase identifier the queryFn forwards into the fetch but is absent from the queryKey (excludes the requestJson contract arg, PascalCase/SCREAMING constants, and signal/pageParam machinery). Document the rule in sim-queries.md. tables.useTable annotated rq-lint-allow (tableId globally unique; workspaceId is only an authz scope).
1 parent f5d42ce commit f752ec0

9 files changed

Lines changed: 199 additions & 30 deletions

File tree

.claude/rules/sim-queries.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const entityKeys = {
2525

2626
Never use inline query keys — always use the factory.
2727

28+
**Every identifier the `queryFn` uses MUST appear in the `queryKey`.** If the fetch is scoped by `workspaceId`, `cursor`, `limit`, an org id, etc., those values must be part of the key — otherwise distinct fetch args share one cache entry (a cross-tenant / per-param cache collision). The lone exception is a globally-unique id used as the key while a second fetch arg is only an authz scope that cannot collide; annotate those with `// rq-lint-allow: <reason>`. Enforced by the `key-fetch-arg-drift` check in `scripts/check-react-query-patterns.ts`.
29+
2830
## File Structure
2931

3032
```typescript
@@ -142,4 +144,4 @@ const handler = useCallback(() => {
142144

143145
## Enforcement
144146

145-
`scripts/check-react-query-patterns.ts` (`bun run check:react-query`, run in CI) statically enforces these conventions: every `useQuery`/`useInfiniteQuery`/`useSuspenseQuery` declares an explicit `staleTime`, inline `queryFn`s destructure `signal`, `queryKey`s reference a colocated factory rather than an inline literal, and every `*Keys` factory in `hooks/queries/**` exposes an `all` root key. `hooks/queries/**` is a zero-tolerance zone; the rest of `apps/sim/**` is ratcheted against `scripts/check-react-query-patterns.baseline.json`. For a genuine exception, put `// rq-lint-allow: <reason>` on the line directly above the flagged construct.
147+
`scripts/check-react-query-patterns.ts` (`bun run check:react-query`, run in CI) statically enforces these conventions: every `useQuery`/`useInfiniteQuery`/`useSuspenseQuery` declares an explicit `staleTime`, inline `queryFn`s destructure `signal`, `queryKey`s reference a colocated factory rather than an inline literal, every `*Keys` factory in `hooks/queries/**` exposes an `all` root key, and every identifier the `queryFn` forwards into the fetch also appears in the `queryKey` (`key-fetch-arg-drift`). `hooks/queries/**` is a zero-tolerance zone; the rest of `apps/sim/**` is ratcheted against `scripts/check-react-query-patterns.baseline.json`. For a genuine exception, put `// rq-lint-allow: <reason>` on the line directly above the flagged construct.

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,9 @@ const RESOURCE_INVALIDATORS: Record<
255255
scheduledtask: (qc, wId) => {
256256
qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) })
257257
},
258-
log: (qc, _wId, id) => {
258+
log: (qc, wId, id) => {
259259
qc.invalidateQueries({ queryKey: logKeys.details() })
260-
qc.invalidateQueries({ queryKey: logKeys.detail(id) })
260+
qc.invalidateQueries({ queryKey: logKeys.detail(wId, id) })
261261
},
262262
/**
263263
* Integrations are sourced from the static integration catalog

apps/sim/app/workspace/[workspaceId]/logs/logs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ export default function Logs() {
525525

526526
try {
527527
const detailLog = await queryClient.fetchQuery({
528-
queryKey: logKeys.detail(logId),
528+
queryKey: logKeys.detail(workspaceId, logId),
529529
queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
530530
staleTime: 30 * 1000,
531531
})

apps/sim/hooks/queries/a2a/agents.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,19 @@ export function useCreateA2AAgent() {
101101

102102
return useMutation({
103103
mutationFn: createA2AAgent,
104-
onSettled: () => {
104+
onSettled: (data) => {
105105
queryClient.invalidateQueries({
106106
queryKey: a2aAgentKeys.lists(),
107107
})
108-
queryClient.invalidateQueries({
109-
queryKey: a2aAgentKeys.byWorkflows(),
110-
})
108+
if (data) {
109+
queryClient.invalidateQueries({
110+
queryKey: a2aAgentKeys.byWorkflow(data.workspaceId, data.workflowId),
111+
})
112+
} else {
113+
queryClient.invalidateQueries({
114+
queryKey: a2aAgentKeys.byWorkflows(),
115+
})
116+
}
111117
},
112118
})
113119
}
@@ -136,16 +142,22 @@ export function useUpdateA2AAgent() {
136142

137143
return useMutation({
138144
mutationFn: updateA2AAgent,
139-
onSettled: (_data, _error, variables) => {
145+
onSettled: (data, _error, variables) => {
140146
queryClient.invalidateQueries({
141147
queryKey: a2aAgentKeys.lists(),
142148
})
143149
queryClient.invalidateQueries({
144150
queryKey: a2aAgentKeys.detail(variables.agentId),
145151
})
146-
queryClient.invalidateQueries({
147-
queryKey: a2aAgentKeys.byWorkflows(),
148-
})
152+
if (data) {
153+
queryClient.invalidateQueries({
154+
queryKey: a2aAgentKeys.byWorkflow(data.workspaceId, data.workflowId),
155+
})
156+
} else {
157+
queryClient.invalidateQueries({
158+
queryKey: a2aAgentKeys.byWorkflows(),
159+
})
160+
}
149161
},
150162
})
151163
}

apps/sim/hooks/queries/inbox.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export const inboxKeys = {
2626
senders: () => [...inboxKeys.all, 'sender'] as const,
2727
senderList: (workspaceId: string) => [...inboxKeys.senders(), workspaceId] as const,
2828
tasks: () => [...inboxKeys.all, 'task'] as const,
29-
taskList: (workspaceId: string, status?: string) =>
30-
[...inboxKeys.tasks(), workspaceId, status ?? 'all'] as const,
29+
taskList: (workspaceId: string, status?: string, cursor?: string, limit?: number) =>
30+
[...inboxKeys.tasks(), workspaceId, status ?? 'all', cursor ?? '', limit ?? ''] as const,
3131
}
3232

3333
type InboxTaskStatusFilter = InboxTaskStatus
@@ -88,7 +88,7 @@ export function useInboxTasks(
8888
opts: { status?: InboxTaskStatusFilter; cursor?: string; limit?: number } = {}
8989
) {
9090
return useQuery({
91-
queryKey: inboxKeys.taskList(workspaceId, opts.status),
91+
queryKey: inboxKeys.taskList(workspaceId, opts.status, opts.cursor, opts.limit),
9292
queryFn: ({ signal }) => fetchInboxTasks(workspaceId, opts, signal),
9393
enabled: Boolean(workspaceId),
9494
staleTime: 15 * 1000,

apps/sim/hooks/queries/logs.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export const logKeys = {
3737
list: (workspaceId: string | undefined, filters: LogFilters) =>
3838
[...logKeys.lists(), workspaceId ?? '', filters] as const,
3939
details: () => [...logKeys.all, 'detail'] as const,
40-
detail: (logId: string | undefined) => [...logKeys.details(), logId ?? ''] as const,
40+
detail: (workspaceId: string | undefined, logId: string | undefined) =>
41+
[...logKeys.details(), workspaceId ?? '', logId ?? ''] as const,
4142
byExecutionAll: () => [...logKeys.all, 'byExecution'] as const,
4243
byExecution: (workspaceId: string | undefined, executionId: string | undefined) =>
4344
[...logKeys.byExecutionAll(), workspaceId ?? '', executionId ?? ''] as const,
@@ -189,7 +190,7 @@ export function useLogDetail(
189190
options?: UseLogDetailOptions
190191
) {
191192
return useQuery({
192-
queryKey: logKeys.detail(logId),
193+
queryKey: logKeys.detail(workspaceId, logId),
193194
queryFn: ({ signal }) => fetchLogDetail(logId as string, workspaceId as string, signal),
194195
enabled: Boolean(logId) && Boolean(workspaceId) && (options?.enabled ?? true),
195196
refetchInterval: options?.refetchInterval ?? false,
@@ -212,7 +213,7 @@ export function useLogByExecutionId(
212213
query: { workspaceId: workspaceId as string },
213214
signal,
214215
})
215-
queryClient.setQueryData(logKeys.detail(data.id), data)
216+
queryClient.setQueryData(logKeys.detail(workspaceId, data.id), data)
216217
return data
217218
},
218219
enabled: Boolean(workspaceId) && Boolean(executionId),
@@ -222,7 +223,7 @@ export function useLogByExecutionId(
222223

223224
export function prefetchLogDetail(queryClient: QueryClient, logId: string, workspaceId: string) {
224225
queryClient.prefetchQuery({
225-
queryKey: logKeys.detail(logId),
226+
queryKey: logKeys.detail(workspaceId, logId),
226227
queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal),
227228
staleTime: 30 * 1000,
228229
})
@@ -315,6 +316,7 @@ export function useCancelExecution() {
315316
})
316317

317318
let affectedLogId: string | null = null
319+
let affectedWorkspaceId: string | undefined
318320
queryClient.setQueriesData<InfiniteData<LogsPage>>({ queryKey: logKeys.lists() }, (old) => {
319321
if (!old) return old
320322
return {
@@ -324,6 +326,7 @@ export function useCancelExecution() {
324326
logs: page.logs.map((log) => {
325327
if (log.executionId !== executionId) return log
326328
affectedLogId = log.id
329+
affectedWorkspaceId = log.workflow?.workspaceId ?? undefined
327330
return { ...log, status: 'cancelling' }
328331
}),
329332
})),
@@ -332,23 +335,31 @@ export function useCancelExecution() {
332335

333336
let previousDetail: WorkflowLogDetail | undefined
334337
if (affectedLogId) {
335-
previousDetail = queryClient.getQueryData<WorkflowLogDetail>(logKeys.detail(affectedLogId))
338+
previousDetail = queryClient.getQueryData<WorkflowLogDetail>(
339+
logKeys.detail(affectedWorkspaceId, affectedLogId)
340+
)
336341
if (previousDetail) {
337-
queryClient.setQueryData<WorkflowLogDetail>(logKeys.detail(affectedLogId), {
338-
...previousDetail,
339-
status: 'cancelling',
340-
})
342+
queryClient.setQueryData<WorkflowLogDetail>(
343+
logKeys.detail(affectedWorkspaceId, affectedLogId),
344+
{
345+
...previousDetail,
346+
status: 'cancelling',
347+
}
348+
)
341349
}
342350
}
343351

344-
return { previousQueries, affectedLogId, previousDetail }
352+
return { previousQueries, affectedLogId, affectedWorkspaceId, previousDetail }
345353
},
346354
onError: (_err, _variables, context) => {
347355
for (const [queryKey, data] of context?.previousQueries ?? []) {
348356
queryClient.setQueryData(queryKey, data)
349357
}
350358
if (context?.affectedLogId && context.previousDetail !== undefined) {
351-
queryClient.setQueryData(logKeys.detail(context.affectedLogId), context.previousDetail)
359+
queryClient.setQueryData(
360+
logKeys.detail(context.affectedWorkspaceId, context.affectedLogId),
361+
context.previousDetail
362+
)
352363
}
353364
},
354365
onSettled: () => {

apps/sim/hooks/queries/organization.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,18 @@ export function useOrganizations() {
174174
}
175175

176176
/**
177-
* Fetch a specific organization by ID
177+
* Fetch a specific organization by ID.
178+
*
179+
* `getFullOrganization` defaults to the active organization when no
180+
* `organizationId` is supplied; passing `orgId` through scopes the result to the
181+
* requested org so it is cached under the correct `organizationKeys.detail(orgId)`
182+
* (no cross-org cache collision). The active-org caller passes the active org's
183+
* id, so its behavior is unchanged.
178184
*/
179-
async function fetchOrganization(_signal?: AbortSignal) {
180-
const response = await client.organization.getFullOrganization()
185+
async function fetchOrganization(orgId: string, _signal?: AbortSignal) {
186+
const response = await client.organization.getFullOrganization({
187+
query: { organizationId: orgId },
188+
})
181189
return response.data
182190
}
183191

@@ -187,7 +195,7 @@ async function fetchOrganization(_signal?: AbortSignal) {
187195
export function useOrganization(orgId: string) {
188196
return useQuery({
189197
queryKey: organizationKeys.detail(orgId),
190-
queryFn: ({ signal }) => fetchOrganization(signal),
198+
queryFn: ({ signal }) => fetchOrganization(orgId, signal),
191199
enabled: !!orgId,
192200
staleTime: 30 * 1000,
193201
placeholderData: keepPreviousData,

apps/sim/hooks/queries/tables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export function useTablesList(
265265
* Fetch a single table by id.
266266
*/
267267
export function useTable(workspaceId: string | undefined, tableId: string | undefined) {
268+
// rq-lint-allow: tableId is a globally-unique id; workspaceId is only an authz scope on the fetch and cannot collide across workspaces
268269
return useQuery({
269270
queryKey: tableKeys.detail(tableId ?? ''),
270271
queryFn: ({ signal }) => fetchTable(workspaceId as string, tableId as string, signal),

0 commit comments

Comments
 (0)