Skip to content

Commit 1e89eb7

Browse files
committed
fix(queries): close six stale-data gaps found auditing the query layer
Each was verified against the mutation that changes the data and the keys that expose it, not taken on report. - Workspace usage/credits were invalidated nowhere in the app. Six sites already refreshed subscriptionKeys after credits moved — post-run, post-wand, limit edits, upgrades, top-ups — and none touched workspace usage, so the credits chip and the run gate held their page-load values until a reload. Adds one shared invalidateWorkspaceUsage and calls it from all six. - Knowledge-base list doc counts went stale: document upload, delete, and bulk delete invalidated only the detail key, though the list carries docCount. - Plan switches that do not redirect refreshed only the host context, leaving subscription and credit state showing the previous plan. - The copilot tool-event handler invalidated a raw workflowKeys.list, which covers only the active scope and skips the selector prefix; it now uses the shared invalidateWorkflowLists like the other thirteen call sites. - scheduleKeys.byId was a strict prefix of scheduleKeys.schedule, so the two addressings aliased, and nothing invalidated byId. De-aliased and invalidated. Not changed: the CSV preview key already folds in the file version and storage key, so a content update addresses a different cache entry — version-in-key is the mechanism there, not a missing invalidation. Tests added for the usage and knowledge fixes, both proven to fail without them. The other four live in files that cannot load in a git worktree (pre-existing postcss resolution failure), so CI is where they first run.
1 parent 5230c29 commit 1e89eb7

11 files changed

Lines changed: 166 additions & 17 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
2828
import { deploymentKeys } from '@/hooks/queries/deployments'
2929
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
30-
import { workflowKeys } from '@/hooks/queries/workflows'
30+
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
3131

3232
type ToolEvent = Extract<PersistedStreamEventEnvelope, { type: 'tool' }>
3333

@@ -58,15 +58,17 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
5858
if (deployedWorkflowId && typeof out?.isDeployed === 'boolean') {
5959
deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.info(deployedWorkflowId) })
6060
deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(deployedWorkflowId) })
61-
deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) })
61+
void invalidateWorkflowLists(deps.queryClient, deps.workspaceId)
6262
}
6363
}
6464

6565
if (FOLDER_TOOL_NAMES.has(name) && isSuccess) {
6666
deps.queryClient.invalidateQueries({ queryKey: folderKeys.list(deps.workspaceId) })
6767
}
6868
if (WORKFLOW_MUTATION_TOOL_NAMES.has(name) && isSuccess) {
69-
deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) })
69+
// `rm` archives, so the archived list moves too — and the shared helper also
70+
// refreshes the workflow selector lists that `@`-mentions and pickers read.
71+
void invalidateWorkflowLists(deps.queryClient, deps.workspaceId, ['active', 'archived'])
7072
}
7173

7274
const extractedResources =

‎apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,11 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
209209
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))
210210
logger.info(`Successfully started processing ${uploadedDocuments.length} documents`)
211211

212-
await queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) })
212+
await Promise.all([
213+
queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }),
214+
/** The knowledge-base list rows carry `docCount`, so an upload changes them too. */
215+
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }),
216+
])
213217

214218
return uploadedDocuments
215219
} catch (err) {

‎apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
99
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
1010
import { CREDIT_TIERS } from '@/lib/billing/constants'
1111
import { getPlanTierCredits, isEnterprise, isFree, isPro, isTeam } from '@/lib/billing/plan-helpers'
12+
import { subscriptionKeys } from '@/hooks/queries/subscription'
1213
import { workspaceHostKeys } from '@/hooks/queries/workspace-host'
14+
import { invalidateWorkspaceUsage } from '@/hooks/queries/workspace-usage'
1315

1416
const PRO_TIER = CREDIT_TIERS[0]
1517
const MAX_TIER = CREDIT_TIERS[1]
@@ -89,8 +91,20 @@ export function useUpgradeState({
8991
}
9092
}, [ownerBilling.billingInterval, subscription.isPaid])
9193

