Skip to content

Commit a60acbf

Browse files
fix(search): clear cached content after disconnect (#7712)
* fix(search): clear cached content after disconnect * fix(search): reset directly opened document caches
1 parent fbb5c70 commit a60acbf

6 files changed

Lines changed: 238 additions & 35 deletions

apps/sim/hooks/queries/organization-accounts.test.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,34 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const mocks = vi.hoisted(() => ({ request: vi.fn() }))
8+
const mocks = vi.hoisted(() => ({ request: vi.fn(), refresh: vi.fn() }))
99
vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request }))
10+
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
1011

1112
import { ApiClientError } from '@/lib/api/client/errors'
1213
import {
1314
disconnectPersonalOrganizationAccountContract,
1415
listOrganizationAccountPeopleContract,
1516
updateOrganizationAccountsContract,
1617
} from '@/lib/api/contracts/organization-accounts'
18+
import { resourceScopeKey } from '@/lib/core/resource-scope'
1719
import {
1820
organizationAccountsKeys,
1921
useDisconnectPersonalOrganizationAccount,
2022
useOrganizationAccountPeople,
2123
useUpdateOrganizationAccounts,
2224
} from '@/hooks/queries/organization-accounts'
2325
import { slackSearchKeys } from '@/hooks/queries/slack-search'
26+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
2427
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
2528

