@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.
Files changed (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -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 && searchConfig.enabled && await this.tableExists('search_tokens')
301
- const hasSearchTokens = searchEnabled
302
- ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
325
+ const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled()
326
+ // Probe `search_tokens` only when this query actually searches (#4723): every consumer of
327
+ // `searchActive` sits behind a like/ilike guard, so on a plain list load the answer is never
328
+ // read — and the probe is a `LIMIT 1` the planner can resolve as a seq scan over a large
329
+ // `search_tokens`. The join path below already probes lazily for the same reason.
330
+ const hasSearchTokens = searchEnabled && searchFilters.length
331
+ ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope)
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
- let searchAvailable = joinSearchAvailability.get(join.entityId)
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`/`tableExists` check is memoized on
475
- // `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
536
+ // twice is cheap: every `columnExists` check is memoized on `this.columnCache`,
537
+ // so the second pass hits no extra DB calls.
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: {
@@ -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, which means
112
- * custom-field (`cf:*`) filters/sorts, `search_tokens` fulltext filtering, and vector-search
113
- * branches are BYPASSED. Only use this on entities whose scoping does not match the standard
114
- * `organization_id = X AND tenant_id = Y` shape and which do not rely on custom-field/search
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 a {host, port, password, db} object
63
- * suitable for BullMQ / ioredis structured connection options.
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
- password: parsed.password || undefined,
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
+ })