Skip to content

Commit d8f91a3

Browse files
authored
improvement(search): bounded Jira fallback, organization search through integrations page connections (#8985)
* improvement(search): bound the any-term fallback so a slow relaxed query keeps the strict page When an all-terms Atlassian search underfills, the live search runs a relaxed any-term query. For Jira that query ORs every pair of up to 8 terms (28 pairs, 56 `text ~` clauses), which can run past the 10 s HTTP timeout on a large site. The transport failure was rethrown, so the whole account leg reported unavailable: the strict matches already fetched were discarded and the search waited out the timeout. - The relaxed search gets its own 4 s budget, composed with the caller's signal through AbortSignal.any and passed down as a per-search signal (NativeSearchInput.signal -> searchAtlassian -> client.json -> secureFetchWithValidation), so the HTTP request itself is cancelled rather than raced. The account leg already has a 12 s deadline that covers resolve, session, the strict search, verification, and the 2.5 s preview reads; a relaxed query normally takes ~1.5 s, so 4 s leaves headroom for a slow but healthy query while ending a hung one with time left for verification and previews inside the leg deadline. Composing with the leg signal means it can never run past that deadline. - Any relaxed failure other than the caller's own cancellation (provider refusal, the budget running out, a transport error) keeps the strict page, marked partial with a message, the same shape as the existing refusal path. Caller cancellation still propagates. A successful relaxed search behaves exactly as before. - Jira relaxed JQL pairs at most the six longest distinct terms (at most 15 pairs), and a word and its regular plural share one slot, since Jira stems text search. Ranking by term coverage still uses every significant term, and Confluence's siteSearch clause is unchanged. - A memoized GET is never shared with a request that carries its own signal. * improvement(search): keep Jira's eight-term pairing, simplify fallback error handling Drop the six-term pair cap (the budget already bounds a slow relaxed query, and the cap removed pairs of the shortest terms). Keep the singular/plural collapse, now leaving -us words (status, focus) whole. One branch per relaxed failure kind, the term cap inside distinctTerms, and no memo special case no caller reaches. * fix(search): search an organization only through its integrations page connections Organization live search also listed a member's plain OAuth credentials at organization scope, which no page shows or can disconnect. One left behind next to the same person's integrations page connection made every result from that provider come back twice. Organization search now uses only credential-group enrollments; workspace search is unchanged.
1 parent a643776 commit d8f91a3

10 files changed

Lines changed: 243 additions & 64 deletions

File tree

‎apps/sim/lib/sim-search/live/account-session.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export async function openLiveAccountSession(
206206
)
207207
}
208208
return client && requiresEveryTerm(provider)
209-
? searchWithAnyTermFallback(searchOnce, search)
209+
? searchWithAnyTermFallback(searchOnce, search, signal)
210210
: searchOnce(search)
211211
},
212212
async verify(document) {

‎apps/sim/lib/sim-search/live/accounts.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ describe('live account discovery boundaries', () => {
130130
})
131131
expect(mocks.resolve).not.toHaveBeenCalled()
132132
})
133+
it('searches an organization only through its integrations page enrollments', async () => {
134+
mockListOrganizationSearchApprovals.mockResolvedValue(new Map([['jira', true]]))
135+
mocks.managed.mockResolvedValue([
136+
{ id: 'enrolled', providerId: 'jira', displayName: 'Enrolled Jira' },
137+
])
138+
const legacy = { id: 'legacy', providerId: 'jira', displayName: 'Legacy Jira' }
139+
const metadata = ['enrolled', 'legacy'].map((id) => ({
140+
id,
141+
scope: 'read:jira-work',
142+
grantedScopes: null,
143+
revokedAt: null,
144+
}))
145+
queueTableRows(schemaMock.credential, [legacy])
146+
queueTableRows(schemaMock.credential, metadata)
147+
queueTableRows(schemaMock.credential, metadata)
148+
expect(
149+
(await listLiveAccounts({ organizationId: 'org' }, 'reader')).map((row) => row.id)
150+
).toEqual(['enrolled'])
151+
})
133152
it('does not resolve another account even when the caller knows its ID', async () => {
134153
metadata()
135154
await expect(resolveLiveAccount(owner, 'reader', 'someone-else')).rejects.toMatchObject({

‎apps/sim/lib/sim-search/live/accounts.ts‎

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { account, credential, user } from '@sim/db/schema'
3-
import { and, eq, inArray, isNull, ne } from 'drizzle-orm'
3+
import { and, eq, inArray, isNull } from 'drizzle-orm'
44
import { liveSearchProviderSchema } from '@/lib/api/contracts/mothership-assistant-tools'
55
import {
66
type ResourceOwner,
@@ -28,34 +28,17 @@ import {
2828
} from '@/lib/sim-search/live/provider-catalog'
2929
import type { LiveAccount } from '@/lib/sim-search/live/types'
3030

31+
/**
32+
* A workspace searches the person's workspace credentials; an organization searches only the
33+
* accounts its members connect on the integrations page (credential-group enrollments).
34+
*/
3135
async function listOAuthRows(scope: ResourceScope, userId: string) {
3236
if (scope.kind === 'workspace') return getPersonalOAuthCredentials(scope.workspaceId, userId)
33-
const [managed, personal] = await Promise.all([
34-
getOwnOrganizationManagedOAuthCredentials({ organizationId: scope.organizationId, userId }),
35-
db
36-
.select({
37-
id: credential.id,
38-
providerId: credential.providerId,
39-
displayName: credential.displayName,
40-
})
41-
.from(credential)
42-
.innerJoin(account, eq(account.id, credential.accountId))
43-
.where(
44-
and(
45-
resourceScopeCondition(credential, scope),
46-
eq(credential.type, 'oauth'),
47-
eq(account.userId, userId),
48-
eq(account.providerId, credential.providerId),
49-
ne(credential.providerId, 'slack')
50-
)
51-
),
52-
])
53-
return [
54-
...managed.map((row) => ({ ...row, type: 'managed_oauth' as const })),
55-
...personal.flatMap((row) =>
56-
row.providerId ? [{ ...row, providerId: row.providerId, type: 'oauth' as const }] : []
57-
),
58-
]
37+
const managed = await getOwnOrganizationManagedOAuthCredentials({
38+
organizationId: scope.organizationId,
39+
userId,
40+
})
41+
return managed.map((row) => ({ ...row, type: 'managed_oauth' as const }))
5942
}
6043

6144
async function listCodaTokenRows(scope: ResourceScope, userId: string) {

‎apps/sim/lib/sim-search/live/atlassian.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export async function searchAtlassian(
169169
fields: ['summary', 'description', 'updated', 'creator', 'status'],
170170
...(input.native?.cursor && single ? { nextPageToken: input.native.cursor } : {}),
171171
},
172+
signal: input.signal,
172173
})
173174
)
174175
return {
@@ -190,6 +191,7 @@ export async function searchAtlassian(
190191
expand: 'content.version,content.space',
191192
...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}),
192193
},
194+
signal: input.signal,
193195
})
194196
)
195197
const next = string(object(data._links).next)

