@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.
Files changed (224) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +62 -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 +190 -70
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/bootstrap/factory.js +18 -13
  15. package/dist/lib/bootstrap/factory.js.map +2 -2
  16. package/dist/lib/commands/command-bus.js +9 -3
  17. package/dist/lib/commands/command-bus.js.map +2 -2
  18. package/dist/lib/commands/command-interceptor-runner.js +9 -4
  19. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  20. package/dist/lib/commands/errors.js +25 -4
  21. package/dist/lib/commands/errors.js.map +2 -2
  22. package/dist/lib/commands/index.js +7 -1
  23. package/dist/lib/commands/index.js.map +2 -2
  24. package/dist/lib/crud/enricher-runner.js +2 -11
  25. package/dist/lib/crud/enricher-runner.js.map +2 -2
  26. package/dist/lib/crud/factory.js +104 -23
  27. package/dist/lib/crud/factory.js.map +2 -2
  28. package/dist/lib/crud/interceptor-runner.js +2 -2
  29. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  30. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  31. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  32. package/dist/lib/crud/types.js +4 -0
  33. package/dist/lib/crud/types.js.map +3 -3
  34. package/dist/lib/data/consistency.js +19 -0
  35. package/dist/lib/data/consistency.js.map +7 -0
  36. package/dist/lib/data/engine.js +37 -11
  37. package/dist/lib/data/engine.js.map +2 -2
  38. package/dist/lib/db/entityMetadata.js +26 -0
  39. package/dist/lib/db/entityMetadata.js.map +7 -0
  40. package/dist/lib/db/pg-errors.js +42 -0
  41. package/dist/lib/db/pg-errors.js.map +2 -2
  42. package/dist/lib/di/container.js +48 -6
  43. package/dist/lib/di/container.js.map +2 -2
  44. package/dist/lib/email/send.js +56 -0
  45. package/dist/lib/email/send.js.map +2 -2
  46. package/dist/lib/encryption/find.js +22 -0
  47. package/dist/lib/encryption/find.js.map +2 -2
  48. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  49. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  50. package/dist/lib/encryption/subscriber.js +2 -10
  51. package/dist/lib/encryption/subscriber.js.map +2 -2
  52. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  53. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  54. package/dist/lib/i18n/config.js +1 -1
  55. package/dist/lib/i18n/config.js.map +2 -2
  56. package/dist/lib/i18n/dictionary-cache.js +5 -0
  57. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  58. package/dist/lib/i18n/server.js +4 -4
  59. package/dist/lib/i18n/server.js.map +3 -3
  60. package/dist/lib/logger/extension.js +29 -0
  61. package/dist/lib/logger/extension.js.map +7 -0
  62. package/dist/lib/logger/index.js +47 -3
  63. package/dist/lib/logger/index.js.map +3 -3
  64. package/dist/lib/modules/registry.js +36 -6
  65. package/dist/lib/modules/registry.js.map +2 -2
  66. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  67. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  68. package/dist/lib/query/ciphertext-search-warning.js +51 -0
  69. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  70. package/dist/lib/query/engine.js +60 -43
  71. package/dist/lib/query/engine.js.map +2 -2
  72. package/dist/lib/query/types.js.map +1 -1
  73. package/dist/lib/redis/connection.js +7 -2
  74. package/dist/lib/redis/connection.js.map +2 -2
  75. package/dist/lib/search/auto-indexing.js +14 -0
  76. package/dist/lib/search/auto-indexing.js.map +7 -0
  77. package/dist/lib/search/availability.js +107 -0
  78. package/dist/lib/search/availability.js.map +7 -0
  79. package/dist/lib/search/config.js +61 -2
  80. package/dist/lib/search/config.js.map +2 -2
  81. package/dist/lib/search/tokenLookup.js +46 -0
  82. package/dist/lib/search/tokenLookup.js.map +7 -0
  83. package/dist/lib/search/tokenize.js +23 -11
  84. package/dist/lib/search/tokenize.js.map +2 -2
  85. package/dist/lib/telemetry/runtime.js +35 -0
  86. package/dist/lib/telemetry/runtime.js.map +7 -0
  87. package/dist/lib/version.js +1 -1
  88. package/dist/lib/version.js.map +1 -1
  89. package/dist/lib/webhooks/body.js +57 -0
  90. package/dist/lib/webhooks/body.js.map +7 -0
  91. package/dist/lib/webhooks/inbound-types.js +1 -0
  92. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  93. package/dist/lib/webhooks/index.js +13 -1
  94. package/dist/lib/webhooks/index.js.map +2 -2
  95. package/dist/lib/webhooks/verify.js +12 -7
  96. package/dist/lib/webhooks/verify.js.map +2 -2
  97. package/dist/modules/analytics.js.map +2 -2
  98. package/dist/modules/overrides.js +50 -1
  99. package/dist/modules/overrides.js.map +2 -2
  100. package/dist/modules/payment_gateways/types.js +8 -1
  101. package/dist/modules/payment_gateways/types.js.map +2 -2
  102. package/dist/modules/registry.js +1 -0
  103. package/dist/modules/registry.js.map +2 -2
  104. package/dist/modules/widgets/extension-points.js +96 -0
  105. package/dist/modules/widgets/extension-points.js.map +7 -0
  106. package/dist/security/enabledModulesRegistry.js +56 -11
  107. package/dist/security/enabledModulesRegistry.js.map +2 -2
  108. package/dist/security/featurePolicy.js +62 -0
  109. package/dist/security/featurePolicy.js.map +7 -0
  110. package/jest.config.cjs +20 -10
  111. package/package.json +19 -9
  112. package/scripts/versionSource.cjs +65 -0
  113. package/src/lib/__tests__/versionSource.test.ts +64 -0
  114. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  115. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  116. package/src/lib/ai/llm-provider.ts +33 -0
  117. package/src/lib/ai/safety-identifier.ts +73 -0
  118. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  119. package/src/lib/auth/__tests__/server.test.ts +23 -0
  120. package/src/lib/auth/organizationScope.ts +65 -0
  121. package/src/lib/auth/server.ts +23 -2
  122. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  123. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  124. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  125. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  126. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  127. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  128. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  129. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  130. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  131. package/src/lib/bootstrap/dynamicLoader.ts +346 -96
  132. package/src/lib/bootstrap/factory.ts +21 -15
  133. package/src/lib/bootstrap/types.ts +6 -0
  134. package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
  135. package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
  136. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
  137. package/src/lib/commands/command-bus.ts +9 -3
  138. package/src/lib/commands/command-interceptor-runner.ts +27 -6
  139. package/src/lib/commands/command-interceptor.ts +8 -0
  140. package/src/lib/commands/errors.ts +67 -2
  141. package/src/lib/commands/index.ts +7 -1
  142. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  143. package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
  144. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  145. package/src/lib/crud/enricher-runner.ts +2 -11
  146. package/src/lib/crud/factory.ts +157 -22
  147. package/src/lib/crud/interceptor-runner.ts +2 -2
  148. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  149. package/src/lib/crud/types.ts +3 -0
  150. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  151. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  152. package/src/lib/data/consistency.ts +17 -0
  153. package/src/lib/data/engine.ts +50 -16
  154. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  155. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  156. package/src/lib/db/entityMetadata.ts +43 -0
  157. package/src/lib/db/pg-errors.ts +58 -0
  158. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  159. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  160. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  161. package/src/lib/di/container.ts +72 -8
  162. package/src/lib/email/__tests__/send.test.ts +37 -1
  163. package/src/lib/email/send.ts +79 -0
  164. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  165. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  166. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  167. package/src/lib/encryption/find.ts +22 -0
  168. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  169. package/src/lib/encryption/subscriber.ts +2 -14
  170. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  171. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  172. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  173. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  174. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  175. package/src/lib/i18n/config.ts +2 -2
  176. package/src/lib/i18n/dictionary-cache.ts +5 -0
  177. package/src/lib/i18n/server.ts +6 -2
  178. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  179. package/src/lib/logger/extension.ts +59 -0
  180. package/src/lib/logger/index.ts +59 -1
  181. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  182. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  183. package/src/lib/modules/registry.ts +46 -7
  184. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  185. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  186. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
  187. package/src/lib/query/__tests__/engine.test.ts +213 -13
  188. package/src/lib/query/ciphertext-search-warning.ts +106 -0
  189. package/src/lib/query/engine.ts +102 -61
  190. package/src/lib/query/types.ts +4 -5
  191. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  192. package/src/lib/redis/connection.ts +10 -7
  193. package/src/lib/search/__tests__/availability.test.ts +211 -0
  194. package/src/lib/search/__tests__/config.test.ts +177 -0
  195. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  196. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  197. package/src/lib/search/auto-indexing.ts +22 -0
  198. package/src/lib/search/availability.ts +232 -0
  199. package/src/lib/search/config.ts +106 -8
  200. package/src/lib/search/tokenLookup.ts +133 -0
  201. package/src/lib/search/tokenize.ts +33 -11
  202. package/src/lib/telemetry/runtime.ts +72 -0
  203. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  204. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  205. package/src/lib/webhooks/body.ts +70 -0
  206. package/src/lib/webhooks/inbound-types.ts +125 -0
  207. package/src/lib/webhooks/index.ts +19 -1
  208. package/src/lib/webhooks/verify.ts +17 -9
  209. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  210. package/src/modules/__tests__/registry.test.ts +10 -2
  211. package/src/modules/analytics.ts +7 -0
  212. package/src/modules/customer-auth.ts +1 -0
  213. package/src/modules/encryption.ts +3 -0
  214. package/src/modules/entities.ts +15 -0
  215. package/src/modules/navigation/backendChrome.ts +15 -0
  216. package/src/modules/overrides.ts +103 -0
  217. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  218. package/src/modules/payment_gateways/types.ts +41 -0
  219. package/src/modules/registry.ts +3 -0
  220. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  221. package/src/modules/widgets/extension-points.ts +524 -0
  222. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  223. package/src/security/enabledModulesRegistry.ts +64 -12
  224. 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'
@@ -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 && searchConfig.enabled && await this.tableExists('search_tokens')
301
- const hasSearchTokens = searchEnabled
302
- ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
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
- 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
- }
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`/`tableExists` check is memoized on
475
- // `this.columnCache`/`this.tableCache`, so the second pass hits no extra DB calls.
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
- const extTable = extName.endsWith('s') ? extName : `${extName}s`
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 = sub.where(sql<boolean>`${textExpr} = ${opts.value}`); break
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 = sub.where(sql<boolean>`${textExpr} <> ${opts.value}`); break
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':
@@ -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
+ })