92-
const refreshHostContext = useCallback(
93-
() => queryClient.invalidateQueries({ queryKey: workspaceHostKeys.detail(workspaceId) }),
94+
/**
95+
* A non-redirect plan switch settles server-side immediately, so every read that
96+
* describes the plan has to be refetched — the host context the page renders from,
97+
* the subscription/usage reads the billing surfaces share, and the workspace credit
98+
* availability that drives the credits chip and the run gate.
99+
*/
100+
const refreshBillingState = useCallback(
101+
() =>
102+
Promise.all([
103+
queryClient.invalidateQueries({ queryKey: workspaceHostKeys.detail(workspaceId) }),
104+
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
105+
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
106+
invalidateWorkspaceUsage(queryClient),
107+
]),
94108
[queryClient, workspaceId]
95109
)
96110

@@ -123,9 +137,9 @@ export function useUpgradeState({
123137
await requestJson(billingSwitchPlanContract, {
124138
body: { targetPlanName: subscription.plan, interval, workspaceId },
125139
})
126-
await refreshHostContext()
140+
await refreshBillingState()
127141
},
128-
[isLegacyPlan, refreshHostContext, subscription.plan, workspaceId]
142+
[isLegacyPlan, refreshBillingState, subscription.plan, workspaceId]
129143
)
130144

131145
const currentCredits = getPlanTierCredits(subscription.plan)
@@ -154,11 +168,11 @@ export function useUpgradeState({
154168
workspaceId,
155169
},
156170
})
157-
await refreshHostContext()
171+
await refreshBillingState()
158172
} catch (e) {
159173
toast.error(getErrorMessage(e, 'Failed to upgrade'))
160174
}
161-
}, [subscription.isTeam, isAnnual, refreshHostContext, workspaceId])
175+
}, [subscription.isTeam, isAnnual, refreshBillingState, workspaceId])
162176

163177
const onUpgradeToOtherTier = useCallback(async () => {
164178
const onMax =
@@ -170,11 +184,11 @@ export function useUpgradeState({
170184
await requestJson(billingSwitchPlanContract, {
171185
body: { targetPlanName, workspaceId },
172186
})
173-
await refreshHostContext()
187+
await refreshBillingState()
174188
} catch (e) {
175189
toast.error(getErrorMessage(e, 'Failed to switch plan'))
176190
}
177-
}, [subscription.plan, subscription.isTeam, refreshHostContext, workspaceId])
191+
}, [subscription.plan, subscription.isTeam, refreshBillingState, workspaceId])
178192

