@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +57 -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 +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.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 +72 -9
- 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/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/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 +5 -1
- 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 +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- 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 +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -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.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -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 +82 -6
- 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__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -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__/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/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__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- 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.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- 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/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'
|
|
@@ -206,8 +214,8 @@ function computeCustomFieldScore(cfg: Record<string, unknown>, kind: string, ent
|
|
|
206
214
|
*/
|
|
207
215
|
export class BasicQueryEngine implements QueryEngine {
|
|
208
216
|
private columnCache = new Map<string, boolean>()
|
|
209
|
-
private tableCache = new Map<string, boolean>()
|
|
210
217
|
private searchAliasSeq = 0
|
|
218
|
+
private searchAvailabilityInstance: SearchTokenAvailability | null = null
|
|
211
219
|
|
|
212
220
|
constructor(
|
|
213
221
|
private em: EntityManager,
|
|
@@ -230,6 +238,22 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
230
238
|
throw new Error('BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
|
|
231
239
|
}
|
|
232
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
|
+
|
|
233
257
|
async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
|
|
234
258
|
// --- UMES query extension: before-query pipeline ---
|
|
235
259
|
const ext = opts.extensions
|
|
@@ -293,17 +317,20 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
293
317
|
const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
|
|
294
318
|
const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
|
|
295
319
|
const searchConfig = resolveSearchConfig()
|
|
320
|
+
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op))
|
|
296
321
|
// Callers that opt out of automatic tenant/org scoping own the full
|
|
297
322
|
// visibility predicate. Search-token filtering has its own tenant/org
|
|
298
323
|
// guards, so it must be disabled on this direct-query path as documented
|
|
299
324
|
// by QueryOptions.omitAutomaticTenantOrgScope.
|
|
300
|
-
const searchEnabled = !skipAutoScope &&
|
|
301
|
-
|
|
302
|
-
|
|
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)
|
|
303
332
|
: false
|
|
304
333
|
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
334
|
if (searchFilters.length) {
|
|
308
335
|
const fields = searchFilters.map((filter) => String(filter.field))
|
|
309
336
|
this.logSearchDebug('search:init', {
|
|
@@ -333,6 +360,45 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
333
360
|
organizationScope: orgScope,
|
|
334
361
|
})
|
|
335
362
|
}
|
|
363
|
+
const fallbackFields = searchFilters
|
|
364
|
+
.filter((filter) => !searchActive || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
365
|
+
.map((filter) => String(filter.field))
|
|
366
|
+
if (fallbackFields.length) {
|
|
367
|
+
await warnOnCiphertextLikeFallback({
|
|
368
|
+
entity: String(entity),
|
|
369
|
+
fields: fallbackFields,
|
|
370
|
+
tenantId: opts.tenantId ?? null,
|
|
371
|
+
// `searchEnabled` also folds in the missing-table and
|
|
372
|
+
// omitAutomaticTenantOrgScope cases, which are "no usable tokens"
|
|
373
|
+
// rather than "the operator switched search off".
|
|
374
|
+
reason: searchActive
|
|
375
|
+
? 'no-indexable-tokens'
|
|
376
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
377
|
+
service: this.getEncryptionService(),
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const [alias, joinedFilters] of joinFilters) {
|
|
382
|
+
const filters = joinedFilters.filter((entry) => entry.op === 'like' || entry.op === 'ilike')
|
|
383
|
+
if (!filters.length) continue
|
|
384
|
+
const join = joinMap.get(alias)
|
|
385
|
+
if (!join?.entityId) continue
|
|
386
|
+
const hasJoinedTokens = searchEnabled
|
|
387
|
+
? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
388
|
+
: false
|
|
389
|
+
const fallbackFields = filters
|
|
390
|
+
.filter((filter) => !hasJoinedTokens || typeof filter.value !== 'string' || tokenizeText(filter.value, searchConfig).hashes.length === 0)
|
|
391
|
+
.map((filter) => filter.column)
|
|
392
|
+
if (!fallbackFields.length) continue
|
|
393
|
+
await warnOnCiphertextLikeFallback({
|
|
394
|
+
entity: join.entityId,
|
|
395
|
+
fields: fallbackFields,
|
|
396
|
+
tenantId: opts.tenantId ?? null,
|
|
397
|
+
reason: hasJoinedTokens
|
|
398
|
+
? 'no-indexable-tokens'
|
|
399
|
+
: searchConfig.enabled ? 'no-search-tokens' : 'search-disabled',
|
|
400
|
+
service: this.getEncryptionService(),
|
|
401
|
+
})
|
|
336
402
|
}
|
|
337
403
|
const recordIdColumn = qualify('id')
|
|
338
404
|
|
|
@@ -396,11 +462,7 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
396
462
|
if (!['like', 'ilike'].includes(filter.op)) return { applied: false, builder }
|
|
397
463
|
if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return { applied: false, builder }
|
|
398
464
|
|
|
399
|
-
|
|
400
|
-
if (searchAvailable === undefined) {
|
|
401
|
-
searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
402
|
-
joinSearchAvailability.set(join.entityId, searchAvailable)
|
|
403
|
-
}
|
|
465
|
+
const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope)
|
|
404
466
|
if (!searchAvailable) return { applied: false, builder }
|
|
405
467
|
|
|
406
468
|
const tokens = tokenizeText(String(filter.value), searchConfig)
|
|
@@ -471,8 +533,8 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
471
533
|
// today's complete selection (base fields + CF projections + extension joins).
|
|
472
534
|
// `projection: 'sortKeys'` selects only `id` + the sort columns — the slim phase-1
|
|
473
535
|
// candidate scan used when `requiresPlaintextSort`. Re-running the WHERE/JOIN logic
|
|
474
|
-
// twice is cheap: every `columnExists
|
|
475
|
-
//
|
|
536
|
+
// twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
|
|
537
|
+
// so the second pass hits no extra DB calls.
|
|
476
538
|
const buildQuery = async (projection: 'full' | 'sortKeys'): Promise<BuiltQuery> => {
|
|
477
539
|
const isSortKeysProjection = projection === 'sortKeys'
|
|
478
540
|
let q: AnyBuilder = db.selectFrom(table as any)
|
|
@@ -1136,51 +1198,6 @@ export class BasicQueryEngine implements QueryEngine {
|
|
|
1136
1198
|
return present
|
|
1137
1199
|
}
|
|
1138
1200
|
|
|
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
1201
|
private applySearchTokens(
|
|
1185
1202
|
q: AnyBuilder,
|
|
1186
1203
|
opts: {
|
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
|
+
})
|