@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4
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 +62 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +190 -70
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -3
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +9 -4
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +104 -23
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +36 -6
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +51 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +60 -43
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +20 -10
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +64 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +346 -96
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
- package/src/lib/commands/command-bus.ts +9 -3
- package/src/lib/commands/command-interceptor-runner.ts +27 -6
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +157 -22
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +46 -7
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
- package/src/lib/query/__tests__/engine.test.ts +213 -13
- package/src/lib/query/ciphertext-search-warning.ts +106 -0
- package/src/lib/query/engine.ts +102 -61
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/entities.ts +15 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
package/src/lib/query/engine.ts
CHANGED
|
@@ -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 {
|
|
@@ -20,6 +27,7 @@ import {
|
|
|
20
27
|
type CustomFieldDefinitionRow,
|
|
21
28
|
type ResolvedCustomFieldDefinitions,
|
|
22
29
|
} from '../crud/custom-field-definition-index'
|
|
30
|
+
import { warnOnCiphertextLikeFallback } from './ciphertext-search-warning'
|
|
23
31
|
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from './encrypted-sort'
|
|
24
32
|
import { mapWithConcurrency } from './bounded-decrypt'
|
|
25
33
|
import { createLogger } from '../logger'
|
|
@@ -64,6 +72,16 @@ const pluralizeBaseName = (name: string): string => {
|
|
|
64
72
|
return `${name}s`
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Accepts a module-declared `EntityExtension.table` only when it is a bare SQL
|
|
77
|
+
* identifier. The value is interpolated into a join clause, so anything else is
|
|
78
|
+
* ignored in favour of the derived table name.
|
|
79
|
+
*/
|
|
80
|
+
const PLAIN_TABLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
81
|
+
|
|
82
|
+
const isPlainTableIdentifier = (value: unknown): value is string =>
|
|
83
|
+
typeof value === 'string' && PLAIN_TABLE_IDENTIFIER_PATTERN.test(value)
|
|
84
|
+
|
|
67
85
|
const toPascalCase = (value: string): string => {
|
|
68
86
|
return value
|
|
69
87
|
.split(/[_\s]+/)
|
|
@@ -206,8 +224,8 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
|
|
|
206
224
|
*/
|
|
207
225
|
export class BasicQueryEngine implements QueryEngine {
|
|
208
226
|
private columnCache = new Map<string, boolean>()
|
|
209
|
-
private tableCache = new Map<string, boolean>()
|
|
210
227
|
private searchAliasSeq = 0
|
|
228
|
+
private searchAvailabilityInstance: SearchTokenAvailability | null = null
|
|
211
229
|
|
|
212
230
|
constructor(
|
|
213
231
|
private em: EntityManager,
|
|
@@ -230,6 +248,22 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
230
248
|
throw new Error('BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
private searchAvailability(): SearchTokenAvailability {
|
|
252
|
+
if (!this.searchAvailabilityInstance) {
|
|
253
|
+
this.searchAvailabilityInstance = createSearchTokenAvailability({
|
|
254
|
+
getDb: () => this.getDb() as unknown as SearchTokenProbeDb,
|
|
255
|
+
getConfig: resolveSearchConfig,
|
|
256
|
+
applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
|
|
257
|
+
query as unknown as AnyBuilder,
|
|
258
|
+
column,
|
|
259
|
+
scope,
|
|
260
|
+
) as unknown as SearchTokenProbeQueryBuilder,
|
|
261
|
+
logDebug: (event, payload) => this.logSearchDebug(event, payload),
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
return this.searchAvailabilityInstance
|
|
265
|
+
}
|
|
266
|
+
|
|
233
267
|
async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
234
268
|
// --- UMES query extension: before-query pipeline ---
|
|
235
269
|
const ext = opts.extensions
|
|
@@ -293,17 +327,20 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
293
327
|
const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
|
|
294
328
|
const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
|
|
295
329
|
const searchConfig = resolveSearchConfig()
|
|
330
|
+
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
|
|
296
331
|
// Callers that opt out of automatic tenant/org scoping own the full
|
|
297
332
|
// visibility predicate. Search-token filtering has its own tenant/org
|
|
298
333
|
// guards, so it must be disabled on this direct-query path as documented
|
|
299
334
|
// by QueryOptions.omitAutomaticTenantOrgScope.
|
|
300
|
-
const searchEnabled = !skipAutoScope &&
|
|
301
|
-
|
|
302
|
-
|
|
335
|
+
const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled()
|
|
336
|
+
// Probe `search_tokens` only when this query actually searches (#4723): every consumer of
|
|
337
|
+
// `searchActive` sits behind a like/ilike guard, so on a plain list load the answer is never
|
|
338
|
+
// read — and the probe is a `LIMIT 1` the planner can resolve as a seq scan over a large
|
|
339
|
+
// `search_tokens`. The join path below already probes lazily for the same reason.
|
|
340
|
+
const hasSearchTokens = searchEnabled && searchFilters.length
|
|
341
|
+
? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
|
|
303
342
|
: false
|
|
304
343
|
const searchActive = searchEnabled && hasSearchTokens
|
|
305
|
-
const joinSearchAvailability = new Map<string, boolean>()
|
|
306
|
-
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike')
|
|
307
344
|
if (searchFilters.length) {
|
|
308
345
|
const fields = searchFilters.map((filter) => String(filter.field))
|
|
309
346
|
this.logSearchDebug('search:init', {
|
|
@@ -333,6 +370,45 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
333
370
|
organizationScope: orgScope,
|
|
334
371
|
})
|
|
335
372
|
}
|
|
373
|
+
const fallbackFields = searchFilters
|
|
374
|
+
.filter((filter) => !searchActive || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
375
|
+
.map((filter) => String(filter.field))
|
|
376
|
+
if (fallbackFields.length) {
|
|
377
|
+
await warnOnCiphertextLikeFallback({
|
|
378
|
+
entity: String(entity),
|
|
379
|
+
fields: fallbackFields,
|
|
380
|
+
tenantId: opts.tenantId ?? null,
|
|
381
|
+
// `searchEnabled` also folds in the missing-table and
|
|
382
|
+
// omitAutomaticTenantOrgScope cases, which are "no usable tokens"
|
|
383
|
+
// rather than "the operator switched search off".
|
|
384
|
+
reason: searchActive
|
|
385
|
+
? 'no-indexable-tokens'
|
|
386
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
387
|
+
service: this.getEncryptionService(),
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const [alias, joinedFilters] of joinFilters) {
|
|
392
|
+
const filters = joinedFilters.filter((entry) => entry.op === 'like' || entry.op === 'ilike')
|
|
393
|
+
if (!filters.length) continue
|
|
394
|
+
const join = joinMap.get(alias)
|
|
395
|
+
if (!join?.entityId) continue
|
|
396
|
+
const hasJoinedTokens = searchEnabled
|
|
397
|
+
? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
398
|
+
: false
|
|
399
|
+
const fallbackFields = filters
|
|
400
|
+
.filter((filter) => !hasJoinedTokens || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
401
|
+
.map((filter) => filter.column)
|
|
402
|
+
if (!fallbackFields.length) continue
|
|
403
|
+
await warnOnCiphertextLikeFallback({
|
|
404
|
+
entity: join.entityId,
|
|
405
|
+
fields: fallbackFields,
|
|
406
|
+
tenantId: opts.tenantId ?? null,
|
|
407
|
+
reason: hasJoinedTokens
|
|
408
|
+
? 'no-indexable-tokens'
|
|
409
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
410
|
+
service: this.getEncryptionService(),
|
|
411
|
+
})
|
|
336
412
|
}
|
|
337
413
|
const recordIdColumn = qualify('id')
|
|
338
414
|
|
|
@@ -396,11 +472,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
396
472
|
if (!['like', 'ilike'].includes(filter.op)) return { applied: false, builder }
|
|
397
473
|
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return { applied: false, builder }
|
|
398
474
|
|
|
399
|
-
|
|
400
|
-
if (searchAvailable === undefined) {
|
|
401
|
-
searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
402
|
-
joinSearchAvailability.set(join.entityId, searchAvailable)
|
|
403
|
-
}
|
|
475
|
+
const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
404
476
|
if (!searchAvailable) return { applied: false, builder }
|
|
405
477
|
|
|
406
478
|
const tokens = tokenizeText(String(filter.value), searchConfig)
|
|
@@ -471,8 +543,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
471
543
|
// today's complete selection (base fields + CF projections + extension joins).
|
|
472
544
|
// `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
|
|
473
545
|
// candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
|
|
474
|
-
// twice is cheap: every `columnExists
|
|
475
|
-
//
|
|
546
|
+
// twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
|
|
547
|
+
// so the second pass hits no extra DB calls.
|
|
476
548
|
const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
|
|
477
549
|
const isSortKeysProjection = projection === 'sortKeys'
|
|
478
550
|
let q: AnyBuilder = db.selectFrom(table as any)
|
|
@@ -861,7 +933,15 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
861
933
|
: exts
|
|
862
934
|
for (const e of chosen) {
|
|
863
935
|
const [, extName] = (e.extension as string).split(':')
|
|
864
|
-
|
|
936
|
+
// Uses the SAME derivation as every other table-name fallback in this file
|
|
937
|
+
// (`pluralizeBaseName`, also called at the resolveEntityTableName sites above)
|
|
938
|
+
// rather than a separate inline one. The inline version handled only `+s`, so
|
|
939
|
+
// `example_customer_priority` derived `example_customer_prioritys` against the
|
|
940
|
+
// real `example_customer_priorities`. Behaviour is unchanged for every name that
|
|
941
|
+
// does not end in `y`; `table` below remains the escape hatch for irregular
|
|
942
|
+
// plurals no guesser can win (`person` → `people`).
|
|
943
|
+
const derivedTable = pluralizeBaseName(extName)
|
|
944
|
+
const extTable = isPlainTableIdentifier(e.table) ? e.table : derivedTable
|
|
865
945
|
const alias = `ext_${sanitize(extName)}`
|
|
866
946
|
q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
|
|
867
947
|
jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
|
|
@@ -1136,51 +1216,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1136
1216
|
return present
|
|
1137
1217
|
}
|
|
1138
1218
|
|
|
1139
|
-
private async tableExists(table: string): Promise<boolean> {
|
|
1140
|
-
if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
|
|
1141
|
-
const db = this.getDb()
|
|
1142
|
-
const exists = await db
|
|
1143
|
-
.selectFrom('information_schema.tables' as any)
|
|
1144
|
-
.select(sql<number>`1`.as('one'))
|
|
1145
|
-
.where('table_name' as any, '=', table)
|
|
1146
|
-
.limit(1)
|
|
1147
|
-
.executeTakeFirst()
|
|
1148
|
-
const present = !!exists
|
|
1149
|
-
this.tableCache.set(table, present)
|
|
1150
|
-
return present
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
private async hasSearchTokens(
|
|
1154
|
-
entity: string,
|
|
1155
|
-
tenantId: string | null,
|
|
1156
|
-
orgScope?: { ids: string[]; includeNull: boolean } | null
|
|
1157
|
-
): Promise<boolean> {
|
|
1158
|
-
try {
|
|
1159
|
-
const db = this.getDb()
|
|
1160
|
-
let query: AnyBuilder = db
|
|
1161
|
-
.selectFrom('search_tokens' as any)
|
|
1162
|
-
.select(sql<number>`1`.as('one'))
|
|
1163
|
-
.where('entity_type' as any, '=', entity)
|
|
1164
|
-
.limit(1)
|
|
1165
|
-
if (tenantId !== undefined) {
|
|
1166
|
-
query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
|
|
1167
|
-
}
|
|
1168
|
-
if (orgScope) {
|
|
1169
|
-
query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
|
|
1170
|
-
}
|
|
1171
|
-
const row = await query.executeTakeFirst()
|
|
1172
|
-
return !!row
|
|
1173
|
-
} catch (err) {
|
|
1174
|
-
this.logSearchDebug('search:has-tokens-error', {
|
|
1175
|
-
entity,
|
|
1176
|
-
tenantId,
|
|
1177
|
-
organizationScope: orgScope,
|
|
1178
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1179
|
-
})
|
|
1180
|
-
return false
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
1219
|
private applySearchTokens(
|
|
1185
1220
|
q: AnyBuilder,
|
|
1186
1221
|
opts: {
|
|
@@ -1334,9 +1369,15 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1334
1369
|
const textExpr = sql<string | null>`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`
|
|
1335
1370
|
switch (opts.op) {
|
|
1336
1371
|
case 'eq':
|
|
1337
|
-
sub =
|
|
1372
|
+
sub = opts.value === null
|
|
1373
|
+
? sub.where(sql<boolean>`${textExpr} is null`)
|
|
1374
|
+
: sub.where(sql<boolean>`${textExpr} = ${opts.value}`)
|
|
1375
|
+
break
|
|
1338
1376
|
case 'ne':
|
|
1339
|
-
sub =
|
|
1377
|
+
sub = opts.value === null
|
|
1378
|
+
? sub.where(sql<boolean>`${textExpr} is not null`)
|
|
1379
|
+
: sub.where(sql<boolean>`${textExpr} <> ${opts.value}`)
|
|
1380
|
+
break
|
|
1340
1381
|
case 'gt':
|
|
1341
1382
|
case 'gte':
|
|
1342
1383
|
case 'lt':
|
package/src/lib/query/types.ts
CHANGED
|
@@ -108,11 +108,10 @@ export type QueryOptions = {
|
|
|
108
108
|
* and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise
|
|
109
109
|
* queries return cross-tenant rows.
|
|
110
110
|
*
|
|
111
|
-
* When this flag is set, the hybrid query engine delegates to the basic engine
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `organization_id = X AND tenant_id = Y` shape
|
|
115
|
-
* features.
|
|
111
|
+
* When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine
|
|
112
|
+
* still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read
|
|
113
|
+
* path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does
|
|
114
|
+
* not match the standard `organization_id = X AND tenant_id = Y` shape.
|
|
116
115
|
*/
|
|
117
116
|
omitAutomaticTenantOrgScope?: boolean
|
|
118
117
|
// Soft-delete behavior: when false (default), rows with non-null deleted_at
|
|
@@ -5,9 +5,11 @@ describe('parseRedisUrl', () => {
|
|
|
5
5
|
expect(parseRedisUrl('rediss://:secret@example.cache.amazonaws.com:6379/2')).toEqual({
|
|
6
6
|
host: 'example.cache.amazonaws.com',
|
|
7
7
|
port: 6379,
|
|
8
|
+
username: undefined,
|
|
8
9
|
password: 'secret',
|
|
9
10
|
db: 2,
|
|
10
11
|
tls: {},
|
|
12
|
+
family: undefined,
|
|
11
13
|
})
|
|
12
14
|
})
|
|
13
15
|
|
|
@@ -15,9 +17,23 @@ describe('parseRedisUrl', () => {
|
|
|
15
17
|
expect(parseRedisUrl('redis://:secret@localhost:6379/0')).toEqual({
|
|
16
18
|
host: 'localhost',
|
|
17
19
|
port: 6379,
|
|
20
|
+
username: undefined,
|
|
18
21
|
password: 'secret',
|
|
19
22
|
db: 0,
|
|
20
23
|
tls: undefined,
|
|
24
|
+
family: undefined,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves ACL credentials and supported address-family options', () => {
|
|
29
|
+
expect(parseRedisUrl('rediss://app%2Duser:p%40ss@example.com:6380/4?family=6')).toEqual({
|
|
30
|
+
host: 'example.com',
|
|
31
|
+
port: 6380,
|
|
32
|
+
username: 'app-user',
|
|
33
|
+
password: 'p@ss',
|
|
34
|
+
db: 4,
|
|
35
|
+
tls: {},
|
|
36
|
+
family: 6,
|
|
21
37
|
})
|
|
22
38
|
})
|
|
23
39
|
})
|
|
@@ -21,9 +21,11 @@ const logger = createLogger('shared').child({ component: 'redis' })
|
|
|
21
21
|
export type ParsedRedisConnection = {
|
|
22
22
|
host: string
|
|
23
23
|
port: number
|
|
24
|
+
username?: string
|
|
24
25
|
password?: string
|
|
25
26
|
db?: number
|
|
26
27
|
tls?: Record<string, unknown>
|
|
28
|
+
family?: number
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -59,12 +61,8 @@ export function getRedisUrlOrThrow(prefix?: string): string {
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
|
-
* Parse a redis:// URL into
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* @deprecated Prefer passing the full URL via `{ url: getRedisUrl(...) }` to
|
|
66
|
-
* BullMQ/ioredis — this preserves rediss://, username, database, and query
|
|
67
|
-
* params that structured parsing may lose. Kept for backward compatibility.
|
|
64
|
+
* Parse a redis:// URL into structured connection options suitable for
|
|
65
|
+
* BullMQ and ioredis.
|
|
68
66
|
*/
|
|
69
67
|
export function parseRedisUrl(url: string): ParsedRedisConnection {
|
|
70
68
|
try {
|
|
@@ -72,12 +70,17 @@ export function parseRedisUrl(url: string): ParsedRedisConnection {
|
|
|
72
70
|
const dbStr = parsed.pathname ? parsed.pathname.slice(1) : ''
|
|
73
71
|
const dbParsed = dbStr !== '' ? parseInt(dbStr, 10) : NaN
|
|
74
72
|
const db = Number.isNaN(dbParsed) ? undefined : dbParsed
|
|
73
|
+
const familyValue = parsed.searchParams.get('family')
|
|
74
|
+
const familyParsed = familyValue !== null ? parseInt(familyValue, 10) : NaN
|
|
75
|
+
const family = [0, 4, 6].includes(familyParsed) ? familyParsed : undefined
|
|
75
76
|
return {
|
|
76
77
|
host: parsed.hostname || 'localhost',
|
|
77
78
|
port: parseInt(parsed.port, 10) || 6379,
|
|
78
|
-
|
|
79
|
+
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
80
|
+
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
79
81
|
db,
|
|
80
82
|
tls: parsed.protocol === 'rediss:' ? {} : undefined,
|
|
83
|
+
family,
|
|
81
84
|
}
|
|
82
85
|
} catch {
|
|
83
86
|
const safeUrl = url.replace(/\/\/[^:]*:[^@]*@/, '//<redacted>@')
|
|
@@ -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
|
+
})
|