179193
return {
180194
isLoading: false,

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { readSSEStream } from '@/lib/core/utils/sse'
1111
import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences'
1212
import type { GenerationType } from '@/blocks/types'
1313
import { subscriptionKeys } from '@/hooks/queries/subscription'
14+
import { invalidateWorkspaceUsage } from '@/hooks/queries/workspace-usage'
1415
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
1516
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1617

@@ -302,6 +303,7 @@ export function useWand({
302303

303304
setTimeout(() => {
304305
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
306+
void invalidateWorkspaceUsage(queryClient)
305307
}, 1000)
306308
} catch (error: any) {
307309
if (error.name === 'AbortError') {

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { hasExecutionResult } from '@/executor/utils/errors'
6363
import { coerceValue } from '@/executor/utils/start-block'
6464
import { subscriptionKeys } from '@/hooks/queries/subscription'
6565
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
66+
import { invalidateWorkspaceUsage } from '@/hooks/queries/workspace-usage'
6667
import {
6768
isExecutionStreamHttpError,
6869
SSEEventHandlerError,
@@ -924,6 +925,7 @@ export function useWorkflowExecution() {
924925
// Invalidate subscription queries to update usage
925926
setTimeout(() => {
926927
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
928+
void invalidateWorkspaceUsage(queryClient)
927929
}, 1000)
928930

929931
safeEnqueue(encodeSSE({ event: 'final', data: result }))
@@ -1460,6 +1462,7 @@ export function useWorkflowExecution() {
14601462
}
14611463
setTimeout(() => {
14621464
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() })
1465+
void invalidateWorkspaceUsage(queryClient)
14631466
}, 1000)
14641467
}
14651468
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
requestJson: vi.fn(),
9+
useMutation: vi.fn(),
10+
invalidateQueries: vi.fn(),
11+
}))
12+
13+
vi.mock('@tanstack/react-query', () => ({
14+
keepPreviousData: Symbol('keepPreviousData'),
15+
useInfiniteQuery: vi.fn(),
16+
useMutation: mocks.useMutation,
17+
useQuery: vi.fn(),
18+
useQueryClient: vi.fn(() => ({ invalidateQueries: mocks.invalidateQueries })),
19+
}))
20+
21+
vi.mock('@sim/emcn', () => ({
22+
toast: { error: vi.fn(), success: vi.fn() },
23+
}))
24+
25+
vi.mock('@/lib/api/client/request', () => ({
26+
requestJson: mocks.requestJson,
27+
}))
28+
29+
import { useBulkDocumentOperation, useDeleteDocument } from '@/hooks/queries/kb/knowledge'
30+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
31+
32+
interface CapturedMutation {
33+
onSettled: (data: unknown, error: unknown, variables: Record<string, unknown>) => void
34+
}
35+
36+
function captureMutation(build: () => unknown): CapturedMutation {
37+
let captured: CapturedMutation | undefined
38+
mocks.useMutation.mockImplementation((options: CapturedMutation) => {
39+
captured = options
40+
return {}
41+
})
42+
build()
43+
if (!captured) throw new Error('useMutation was not called')
44+
return captured
45+
}
46+
47+
describe('knowledge document mutations', () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks()
50+
})
51+
52+
it('invalidates the knowledge-base lists when a document is deleted', () => {
53+
const mutation = captureMutation(() => useDeleteDocument())
54+
55+
mutation.onSettled(undefined, undefined, { knowledgeBaseId: 'kb-1', documentId: 'doc-1' })
56+
57+
expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: knowledgeKeys.lists() })
58+
})
59+
60+
it('invalidates the knowledge-base lists on a bulk delete', () => {
61+
const mutation = captureMutation(() => useBulkDocumentOperation())
62+
63+
mutation.onSettled(undefined, undefined, { knowledgeBaseId: 'kb-1', operation: 'delete' })
64+
65+
expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: knowledgeKeys.lists() })
66+
})
67+
68+
it('leaves the knowledge-base lists alone on a bulk enable', () => {
69+
const mutation = captureMutation(() => useBulkDocumentOperation())
70+
71+
mutation.onSettled(undefined, undefined, { knowledgeBaseId: 'kb-1', operation: 'enable' })
72+
73+
expect(mocks.invalidateQueries).not.toHaveBeenCalledWith({ queryKey: knowledgeKeys.lists() })
74+
})
75+
})

‎apps/sim/hooks/queries/kb/knowledge.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,10 @@ export function useDeleteDocument() {
539539
queryClient.invalidateQueries({
540540
queryKey: knowledgeKeys.detail(knowledgeBaseId),
541541
})
542+
/** The knowledge-base list rows carry `docCount`, so removing a document changes them too. */
543+
queryClient.invalidateQueries({
544+
queryKey: knowledgeKeys.lists(),
545+
})
542546
},
543547
})
544548
}
@@ -573,10 +577,16 @@ export function useBulkDocumentOperation() {
573577

574578
return useMutation({
575579
mutationFn: bulkDocumentOperation,
576-
onSettled: (_data, _error, { knowledgeBaseId }) => {
580+
onSettled: (_data, _error, { knowledgeBaseId, operation }) => {
577581
queryClient.invalidateQueries({
578582
queryKey: knowledgeKeys.detail(knowledgeBaseId),
579583
})
584+
/** Only a bulk delete changes the `docCount` the knowledge-base list rows render. */
585+
if (operation === 'delete') {
586+
queryClient.invalidateQueries({
587+
queryKey: knowledgeKeys.lists(),
588+
})
589+
}
580590
},
581591
})
582592
}

‎apps/sim/hooks/queries/schedules.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ export const scheduleKeys = {
2727
details: () => [...scheduleKeys.all, 'detail'] as const,
2828
schedule: (workflowId: string, blockId: string) =>
2929
[...scheduleKeys.details(), workflowId, blockId] as const,
30-
byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const,
30+
/**
31+
* By-id reads sit under their own segment rather than directly under `details()`:
32+
* a bare `[...details(), scheduleId]` is a prefix of `schedule(scheduleId, blockId)`,
33+
* so the two addressings of the same schedule would alias in the cache.
34+
*/
35+
byIds: () => [...scheduleKeys.details(), 'by-id'] as const,
36+
byId: (scheduleId: string) => [...scheduleKeys.byIds(), scheduleId] as const,
3137
}
3238

3339
export type ScheduleData = WorkflowScheduleRow
@@ -196,7 +202,7 @@ export function useReactivateSchedule() {
196202
body: { action: 'reactivate' },
197203
})
198204

