@open-mercato/shared 0.6.7-develop.6785.1.1dd7cfac55 → 0.6.7-develop.6795.1.8a3f27921c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,13 @@ import {
12
12
  type ResolvedJoin,
13
13
  } from './join-utils'
14
14
  import { resolveSearchConfig } from '../search/config'
15
+ import {
16
+ createSearchTokenAvailability,
17
+ isSearchFilterOp,
18
+ type SearchTokenAvailability,
19
+ type SearchTokenProbeDb,
20
+ type SearchTokenProbeQueryBuilder,
21
+ } from '../search/availability'
15
22
  import { tokenizeText } from '../search/tokenize'
16
23
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from './query-extension-runner'
17
24
  import {
@@ -207,8 +214,8 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
207
214
  */
208
215
  export class BasicQueryEngine implements QueryEngine {
209
216
  private columnCache = new Map<string, boolean>()
210
- private tableCache = new Map<string, boolean>()
211
217
  private searchAliasSeq = 0
218
+ private searchAvailabilityInstance: SearchTokenAvailability | null = null
212
219
 
213
220
  constructor(
214
221
  private em: EntityManager,
@@ -231,6 +238,22 @@ export class BasicQueryEngine implements QueryEngine {
231
238
  throw new Error('BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
232
239
  }
233
240
 
241
+ private searchAvailability(): SearchTokenAvailability {
242
+ if (!this.searchAvailabilityInstance) {
243
+ this.searchAvailabilityInstance = createSearchTokenAvailability({
244
+ getDb: () => this.getDb() as unknown as SearchTokenProbeDb,
245
+ getConfig: resolveSearchConfig,
246
+ applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
247
+ query as unknown as AnyBuilder,
248
+ column,
249
+ scope,
250
+ ) as unknown as SearchTokenProbeQueryBuilder,
251
+ logDebug: (event, payload) => this.logSearchDebug(event, payload),
252
+ })
253
+ }
254
+ return this.searchAvailabilityInstance
255
+ }
256
+
234
257
  async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
235
258
  // --- UMES query extension: before-query pipeline ---
236
259
  const ext = opts.extensions
@@ -294,17 +317,20 @@ export class BasicQueryEngine implements QueryEngine {
294
317
  const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
295
318
  const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
296
319
  const searchConfig = resolveSearchConfig()
320
+ const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
297
321
  // Callers that opt out of automatic tenant/org scoping own the full
298
322
  // visibility predicate. Search-token filtering has its own tenant/org
299
323
  // guards, so it must be disabled on this direct-query path as documented
300
324
  // by QueryOptions.omitAutomaticTenantOrgScope.
301
- const searchEnabled = !skipAutoScope && searchConfig.enabled && await this.tableExists('search_tokens')
302
- const hasSearchTokens = searchEnabled
303
- ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
325
+ const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled()
326
+ // Probe `search_tokens` only when this query actually searches (#4723): every consumer of
327
+ // `searchActive` sits behind a like/ilike guard, so on a plain list load the answer is never
328
+ // read — and the probe is a `LIMIT 1` the planner can resolve as a seq scan over a large
329
+ // `search_tokens`. The join path below already probes lazily for the same reason.
330
+ const hasSearchTokens = searchEnabled && searchFilters.length
331
+ ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
304
332
  : false
305
333
  const searchActive = searchEnabled && hasSearchTokens
306
- const joinSearchAvailability = new Map<string, boolean>()
307
- const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike')
308
334
  if (searchFilters.length) {
309
335
  const fields = searchFilters.map((filter) => String(filter.field))
310
336
  this.logSearchDebug('search:init', {
@@ -358,9 +384,8 @@ export class BasicQueryEngine implements QueryEngine {
358
384
  const join = joinMap.get(alias)
359
385
  if (!join?.entityId) continue
360
386
  const hasJoinedTokens = searchEnabled
361
- ? await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
387
+ ? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
362
388
  : false
363
- joinSearchAvailability.set(join.entityId, hasJoinedTokens)
364
389
  const fallbackFields = filters
365
390
  .filter((filter) => !hasJoinedTokens || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
366
391
  .map((filter) => filter.column)
@@ -437,11 +462,7 @@ export class BasicQueryEngine implements QueryEngine {
437
462
  if (!['like', 'ilike'].includes(filter.op)) return { applied: false, builder }
438
463
  if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return { applied: false, builder }
439
464
 
440
- let searchAvailable = joinSearchAvailability.get(join.entityId)
441
- if (searchAvailable === undefined) {
442
- searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
443
- joinSearchAvailability.set(join.entityId, searchAvailable)
444
- }
465
+ const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
445
466
  if (!searchAvailable) return { applied: false, builder }
446
467
 
447
468
  const tokens = tokenizeText(String(filter.value), searchConfig)
@@ -512,8 +533,8 @@ export class BasicQueryEngine implements QueryEngine {
512
533
  // today's complete selection (base fields + CF projections + extension joins).
513
534
  // `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
514
535
  // candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
515
- // twice is cheap: every `columnExists`/`tableExists` check is memoized on
516
- // `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
536
+ // twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
537
+ // so the second pass hits no extra DB calls.
517
538
  const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
518
539
  const isSortKeysProjection = projection === 'sortKeys'
519
540
  let q: AnyBuilder = db.selectFrom(table as any)
@@ -1177,51 +1198,6 @@ export class BasicQueryEngine implements QueryEngine {
1177
1198
  return present
1178
1199
  }
1179
1200
 
1180
- private async tableExists(table: string): Promise<boolean> {
1181
- if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
1182
- const db = this.getDb()
1183
- const exists = await db
1184
- .selectFrom('information_schema.tables' as any)
1185
- .select(sql<number>`1`.as('one'))
1186
- .where('table_name' as any, '=', table)
1187
- .limit(1)
1188
- .executeTakeFirst()
1189
- const present = !!exists
1190
- this.tableCache.set(table, present)
1191
- return present
1192
- }
1193
-
1194
- private async hasSearchTokens(
1195
- entity: string,
1196
- tenantId: string | null,
1197
- orgScope?: { ids: string[]; includeNull: boolean } | null
1198
- ): Promise<boolean> {
1199
- try {
1200
- const db = this.getDb()
1201
- let query: AnyBuilder = db
1202
- .selectFrom('search_tokens' as any)
1203
- .select(sql<number>`1`.as('one'))
1204
- .where('entity_type' as any, '=', entity)
1205
- .limit(1)
1206
- if (tenantId !== undefined) {
1207
- query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
1208
- }
1209
- if (orgScope) {
1210
- query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
1211
- }
1212
- const row = await query.executeTakeFirst()
1213
- return !!row
1214
- } catch (err) {
1215
- this.logSearchDebug('search:has-tokens-error', {
1216
- entity,
1217
- tenantId,
1218
- organizationScope: orgScope,
1219
- error: err instanceof Error ? err.message : String(err),
1220
- })
1221
- return false
1222
- }
1223
- }
1224
-
1225
1201
  private applySearchTokens(
1226
1202
  q: AnyBuilder,
1227
1203
  opts: {
@@ -0,0 +1,211 @@
1
+ import {
2
+ clearSearchTokenPresenceCache,
3
+ createSearchTokenAvailability,
4
+ hasSearchFilter,
5
+ isSearchFilterOp,
6
+ type OrganizationScope,
7
+ type SearchTokenProbeQueryBuilder,
8
+ } from '../availability'
9
+
10
+ beforeEach(() => {
11
+ clearSearchTokenPresenceCache()
12
+ delete process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS
13
+ })
14
+
15
+ afterAll(() => {
16
+ delete process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS
17
+ })
18
+
19
+ type ProbeLog = { table: string; wheres: unknown[][] }
20
+
21
+ function createFakeDb(options?: { rowsByTable?: Record<string, object | undefined>; failTables?: string[] }) {
22
+ const probes: ProbeLog[] = []
23
+ const db = {
24
+ selectFrom(table: string) {
25
+ const log: ProbeLog = { table, wheres: [] }
26
+ probes.push(log)
27
+ const chain = {
28
+ select: () => chain,
29
+ where: (...args: unknown[]) => {
30
+ log.wheres.push(args)
31
+ return chain
32
+ },
33
+ limit: () => chain,
34
+ executeTakeFirst: async () => {
35
+ if (options?.failTables?.includes(table)) throw new Error(`probe failed for ${table}`)
36
+ return options?.rowsByTable?.[table]
37
+ },
38
+ }
39
+ return chain
40
+ },
41
+ }
42
+ return { db, probes }
43
+ }
44
+
45
+ function buildAvailability(options?: Parameters<typeof createFakeDb>[0] & { enabled?: boolean }) {
46
+ const { db, probes } = createFakeDb(options)
47
+ const logDebug = jest.fn()
48
+ const applyOrganizationScope = jest.fn((query: SearchTokenProbeQueryBuilder) => query)
49
+ const availability = createSearchTokenAvailability({
50
+ getDb: () => db,
51
+ getConfig: () => ({ enabled: options?.enabled ?? true }),
52
+ applyOrganizationScope,
53
+ logDebug,
54
+ })
55
+ return { availability, probes, logDebug, applyOrganizationScope }
56
+ }
57
+
58
+ const countProbes = (probes: ProbeLog[], table: string) => probes.filter((probe) => probe.table === table).length
59
+
60
+ describe('hasSearchFilter / isSearchFilterOp', () => {
61
+ test('recognizes like and ilike only', () => {
62
+ expect(isSearchFilterOp('like')).toBe(true)
63
+ expect(isSearchFilterOp('ilike')).toBe(true)
64
+ expect(isSearchFilterOp('eq')).toBe(false)
65
+ expect(hasSearchFilter([{ op: 'eq' }, { op: 'in' }])).toBe(false)
66
+ expect(hasSearchFilter([{ op: 'eq' }, { op: 'ilike' }])).toBe(true)
67
+ expect(hasSearchFilter([])).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe('createSearchTokenAvailability', () => {
72
+ test('staticEnabled probes the table once per instance and rechecks config each call', async () => {
73
+ const { availability, probes } = buildAvailability({
74
+ rowsByTable: { 'information_schema.tables': { one: 1 } },
75
+ })
76
+ await expect(availability.staticEnabled()).resolves.toBe(true)
77
+ await expect(availability.staticEnabled()).resolves.toBe(true)
78
+ expect(countProbes(probes, 'information_schema.tables')).toBe(1)
79
+ })
80
+
81
+ test('staticEnabled short-circuits on disabled config without probing', async () => {
82
+ const { availability, probes } = buildAvailability({ enabled: false })
83
+ await expect(availability.staticEnabled()).resolves.toBe(false)
84
+ expect(probes).toHaveLength(0)
85
+ })
86
+
87
+ test('staticEnabled retries after a failed table probe instead of caching the rejection', async () => {
88
+ const options = { rowsByTable: { 'information_schema.tables': { one: 1 } }, failTables: ['information_schema.tables'] }
89
+ const { availability, probes } = buildAvailability(options)
90
+ await expect(availability.staticEnabled()).rejects.toThrow('probe failed')
91
+ options.failTables.length = 0
92
+ await expect(availability.staticEnabled()).resolves.toBe(true)
93
+ expect(countProbes(probes, 'information_schema.tables')).toBe(2)
94
+ })
95
+
96
+ test('hasTokens memoizes per (entity, tenant, org) key', async () => {
97
+ const { availability, probes } = buildAvailability({ rowsByTable: { search_tokens: { one: 1 } } })
98
+ const orgScope: OrganizationScope = { ids: ['org1'], includeNull: false }
99
+
100
+ await expect(availability.hasTokens('example:todo', 't1', orgScope)).resolves.toBe(true)
101
+ await expect(availability.hasTokens('example:todo', 't1', orgScope)).resolves.toBe(true)
102
+ expect(countProbes(probes, 'search_tokens')).toBe(1)
103
+
104
+ await availability.hasTokens('example:todo', 't2', orgScope)
105
+ await availability.hasTokens('example:other', 't1', orgScope)
106
+ await availability.hasTokens('example:todo', 't1', { ids: ['org2'], includeNull: false })
107
+ expect(countProbes(probes, 'search_tokens')).toBe(4)
108
+ })
109
+
110
+ test('hasTokens applies the injected organization scope', async () => {
111
+ const { availability, applyOrganizationScope } = buildAvailability()
112
+ const orgScope: OrganizationScope = { ids: ['org1'], includeNull: true }
113
+ await availability.hasTokens('example:todo', 't1', orgScope)
114
+ expect(applyOrganizationScope).toHaveBeenCalledWith(expect.anything(), 'search_tokens.organization_id', orgScope)
115
+
116
+ applyOrganizationScope.mockClear()
117
+ await availability.hasTokens('example:todo', 't1', null)
118
+ expect(applyOrganizationScope).not.toHaveBeenCalled()
119
+ })
120
+
121
+ test('hasTokens resolves false and logs search:has-tokens-error on probe failure', async () => {
122
+ const { availability, logDebug } = buildAvailability({ failTables: ['search_tokens'] })
123
+ await expect(availability.hasTokens('example:todo', 't1', null)).resolves.toBe(false)
124
+ expect(logDebug).toHaveBeenCalledWith('search:has-tokens-error', expect.objectContaining({
125
+ entity: 'example:todo',
126
+ tenantId: 't1',
127
+ error: expect.stringContaining('probe failed'),
128
+ }))
129
+ })
130
+
131
+ test('anySourceHasTokens stops at the first source with tokens and logs each probed source', async () => {
132
+ const { availability, probes, logDebug } = buildAvailability({ rowsByTable: { search_tokens: { one: 1 } } })
133
+ const result = await availability.anySourceHasTokens(
134
+ [
135
+ { entity: 'example:todo', recordIdColumn: 'b.id' },
136
+ { entity: 'example:other', recordIdColumn: 'cfs0.entity_id' },
137
+ ],
138
+ 't1',
139
+ null,
140
+ )
141
+ expect(result).toBe(true)
142
+ expect(countProbes(probes, 'search_tokens')).toBe(1)
143
+ expect(logDebug).toHaveBeenCalledTimes(1)
144
+ expect(logDebug).toHaveBeenCalledWith('search:source-has-tokens', expect.objectContaining({
145
+ entity: 'example:todo',
146
+ recordIdColumn: 'b.id',
147
+ hasTokens: true,
148
+ }))
149
+ })
150
+
151
+ test('process-level TTL cache serves later resolver instances without re-probing', async () => {
152
+ const first = buildAvailability({ rowsByTable: { search_tokens: { one: 1 } } })
153
+ await expect(first.availability.hasTokens('example:todo', 't1', null)).resolves.toBe(true)
154
+ expect(countProbes(first.probes, 'search_tokens')).toBe(1)
155
+
156
+ const second = buildAvailability()
157
+ await expect(second.availability.hasTokens('example:todo', 't1', null)).resolves.toBe(true)
158
+ expect(countProbes(second.probes, 'search_tokens')).toBe(0)
159
+ })
160
+
161
+ test('process-level TTL cache expires and can be disabled', async () => {
162
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000_000)
163
+ try {
164
+ const first = buildAvailability()
165
+ await first.availability.hasTokens('example:todo', 't1', null)
166
+
167
+ nowSpy.mockReturnValue(1_000_000 + 30_001)
168
+ const second = buildAvailability()
169
+ await second.availability.hasTokens('example:todo', 't1', null)
170
+ expect(countProbes(second.probes, 'search_tokens')).toBe(1)
171
+
172
+ process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS = '0'
173
+ const third = buildAvailability()
174
+ await third.availability.hasTokens('example:todo', 't1', null)
175
+ expect(countProbes(third.probes, 'search_tokens')).toBe(1)
176
+ } finally {
177
+ nowSpy.mockRestore()
178
+ }
179
+ })
180
+
181
+ test('error-driven false results are not TTL-cached', async () => {
182
+ const first = buildAvailability({ failTables: ['search_tokens'] })
183
+ await expect(first.availability.hasTokens('example:todo', 't1', null)).resolves.toBe(false)
184
+
185
+ const second = buildAvailability({ rowsByTable: { search_tokens: { one: 1 } } })
186
+ await expect(second.availability.hasTokens('example:todo', 't1', null)).resolves.toBe(true)
187
+ expect(countProbes(second.probes, 'search_tokens')).toBe(1)
188
+ })
189
+
190
+ test('probe uses index-friendly tenant predicates instead of IS NOT DISTINCT FROM', async () => {
191
+ const { availability, probes } = buildAvailability()
192
+ await availability.hasTokens('example:todo', 't1', null)
193
+ await availability.hasTokens('example:todo', null, null)
194
+ const [scoped, global] = probes.filter((probe) => probe.table === 'search_tokens')
195
+ expect(scoped.wheres).toContainEqual(['tenant_id', '=', 't1'])
196
+ expect(global.wheres).toContainEqual(['tenant_id', 'is', null])
197
+ })
198
+
199
+ test('anySourceHasTokens sweeps all sources when none has tokens, sharing the memo with hasTokens', async () => {
200
+ const { availability, probes } = buildAvailability()
201
+ const sources = [
202
+ { entity: 'example:todo', recordIdColumn: 'b.id' },
203
+ { entity: 'example:other', recordIdColumn: 'cfs0.entity_id' },
204
+ ]
205
+ await expect(availability.anySourceHasTokens(sources, 't1', null)).resolves.toBe(false)
206
+ expect(countProbes(probes, 'search_tokens')).toBe(2)
207
+
208
+ await expect(availability.hasTokens('example:todo', 't1', null)).resolves.toBe(false)
209
+ expect(countProbes(probes, 'search_tokens')).toBe(2)
210
+ })
211
+ })
@@ -0,0 +1,232 @@
1
+ import { sql } from 'kysely'
2
+ import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
3
+
4
+ export type OrganizationScope = { ids: string[]; includeNull: boolean }
5
+
6
+ export type SearchTokenSourceRef = { entity: string; recordIdColumn?: string }
7
+
8
+ type ProbeExpression = object | string | readonly string[] | null
9
+
10
+ export type SearchTokenProbeQueryBuilder = {
11
+ select: (selection: ProbeExpression) => SearchTokenProbeQueryBuilder
12
+ where: (column: ProbeExpression, operator?: string, value?: ProbeExpression) => SearchTokenProbeQueryBuilder
13
+ limit: (count: number) => SearchTokenProbeQueryBuilder
14
+ executeTakeFirst: () => Promise<object | undefined>
15
+ }
16
+
17
+ export type SearchTokenProbeDb = { selectFrom: (table: string) => SearchTokenProbeQueryBuilder }
18
+
19
+ export type SearchTokenAvailabilityDebugPayload = {
20
+ entity: string
21
+ tenantId: string | null
22
+ organizationScope?: OrganizationScope | null
23
+ recordIdColumn?: string
24
+ hasTokens?: boolean
25
+ error?: string
26
+ }
27
+
28
+ export type SearchTokenAvailabilityDeps = {
29
+ getDb: () => SearchTokenProbeDb
30
+ getConfig: () => { enabled: boolean }
31
+ applyOrganizationScope: (
32
+ query: SearchTokenProbeQueryBuilder,
33
+ column: string,
34
+ scope: OrganizationScope,
35
+ ) => SearchTokenProbeQueryBuilder
36
+ logDebug: (event: string, payload: SearchTokenAvailabilityDebugPayload) => void
37
+ }
38
+
39
+ export type SearchTokenAvailability = {
40
+ /**
41
+ * The cheap, statically-known half of the decision: search is configured on
42
+ * AND the `search_tokens` table exists. Safe to resolve eagerly (consumers
43
+ * like custom-field source attachment need it before any filter is
44
+ * inspected); the table probe is memoized per instance.
45
+ */
46
+ staticEnabled: () => Promise<boolean>
47
+ /**
48
+ * The expensive half: does `search_tokens` hold any row for this
49
+ * (entity, tenant, organization scope)? Historically the `LIMIT 1` probe
50
+ * could degenerate into a seq scan on a large table (#4723), so callers MUST
51
+ * only ask when the query actually carries a like/ilike filter — use
52
+ * `hasSearchFilter` for the gate. Three layers keep it cheap for the end
53
+ * user: index-usable predicates (no `IS NOT DISTINCT FROM`) served by the
54
+ * dedicated `search_tokens_presence_idx (entity_type, tenant_id,
55
+ * organization_id)` prefix — which makes the MISS as cheap as the hit — a
56
+ * per-request instance memo, and a process-level TTL cache
57
+ * (`OM_SEARCH_TOKEN_PRESENCE_CACHE_MS`, default 30s) that amortizes the
58
+ * probe across requests. Probe errors log `search:has-tokens-error`,
59
+ * resolve to `false`, and are never TTL-cached.
60
+ */
61
+ hasTokens: (entity: string, tenantId: string | null, orgScope?: OrganizationScope | null) => Promise<boolean>
62
+ /** First-hit sweep over token sources; logs `search:source-has-tokens` per probed source. */
63
+ anySourceHasTokens: (
64
+ sources: SearchTokenSourceRef[],
65
+ tenantId: string | null,
66
+ orgScope?: OrganizationScope | null,
67
+ ) => Promise<boolean>
68
+ }
69
+
70
+ export function isSearchFilterOp(op: string | null | undefined): boolean {
71
+ return op === 'like' || op === 'ilike'
72
+ }
73
+
74
+ /**
75
+ * The single definition of "this query actually searches". Every consumer of
76
+ * the token-availability answer sits behind a like/ilike guard, so when this
77
+ * returns `false` the `hasTokens` probe's answer would never be read — gate
78
+ * the probe on it.
79
+ */
80
+ export function hasSearchFilter(filters: ReadonlyArray<{ op?: string | null }>): boolean {
81
+ return filters.some((filter) => isSearchFilterOp(filter.op))
82
+ }
83
+
84
+ function orgScopeKey(scope: OrganizationScope | null | undefined): string {
85
+ if (!scope) return 'none'
86
+ return `${scope.includeNull ? '1' : '0'}:${[...scope.ids].sort((left, right) => left.localeCompare(right)).join(',')}`
87
+ }
88
+
89
+ const PRESENCE_CACHE_DEFAULT_TTL_MS = 30_000
90
+ const PRESENCE_CACHE_MAX_ENTRIES = 10_000
91
+
92
+ // Process-level TTL cache for the token-presence answer. Module-level (process-global) on
93
+ // purpose: `createRequestContainer` builds fresh engines — and with them fresh resolver
94
+ // instances — per request, so an instance-scoped memo alone re-pays the probe on every
95
+ // request. On a large `search_tokens` one probe can be pathologically expensive (#4723),
96
+ // so the answer is amortized across requests here and only re-checked once per TTL.
97
+ // Staleness contract: a stale `false` keeps like/ilike on the plain-column fallback for up
98
+ // to the TTL after an entity's first tokens are written; a stale `true` routes search
99
+ // through an emptied token set for up to the TTL after a purge. Both converge within the
100
+ // TTL; set OM_SEARCH_TOKEN_PRESENCE_CACHE_MS=0 to disable and probe per request again.
101
+ const presenceCache = new Map<string, { value: boolean; expiresAt: number }>()
102
+
103
+ function resolvePresenceCacheTtlMs(): number {
104
+ return parseNumberWithDefault(process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS, PRESENCE_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })
105
+ }
106
+
107
+ function storePresence(key: string, value: boolean, ttlMs: number): void {
108
+ if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) {
109
+ const now = Date.now()
110
+ for (const [entryKey, entry] of presenceCache) {
111
+ if (entry.expiresAt <= now) presenceCache.delete(entryKey)
112
+ }
113
+ if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) presenceCache.clear()
114
+ }
115
+ presenceCache.set(key, { value, expiresAt: Date.now() + ttlMs })
116
+ }
117
+
118
+ export function clearSearchTokenPresenceCache(): void {
119
+ presenceCache.clear()
120
+ }
121
+
122
+ /**
123
+ * One place that answers "is token search usable here?" for both query
124
+ * engines, instead of each hand-assembling config + table-existence +
125
+ * token-presence probes with private duplicate helpers and ad-hoc memo maps.
126
+ *
127
+ * Memoization is per instance; engines are constructed per request
128
+ * (`createRequestContainer`), so entries never outlive a request — the same
129
+ * staleness contract the engines' previous per-query join maps had. A
130
+ * rejected table probe is evicted so the next call retries instead of
131
+ * observing a poisoned cache entry.
132
+ */
133
+ export function createSearchTokenAvailability(deps: SearchTokenAvailabilityDeps): SearchTokenAvailability {
134
+ const tablePresence = new Map<string, Promise<boolean>>()
135
+ const tokenPresence = new Map<string, Promise<boolean>>()
136
+
137
+ const tableExists = (table: string): Promise<boolean> => {
138
+ const cached = tablePresence.get(table)
139
+ if (cached) return cached
140
+ const probe = (async () => {
141
+ const row = await deps.getDb()
142
+ .selectFrom('information_schema.tables')
143
+ .select(sql<number>`1`.as('one'))
144
+ .where('table_name', '=', table)
145
+ .limit(1)
146
+ .executeTakeFirst()
147
+ return !!row
148
+ })()
149
+ tablePresence.set(table, probe)
150
+ probe.catch(() => tablePresence.delete(table))
151
+ return probe
152
+ }
153
+
154
+ const probeTokens = async (
155
+ entity: string,
156
+ tenantId: string | null,
157
+ orgScope?: OrganizationScope | null,
158
+ ): Promise<boolean> => {
159
+ let query = deps.getDb()
160
+ .selectFrom('search_tokens')
161
+ .select(sql<number>`1`.as('one'))
162
+ .where('entity_type', '=', entity)
163
+ // Deliberately `= / IS NULL` instead of `IS NOT DISTINCT FROM` (identical semantics
164
+ // for a string|null tenant): the latter cannot serve as an index condition, which is
165
+ // part of why the planner degraded this probe to a seq scan on large tables (#4723).
166
+ // With plain predicates the probe is a pure prefix seek on
167
+ // `search_tokens_presence_idx (entity_type, tenant_id, organization_id)`, making the
168
+ // miss as cheap as the hit.
169
+ query = tenantId == null
170
+ ? query.where('tenant_id', 'is', null)
171
+ : query.where('tenant_id', '=', tenantId)
172
+ if (orgScope) {
173
+ query = deps.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
174
+ }
175
+ const row = await query.limit(1).executeTakeFirst()
176
+ return !!row
177
+ }
178
+
179
+ const hasTokens = (
180
+ entity: string,
181
+ tenantId: string | null,
182
+ orgScope?: OrganizationScope | null,
183
+ ): Promise<boolean> => {
184
+ const key = `${entity}|${tenantId ?? '__null__'}|${orgScopeKey(orgScope)}`
185
+ const ttlMs = resolvePresenceCacheTtlMs()
186
+ if (ttlMs > 0) {
187
+ const entry = presenceCache.get(key)
188
+ if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value)
189
+ }
190
+ const cached = tokenPresence.get(key)
191
+ if (cached) return cached
192
+ const probe = (async () => {
193
+ try {
194
+ const value = await probeTokens(entity, tenantId, orgScope)
195
+ // Only genuine probe results enter the process-level cache — caching an
196
+ // error-driven `false` would pin degraded search for a full TTL after a
197
+ // transient DB failure.
198
+ if (ttlMs > 0) storePresence(key, value, ttlMs)
199
+ return value
200
+ } catch (err) {
201
+ deps.logDebug('search:has-tokens-error', {
202
+ entity,
203
+ tenantId,
204
+ organizationScope: orgScope,
205
+ error: err instanceof Error ? err.message : String(err),
206
+ })
207
+ return false
208
+ }
209
+ })()
210
+ tokenPresence.set(key, probe)
211
+ return probe
212
+ }
213
+
214
+ return {
215
+ staticEnabled: async () => deps.getConfig().enabled && await tableExists('search_tokens'),
216
+ hasTokens,
217
+ anySourceHasTokens: async (sources, tenantId, orgScope) => {
218
+ for (const source of sources) {
219
+ const ok = await hasTokens(source.entity, tenantId, orgScope)
220
+ deps.logDebug('search:source-has-tokens', {
221
+ entity: source.entity,
222
+ recordIdColumn: source.recordIdColumn,
223
+ tenantId,
224
+ organizationScope: orgScope,
225
+ hasTokens: ok,
226
+ })
227
+ if (ok) return true
228
+ }
229
+ return false
230
+ },
231
+ }
232
+ }