@open-mercato/shared 0.6.7-develop.6788.1.c2a1520a30 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/query/engine.js +25 -42
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +8 -8
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/engine.ts +36 -60
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/availability.ts +232 -0
|
@@ -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
|
+
}
|