199-
return { workflowId, blockId, workspaceId }
205+
return { scheduleId, workflowId, blockId, workspaceId }
200206
},
201207
onSuccess: ({ workflowId, blockId }) => {
202208
logger.info('Schedule reactivated', { workflowId, blockId })
@@ -206,9 +212,10 @@ export function useReactivateSchedule() {
206212
},
207213
onSettled: async (data) => {
208214
if (!data) return
209-
const { workflowId, blockId, workspaceId } = data
215+
const { scheduleId, workflowId, blockId, workspaceId } = data
210216
await Promise.all([
211217
queryClient.invalidateQueries({ queryKey: scheduleKeys.schedule(workflowId, blockId) }),
218+
queryClient.invalidateQueries({ queryKey: scheduleKeys.byId(scheduleId) }),
212219
workspaceId
213220
? queryClient.invalidateQueries({ queryKey: scheduleKeys.list(workspaceId) })
214221
: Promise.resolve(),

‎apps/sim/hooks/queries/subscription.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/lib/api/contracts/subscription'
1515
import { organizationKeys } from '@/hooks/queries/organization'
1616
import { workspaceKeys } from '@/hooks/queries/workspace'
17+
import { invalidateWorkspaceUsage } from '@/hooks/queries/workspace-usage'
1718

1819
export type { SubscriptionApiResponse }
1920

@@ -265,6 +266,7 @@ export function useUpdateUsageLimit() {
265266
return Promise.all([
266267
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
267268
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
269+
invalidateWorkspaceUsage(queryClient),
268270
])
269271
},
270272
})
@@ -291,6 +293,7 @@ export function useUpgradeSubscription() {
291293
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
292294
queryClient.invalidateQueries({ queryKey: subscriptionKeys.invoicesAll() }),
293295
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }),
296+
invalidateWorkspaceUsage(queryClient),
294297
...(variables.orgId
295298
? [
296299
queryClient.invalidateQueries({
@@ -328,6 +331,7 @@ export function usePurchaseCredits() {
328331
return Promise.all([
329332
queryClient.invalidateQueries({ queryKey: subscriptionKeys.users() }),
330333
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() }),
334+
invalidateWorkspaceUsage(queryClient),
331335
...(variables.orgId
332336
? [
333337
queryClient.invalidateQueries({

‎apps/sim/hooks/queries/workspace-usage.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import {
1919
fetchWorkspaceCreditAvailability,
2020
fetchWorkspaceUsageGate,
21+
invalidateWorkspaceUsage,
2122
WORKSPACE_CREDIT_AVAILABILITY_STALE_TIME,
2223
WORKSPACE_USAGE_GATE_STALE_TIME,
2324
workspaceUsageKeys,
@@ -65,4 +66,18 @@ describe('workspace usage gate query', () => {
6566
signal,
6667
})
6768
})
69+
70+
it('invalidates both usage families so a spend or top-up refetches them', async () => {
71+
const invalidateQueries = vi.fn().mockResolvedValue(undefined)
72+
const queryClient = { invalidateQueries } as unknown as Parameters<
73+
typeof invalidateWorkspaceUsage
74+
>[0]
75+
76+
await invalidateWorkspaceUsage(queryClient)
77+
78+
expect(invalidateQueries).toHaveBeenCalledWith({
79+
queryKey: workspaceUsageKeys.creditAvailabilities(),
80+
})
81+
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: workspaceUsageKeys.gates() })
82+
})
6883
})

0 commit comments

Comments
 (0)