‎apps/sim/lib/sim-search/live/http.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ export function createNativeClient(input: {
3939
query?: Record<string, string | string[]>
4040
body?: unknown
4141
googleService?: 'sheets'
42+
signal?: AbortSignal
4243
}
4344
) {
44-
input.signal.throwIfAborted()
45+
const signal = options?.signal ? AbortSignal.any([input.signal, options.signal]) : input.signal
46+
signal.throwIfAborted()
4547
if (++requests > (input.requestBudget ?? NATIVE_SEARCH_REQUEST_BUDGET))
4648
throw new NativeSearchError('unavailable', 'Request budget reached. Narrow the query.')
4749
const url = new URL(input.origin)
@@ -68,7 +70,7 @@ export function createNativeClient(input: {
6870
},
6971
method: options?.body ? 'POST' : 'GET',
7072
...(options?.body ? { body: JSON.stringify(options.body) } : {}),
71-
signal: input.signal,
73+
signal,
7274
timeout: 10_000,
7375
maxResponseBytes: NATIVE_RESPONSE_MAX_BYTES,
7476
maxRedirects: 0,

‎apps/sim/lib/sim-search/live/term-fallback.test.ts‎

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1-
import { describe, expect, it, vi } from 'vitest'
2-
import { NativeSearchError } from '@/lib/sim-search/live/http'
1+
import {
2+
inputValidationMock,
3+
inputValidationMockFns,
4+
} from '@sim/testing/mocks/input-validation.mock'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
8+
9+
import { searchAtlassian } from '@/lib/sim-search/live/atlassian'
10+
import { createNativeClient, NativeSearchError } from '@/lib/sim-search/live/http'
311
import { searchWithAnyTermFallback } from '@/lib/sim-search/live/term-fallback'
412
import type { NativeDocument, NativePage, NativeSearchInput } from '@/lib/sim-search/live/types'
513

614
const input: NativeSearchInput = { query: 'contractor offboarding runbook', limit: 4, scopes: [] }
715

16+
/** The any-term search's own budget, which a slow provider must not outlast. */
17+
const BUDGET_MS = 4000
18+
19+
/** A caller that never cancels. */
20+
const live = new AbortController().signal
21+
22+
/** Settles only when its request is cancelled, like a provider that never answers. */
23+
const untilAborted = (signal: AbortSignal | undefined) =>
24+
new Promise<never>((_, reject) => {
25+
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
26+
})
27+
828
function page(...documents: Partial<NativeDocument>[]): NativePage {
929
return {
1030
documents: documents.map((document, index) => ({
@@ -26,6 +46,10 @@ function provider(strict: NativePage, relaxed: NativePage | Error) {
2646
}
2747

2848
describe('searchWithAnyTermFallback', () => {
49+
afterEach(() => {
50+
vi.useRealTimers()
51+
})
52+
2953
it('follows an exhausted all-terms page with partial matches, most terms first', async () => {
3054
const search = provider(
3155
page({ id: 'exact', title: 'Contractor offboarding runbook' }),
@@ -37,14 +61,15 @@ describe('searchWithAnyTermFallback', () => {
3761
{ id: 'contractors', title: 'Contractor directory' }
3862
)
3963
)
40-
const result = await searchWithAnyTermFallback(search, input)
64+
const result = await searchWithAnyTermFallback(search, input, live)
4165
expect(result.documents.map((document) => document.id)).toEqual([
4266
'exact',
4367
'in-title',
4468
'in-preview',
4569
'runbook',
4670
])
4771
expect(result.nextCursor).toBeUndefined()
72+
expect(result.partial).toBeFalsy()
4873
expect(result.message).toContain('Few results contained every search term')
4974
})
5075

@@ -58,7 +83,7 @@ describe('searchWithAnyTermFallback', () => {
5883
? { documents: page(...loose).documents.slice(0, request.limit) }
5984
: page()
6085
)
61-
const result = await searchWithAnyTermFallback(search, { ...input, limit: 2 })
86+
const result = await searchWithAnyTermFallback(search, { ...input, limit: 2 }, live)
6287
expect(result.documents.map((document) => document.title)).toEqual([
6388
'Contractor offboarding form',
6489
'Runbook 0',
@@ -68,34 +93,36 @@ describe('searchWithAnyTermFallback', () => {
6893
it('adds a few partial matches however large the page', async () => {
6994
const partial = Array.from({ length: 12 }, () => ({ title: 'Contractor offboarding' }))
7095
const search = provider(page(), page(...partial))
71-
const result = await searchWithAnyTermFallback(search, { ...input, limit: 20 })
96+
const result = await searchWithAnyTermFallback(search, { ...input, limit: 20 }, live)
7297
expect(result.documents).toHaveLength(5)
7398
})
7499

75100
it('never relaxes a native query, which is the caller’s exact syntax', async () => {
76101
const search = provider(page(), page({ id: 'loose' }))
77102
const native = { provider: 'confluence' as const, query: 'text ~ "runbook"' }
78-
expect((await searchWithAnyTermFallback(search, { ...input, native })).documents).toEqual([])
103+
expect((await searchWithAnyTermFallback(search, { ...input, native }, live)).documents).toEqual(
104+
[]
105+
)
79106
expect(search).toHaveBeenCalledTimes(1)
80107
})
81108

82109
it('keeps a continuable page so its cursor continues the query that produced it', async () => {
83110
const strict = { ...page({ id: 'exact' }), nextCursor: 'page-2' }
84111
const search = provider(strict, page({ id: 'loose' }))
85-
expect(await searchWithAnyTermFallback(search, input)).toBe(strict)
112+
expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict)
86113
expect(search).toHaveBeenCalledTimes(1)
87114
})
88115

89116
it('does not relax a page that is already full', async () => {
90117
const strict = page({}, {}, {}, {})
91118
const search = provider(strict, page({ id: 'loose' }))
92-
expect(await searchWithAnyTermFallback(search, input)).toBe(strict)
119+
expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict)
93120
expect(search).toHaveBeenCalledTimes(1)
94121
})
95122

96123
it('does not relax a query with fewer than two distinctive terms', async () => {
97124
const search = provider(page(), page({ id: 'loose' }))
98-
await searchWithAnyTermFallback(search, { ...input, query: 'what is the runbook' })
125+
await searchWithAnyTermFallback(search, { ...input, query: 'what is the runbook' }, live)
99126
expect(search).toHaveBeenCalledTimes(1)
100127
})
101128

@@ -104,25 +131,95 @@ describe('searchWithAnyTermFallback', () => {
104131
page({ id: 'exact' }),
105132
new NativeSearchError('rate_limited', 'Slow down')
106133
)
107-
const result = await searchWithAnyTermFallback(search, input)
134+
const result = await searchWithAnyTermFallback(search, input, live)
108135
expect(result.documents.map((document) => document.id)).toEqual(['exact'])
109136
expect(result).toMatchObject({ partial: true, message: 'Slow down' })
110137
})
111138

112-
it('propagates cancellation during the any-term search instead of returning a page', async () => {
113-
const search = provider(page({ id: 'exact' }), new DOMException('Timed out', 'TimeoutError'))
114-
await expect(searchWithAnyTermFallback(search, input)).rejects.toThrow('Timed out')
139+
it('propagates the caller’s cancellation during the any-term search instead of returning a page', async () => {
140+
const caller = new AbortController()
141+
const search = vi.fn(async (request: NativeSearchInput) =>
142+
request.textMatch === 'any' ? untilAborted(request.signal) : page({ id: 'exact' })
143+
)
144+
const result = searchWithAnyTermFallback(search, input, caller.signal)
145+
await vi.waitFor(() => expect(search).toHaveBeenCalledTimes(2))
146+
caller.abort(new DOMException('Cancelled', 'AbortError'))
147+
await expect(result).rejects.toThrow('Cancelled')
148+
})
149+
150+
it('keeps the all-terms page as partial when the any-term search outlasts its budget', async () => {
151+
vi.useFakeTimers()
152+
let relaxedSignal: AbortSignal | undefined
153+
const search = vi.fn(async (request: NativeSearchInput) => {
154+
if (request.textMatch !== 'any') return page({ id: 'exact' })
155+
relaxedSignal = request.signal
156+
return untilAborted(request.signal)
157+
})
158+
let settled = false
159+
const result = searchWithAnyTermFallback(search, input, live).finally(() => {
160+
settled = true
161+
})
162+
await vi.advanceTimersByTimeAsync(BUDGET_MS - 1)
163+
expect(settled).toBe(false)
164+
await vi.advanceTimersByTimeAsync(1)
165+
const searched = await result
166+
expect(relaxedSignal?.aborted).toBe(true)
167+
expect(searched.documents.map((document) => document.id)).toEqual(['exact'])
168+
expect(searched).toMatchObject({ partial: true })
169+
expect(searched.message).toContain('some of the search terms')
170+
})
171+
172+
it('keeps the all-terms page as partial when the any-term search fails in transport', async () => {
173+
const search = provider(page({ id: 'exact' }), new TypeError('fetch failed'))
174+
const searched = await searchWithAnyTermFallback(search, input, live)
175+
expect(searched.documents.map((document) => document.id)).toEqual(['exact'])
176+
expect(searched).toMatchObject({ partial: true })
177+
expect(searched.message).toContain('some of the search terms')
178+
})
179+
180+
it('cancels a slow relaxed Jira request at the network once the budget runs out', async () => {
181+
vi.useFakeTimers()
182+
const relaxedSignals: AbortSignal[] = []
183+
inputValidationMockFns.mockSecureFetchWithValidation.mockImplementation(
184+
async (url: string, init: { body?: string; signal: AbortSignal }) => {
185+
if (url.endsWith('/oauth/token/accessible-resources'))
186+
return Response.json([{ id: 'cloud', url: 'https://acme.atlassian.net' }])
187+
const { jql } = JSON.parse(init.body ?? '{}') as { jql: string }
188+
if (!jql.includes(' OR '))
189+
return Response.json({ issues: [{ key: 'ENG-1', fields: { summary: 'Exact' } }] })
190+
relaxedSignals.push(init.signal)
191+
return untilAborted(init.signal)
192+
}
193+
)
194+
const caller = new AbortController()
195+
const client = createNativeClient({
196+
origin: 'https://api.atlassian.com',
197+
accessToken: 'private',
198+
signal: caller.signal,
199+
})
200+
const result = searchWithAnyTermFallback(
201+
(request) => searchAtlassian(client, 'jira', request),
202+
input,
203+
caller.signal
204+
)
205+
await vi.advanceTimersByTimeAsync(BUDGET_MS)
206+
const searched = await result
207+
expect(relaxedSignals).toHaveLength(1)
208+
expect(relaxedSignals[0]?.aborted).toBe(true)
209+
expect(caller.signal.aborted).toBe(false)
210+
expect(searched.documents.map((document) => document.id)).toEqual(['ENG-1'])
211+
expect(searched.partial).toBe(true)
115212
})
116213

117214
it('does not relax a date-sorted search, whose page is reordered by date', async () => {
118215
const search = provider(page(), page({ id: 'loose' }))
119-
await searchWithAnyTermFallback(search, { ...input, filters: { sortBy: 'newest' } })
216+
await searchWithAnyTermFallback(search, { ...input, filters: { sortBy: 'newest' } }, live)
120217
expect(search).toHaveBeenCalledTimes(1)
121218
})
122219

123220
it('leaves the page untouched when the any-term search adds nothing new', async () => {
124221
const strict = page({ id: 'exact' })
125222
const search = provider(strict, page({ id: 'exact' }))
126-
expect(await searchWithAnyTermFallback(search, input)).toBe(strict)
223+
expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict)
127224
})
128225
})

0 commit comments

Comments
 (0)