2629
describe('personal account disconnect', () => {
2730
it.each([true, false])(
28-
'refreshes this organization only after success=%s, including after unmount',
31+
'clears content and refreshes the router only after success=%s, including after unmount',
2932
async (success) => {
3033
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
3134
mocks.request.mockReset()
35+
mocks.refresh.mockReset()
3236
const response = Promise.withResolvers<{ success: true }>()
3337
mocks.request.mockReturnValue(response.promise)
3438
const client = new QueryClient()
@@ -48,7 +52,39 @@ describe('personal account disconnect', () => {
4852
)
4953
const people = organizationAccountsKeys.people('org-1')
5054
const other = searchSourceKeys.list({ kind: 'organization', organizationId: 'org-2' })
51-
for (const key of [own, catalog, people, other]) client.setQueryData(key, { existing: true })
55+
const results = knowledgeKeys.search(
56+
resourceScopeKey({ kind: 'organization', organizationId: 'org-1' }),
57+
'private content'
58+
)
59+
const otherResults = knowledgeKeys.search(
60+
resourceScopeKey({ kind: 'organization', organizationId: 'org-2' }),
61+
'private content'
62+
)
63+
const workspaceResults = knowledgeKeys.search('workspace-1', 'private content')
64+
const ownDocument = knowledgeKeys.document('kb-1', 'document-1')
65+
const chunks = knowledgeKeys.chunks('kb-2', 'document-2', '')
66+
const otherDocument = knowledgeKeys.document('kb-other', 'document-other')
67+
const resultOnlyDocument = knowledgeKeys.document('kb-result-only', 'document-result-only')
68+
const sourcePages = {
69+
pages: [
70+
{ sources: [{ knowledgeBaseId: 'kb-1' }], nextCursor: 'next' },
71+
{ sources: [{ knowledgeBaseId: 'kb-2' }], nextCursor: null },
72+
],
73+
pageParams: [undefined, 'next'],
74+
}
75+
for (const key of [own, catalog]) client.setQueryData(key, sourcePages)
76+
client.setQueryData(results, [{ knowledgeBaseId: 'kb-result-only' }])
77+
for (const key of [
78+
people,
79+
other,
80+
otherResults,
81+
workspaceResults,
82+
ownDocument,
83+
resultOnlyDocument,
84+
chunks,
85+
otherDocument,
86+
])
87+
client.setQueryData(key, { content: 'previously authorized content' })
5288
try {
5389
await act(async () =>
5490
root.render(
@@ -78,9 +114,23 @@ describe('personal account disconnect', () => {
78114
disconnectPersonalOrganizationAccountContract,
79115
{ params: { credentialId: 'own-credential' } }
80116
)
81-
for (const key of [own, catalog, people])
82-
expect(client.getQueryState(key)?.isInvalidated).toBe(success)
117+
for (const key of [
118+
own,
119+
catalog,
120+
results,
121+
ownDocument,
122+
resultOnlyDocument,
123+
chunks,
124+
otherDocument,
125+
]) {
126+
if (success) expect(client.getQueryData(key)).toBeUndefined()
127+
else expect(client.getQueryData(key)).toBeDefined()
128+
}
129+
expect(client.getQueryState(people)?.isInvalidated).toBe(success)
83130
expect(client.getQueryState(other)?.isInvalidated).toBe(false)
131+
expect(mocks.refresh).toHaveBeenCalledTimes(success ? 1 : 0)
132+
for (const key of [otherResults, workspaceResults])
133+
expect(client.getQueryData(key)).toEqual({ content: 'previously authorized content' })
84134
} finally {
85135
await act(async () => root.unmount())
86136
client.clear()

apps/sim/hooks/queries/organization-accounts.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useQuery,
88
useQueryClient,
99
} from '@tanstack/react-query'
10+
import { useRouter } from 'next/navigation'
1011
import { isApiClientError } from '@/lib/api/client/errors'
1112
import { requestJson } from '@/lib/api/client/request'
1213
import {
@@ -37,27 +38,29 @@ import {
3738
updateOrganizationAccountWorkspaceAccessContract,
3839
} from '@/lib/api/contracts/organization-accounts'
3940
import { slackSearchKeys } from '@/hooks/queries/slack-search'
41+
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
4042
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
4143

4244
export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000
4345

4446
/** Disconnects an owned grant; indexing and source setup do not gate this operation. */
4547
export function useDisconnectPersonalOrganizationAccount(organizationId: string) {
4648
const queryClient = useQueryClient()
49+
const router = useRouter()
4750
return useMutation({
4851
mutationFn: (credentialId: string) =>
4952
requestJson(disconnectPersonalOrganizationAccountContract, {
5053
params: { credentialId },
5154
}),
52-
onSuccess: () =>
53-
Promise.all([
54-
queryClient.invalidateQueries({
55-
queryKey: searchSourceKeys.list({ kind: 'organization', organizationId }),
56-
}),
55+
onSuccess: async () => {
56+
await Promise.all([
57+
resetOrganizationSearchAccess(queryClient, organizationId),
5758
queryClient.invalidateQueries({
5859
queryKey: organizationAccountsKeys.detail(organizationId),
5960
}),
60-
]),
61+
])
62+
router.refresh()
63+
},
6164
})
6265
}
6366

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { createRoot } from 'react-dom/client'
5+
import { expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({ request: vi.fn(), refresh: vi.fn() }))
8+
vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request }))
9+
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
10+
11+
import { useUpdateSearchIntegration } from '@/hooks/queries/search-integrations'
12+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
13+
14+
it.each([true, false])(
15+
'clears document content and refreshes server pages after integration update success=%s',
16+
async (success) => {
17+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
18+
mocks.request.mockReset()
19+
mocks.refresh.mockReset()
20+
const response = Promise.withResolvers<{ data: { connectorType: string; approved: boolean } }>()
21+
mocks.request.mockReturnValue(response.promise)
22+
const client = new QueryClient()
23+
const root = createRoot(document.createElement('div'))
24+
const key = knowledgeKeys.document('kb-direct', 'document-direct')
25+
client.setQueryData(key, { content: 'previously authorized content' })
26+
let mutation: ReturnType<typeof useUpdateSearchIntegration>
27+
function Probe() {
28+
mutation = useUpdateSearchIntegration()
29+
return null
30+
}
31+
try {
32+
await act(async () =>
33+
root.render(
34+
<QueryClientProvider client={client}>
35+
<Probe />
36+
</QueryClientProvider>
37+
)
38+
)
39+
let pending: Promise<unknown>
40+
await act(async () => {
41+
pending = mutation.mutateAsync({
42+
organizationId: 'org-1',
43+
connectorType: 'github',
44+
approved: false,
45+
})
46+
})
47+
await act(async () =>
48+
root.render(<QueryClientProvider client={client}>{null}</QueryClientProvider>)
49+
)
50+
await act(async () => {
51+
if (success) {
52+
response.resolve({ data: { connectorType: 'github', approved: false } })
53+
await pending
54+
} else {
55+
const rejection = expect(pending).rejects.toThrow('Try again')
56+
response.reject(new Error('Try again'))
57+
await rejection
58+
}
59+
})
60+
expect(mocks.refresh).toHaveBeenCalledTimes(success ? 1 : 0)
61+
if (success) expect(client.getQueryData(key)).toBeUndefined()
62+
else expect(client.getQueryData(key)).toEqual({ content: 'previously authorized content' })
63+
} finally {
64+
await act(async () => root.unmount())
65+
client.clear()
66+
vi.unstubAllGlobals()
67+
}
68+
}
69+
)
Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import { type InfiniteData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
import { useRouter } from 'next/navigation'
23
import { requestJson } from '@/lib/api/client/request'
3-
import type { SearchSourcePage } from '@/lib/api/contracts/knowledge/connectors'
44
import {
55
listSearchIntegrationsContract,
66
type UpdateSearchIntegrationBody,
77
updateSearchIntegrationContract,
88
} from '@/lib/api/contracts/knowledge/search-integrations'
9-
import { resourceScopeKey } from '@/lib/core/resource-scope'
10-
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
9+
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
1110
import { searchIntegrationKeys } from '@/hooks/queries/utils/search-integration-keys'
12-
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
1311

1412
export const SEARCH_INTEGRATIONS_STALE_TIME = 30_000
1513

@@ -25,32 +23,16 @@ export function useSearchIntegrations(organizationId: string) {
2523

2624
export function useUpdateSearchIntegration() {
2725
const queryClient = useQueryClient()
26+
const router = useRouter()
2827
return useMutation({
2928
mutationFn: async (body: UpdateSearchIntegrationBody) =>
3029
(await requestJson(updateSearchIntegrationContract, { body })).data,
3130
onSuccess: async (_data, { organizationId }) => {
32-
const scope = { kind: 'organization', organizationId } as const
33-
const pages = queryClient.getQueriesData<InfiniteData<SearchSourcePage>>({
34-
queryKey: searchSourceKeys.list(scope),
35-
predicate: (query) => query.queryKey[3] === 'pages',
36-
})
37-
const knowledgeBaseIds = new Set(
38-
pages.flatMap(
39-
([, data]) =>
40-
data?.pages.flatMap((page) => page.sources.map((source) => source.knowledgeBaseId)) ??
41-
[]
42-
)
43-
)
4431
await Promise.all([
32+
resetOrganizationSearchAccess(queryClient, organizationId),
4533
queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.list(organizationId) }),
46-
queryClient.invalidateQueries({ queryKey: searchSourceKeys.list(scope) }),
47-
queryClient.resetQueries({
48-
queryKey: [...knowledgeKeys.searches(), resourceScopeKey(scope)],
49-
}),
50-
...[...knowledgeBaseIds].map((id) =>
51-
queryClient.resetQueries({ queryKey: knowledgeKeys.detail(id) })
52-
),
5334
])
35+
router.refresh()
5436
},
5537
})
5638
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/** @vitest-environment node */
2+
import { QueryClient } from '@tanstack/react-query'
3+
import { expect, it, vi } from 'vitest'
4+
import type { WorkspaceKnowledgeSearchResult } from '@/lib/api/contracts/knowledge/search'
5+
import { resourceScopeKey } from '@/lib/core/resource-scope'
6+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
7+
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
8+
9+
it.each([
10+
{ name: 'document', key: knowledgeKeys.document('kb-direct', 'document-direct') },
11+
{ name: 'chunks', key: knowledgeKeys.chunks('kb-direct', 'document-direct', '') },
12+
])('clears and cancels directly loaded $name without source or Search caches', async ({ key }) => {
13+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
14+
const response = Promise.withResolvers<{ content: string }>()
15+
const aborted = vi.fn()
16+
try {
17+
client.setQueryData(key, { content: 'cached private content' })
18+
const pending = client.fetchQuery({
19+
queryKey: key,
20+
queryFn: ({ signal }) => {
21+
signal.addEventListener('abort', aborted, { once: true })
22+
return response.promise
23+
},
24+
})
25+
const rejected = expect(pending).rejects.toThrow()
26+
await resetOrganizationSearchAccess(client, 'org-1')
27+
await rejected
28+
expect(aborted).toHaveBeenCalledOnce()
29+
expect(client.getQueryData(key)).toBeUndefined()
30+
response.resolve({ content: 'late private content' })
31+
await response.promise
32+
expect(client.getQueryData(key)).toBeUndefined()
33+
} finally {
34+
client.clear()
35+
}
36+
})
37+
38+
it('cancels an in-flight search so its late result cannot restore disconnected content', async () => {
39+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
40+
const key = knowledgeKeys.search(
41+
resourceScopeKey({ kind: 'organization', organizationId: 'org-1' }),
42+
'private content'
43+
)
44+
const response = Promise.withResolvers<WorkspaceKnowledgeSearchResult[]>()
45+
const result: WorkspaceKnowledgeSearchResult = {
46+
documentId: 'document-1',
47+
knowledgeBaseId: 'kb-1',
48+
knowledgeBaseName: 'Knowledge',
49+
documentName: 'Private document',
50+
sourceUrl: null,
51+
connectorType: 'github',
52+
sourceModifiedAt: null,
53+
author: null,
54+
content: 'cached private content',
55+
chunkIndex: 0,
56+
similarity: 1,
57+
}
58+
const aborted = vi.fn()
59+
try {
60+
client.setQueryData(key, [result])
61+
const pending = client.fetchQuery({
62+
queryKey: key,
63+
queryFn: ({ signal }) => {
64+
signal.addEventListener('abort', aborted, { once: true })
65+
return response.promise
66+
},
67+
})
68+
const rejected = expect(pending).rejects.toThrow()
69+
await resetOrganizationSearchAccess(client, 'org-1')
70+
await rejected
71+
expect(aborted).toHaveBeenCalledOnce()
72+
expect(client.getQueryData(key)).toBeUndefined()
73+
response.resolve([{ ...result, content: 'late private content' }])
74+
await response.promise
75+
expect(client.getQueryData(key)).toBeUndefined()
76+
} finally {
77+
client.clear()
78+
}
79+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { QueryClient } from '@tanstack/react-query'
2+
import { resourceScopeKey } from '@/lib/core/resource-scope'
3+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
4+
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
5+
6+
/** Drops cached results and document content when organization Search access changes. */
7+
export async function resetOrganizationSearchAccess(
8+
queryClient: QueryClient,
9+
organizationId: string
10+
) {
11+
const scope = { kind: 'organization', organizationId } as const
12+
await Promise.all([
13+
queryClient.resetQueries({
14+
queryKey: [...knowledgeKeys.searches(), resourceScopeKey(scope)],
15+
}),
16+
/** Document keys carry no resource scope and may exist without source or result caches. */
17+
queryClient.resetQueries({ queryKey: knowledgeKeys.details() }),
18+
queryClient.resetQueries({ queryKey: searchSourceKeys.list(scope) }),
19+
])
20+
}

0 commit comments

Comments
 (0)