@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
@@ -282,6 +282,73 @@ describe('BasicQueryEngine — null equality', () => {
282
282
  })
283
283
  })
284
284
 
285
+ describe('BasicQueryEngine — doc-field null equality (issue #4841)', () => {
286
+ function serializeNode(value: any): string {
287
+ return JSON.stringify(value, (_key, inner) =>
288
+ inner && typeof inner.toOperationNode === 'function' ? inner.toOperationNode() : inner,
289
+ )
290
+ }
291
+
292
+ // The doc-backed predicates land inside the entity_indexes EXISTS sub-builder;
293
+ // pick out the one that reads the JSONB doc (`doc ->> 'field'`).
294
+ function docPredicates(fakeDb: any, table: string): string[] {
295
+ const baseCall = fakeDb._calls.find((b: any) => b._ops.table === table)
296
+ expect(baseCall).toBeTruthy()
297
+ const existsEntries = (baseCall._ops.wheres as any[]).filter(
298
+ (where: any) => Array.isArray(where) && where[0] === 'exists',
299
+ )
300
+ expect(existsEntries.length).toBeGreaterThan(0)
301
+ return existsEntries
302
+ .flatMap((entry: any) => (entry[1]?._ops?.wheres ?? []) as any[])
303
+ .map((where: any) => serializeNode(where))
304
+ .filter((serialized: string) => serialized.includes('->>'))
305
+ }
306
+
307
+ async function runDocFilter(filters: Record<string, unknown>) {
308
+ const fakeDb = createFakeKysely({
309
+ scheduled_jobs: [],
310
+ // `ended_at` is deliberately absent, so the field resolves to the
311
+ // entity_indexes doc rather than to a base column.
312
+ 'information_schema.columns': [
313
+ { table_name: 'scheduled_jobs', column_name: 'id' },
314
+ ],
315
+ 'information_schema.tables': [{ table_name: 'entity_indexes' }],
316
+ })
317
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
318
+ await engine.query('scheduler:scheduled_job', {
319
+ tenantId: 't1',
320
+ fields: ['id'],
321
+ omitAutomaticTenantOrgScope: true,
322
+ filters,
323
+ })
324
+ return docPredicates(fakeDb, 'scheduled_jobs')
325
+ }
326
+
327
+ test('$eq null on a doc field compiles to "is null", never "= null"', async () => {
328
+ const predicates = await runDocFilter({ ended_at: { $eq: null } })
329
+
330
+ expect(predicates.length).toBeGreaterThan(0)
331
+ expect(predicates.some((sql: string) => sql.includes('is null'))).toBe(true)
332
+ // `(doc ->> 'ended_at') = NULL` is never TRUE in SQL, so it silently
333
+ // returns zero rows instead of matching unset values.
334
+ expect(predicates.some((sql: string) => / = /.test(sql))).toBe(false)
335
+ })
336
+
337
+ test('bare null on a doc field compiles to "is null"', async () => {
338
+ const predicates = await runDocFilter({ ended_at: null })
339
+
340
+ expect(predicates.some((sql: string) => sql.includes('is null'))).toBe(true)
341
+ expect(predicates.some((sql: string) => / = /.test(sql))).toBe(false)
342
+ })
343
+
344
+ test('$ne null on a doc field compiles to "is not null", never "<> null"', async () => {
345
+ const predicates = await runDocFilter({ started_at: { $ne: null } })
346
+
347
+ expect(predicates.some((sql: string) => sql.includes('is not null'))).toBe(true)
348
+ expect(predicates.some((sql: string) => sql.includes('<>'))).toBe(false)
349
+ })
350
+ })
351
+
285
352
  describe('BasicQueryEngine — omitAutomaticTenantOrgScope', () => {
286
353
  test('skips automatic tenant and organization guards when flag is set', async () => {
287
354
  const fakeDb = createFakeKysely({
@@ -1,10 +1,28 @@
1
1
  import { BasicQueryEngine } from '../engine'
2
2
  import { SortDir } from '../types'
3
3
  import { registerModules } from '../../i18n/server'
4
+ import { clearSearchTokenPresenceCache } from '../../search/availability'
5
+
6
+ // The token-presence answer is cached process-wide (TTL); without clearing it,
7
+ // probe-count assertions would observe hits from earlier tests in this file.
8
+ beforeEach(() => {
9
+ clearSearchTokenPresenceCache()
10
+ })
4
11
 
5
12
  // Mock modules with one entity extension
6
13
  const mockModules = [
7
14
  { id: 'auth', entityExtensions: [ { base: 'auth:user', extension: 'my_module:user_profile', join: { baseKey: 'id', extensionKey: 'user_id' } } ] },
15
+ {
16
+ id: 'example',
17
+ entityExtensions: [
18
+ // Declares `table` explicitly; the derived plural now agrees with it.
19
+ { base: 'customers:customer_entity', extension: 'example:example_customer_priority', join: { baseKey: 'id', extensionKey: 'customer_id' }, table: 'example_customer_priorities' },
20
+ // No `table`: exercises the derived-plural fallback.
21
+ { base: 'auth:role', extension: 'example:example_role_policy', join: { baseKey: 'id', extensionKey: 'role_id' } },
22
+ // `table` is not a bare identifier, so the engine must refuse it.
23
+ { base: 'auth:session', extension: 'example:example_session_note', join: { baseKey: 'id', extensionKey: 'session_id' }, table: 'notes"; drop table users --' },
24
+ ],
25
+ },
8
26
  ]
9
27
 
10
28
  // Register modules for the registration-based pattern
@@ -355,6 +373,8 @@ describe('BasicQueryEngine (Kysely)', () => {
355
373
  const fakeDb = createFakeKysely({
356
374
  customer_entities: [],
357
375
  customer_people: [],
376
+ search_tokens: [{ one: 1 }],
377
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
358
378
  'information_schema.columns': [
359
379
  { table_name: 'customer_entities', column_name: 'tenant_id' },
360
380
  { table_name: 'customer_people', column_name: 'id' },
@@ -362,8 +382,6 @@ describe('BasicQueryEngine (Kysely)', () => {
362
382
  ],
363
383
  })
364
384
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
365
- jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
366
- jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
367
385
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
368
386
 
369
387
  await engine.query('customers:customer_entity', {
@@ -409,7 +427,6 @@ describe('BasicQueryEngine (Kysely)', () => {
409
427
  ],
410
428
  })
411
429
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
412
- jest.spyOn(engine as any, 'tableExists').mockResolvedValue(false)
413
430
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
414
431
 
415
432
  await engine.query('customers:customer_entity', {
@@ -445,10 +462,10 @@ describe('BasicQueryEngine (Kysely)', () => {
445
462
  test('uses search tokens for index document fields on base entities', async () => {
446
463
  const fakeDb = createFakeKysely({
447
464
  todos: [],
465
+ search_tokens: [{ one: 1 }],
466
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
448
467
  })
449
468
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
450
- const tableExistsSpy = jest.spyOn(engine as any, 'tableExists').mockResolvedValue(true)
451
- const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
452
469
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
453
470
 
454
471
  await engine.query('example:todo', {
@@ -461,12 +478,15 @@ describe('BasicQueryEngine (Kysely)', () => {
461
478
  page: { page: 1, pageSize: 10 },
462
479
  })
463
480
 
464
- expect(tableExistsSpy).toHaveBeenCalledWith('search_tokens')
465
- expect(hasSearchTokensSpy).toHaveBeenCalledWith(
466
- 'example:todo',
467
- 't1',
468
- expect.objectContaining({ ids: ['org1'] }),
469
- )
481
+ const calls = fakeDb._calls as Array<{ _ops: { table: string; wheres: unknown[][] } }>
482
+ const tableProbe = calls.find((call) =>
483
+ call._ops.table === 'information_schema.tables' &&
484
+ call._ops.wheres.some((where) => where[0] === 'table_name' && where[2] === 'search_tokens'))
485
+ expect(tableProbe).toBeTruthy()
486
+ const tokenProbe = calls.find((call) =>
487
+ call._ops.table === 'search_tokens' &&
488
+ call._ops.wheres.some((where) => where[0] === 'entity_type' && where[2] === 'example:todo'))
489
+ expect(tokenProbe).toBeTruthy()
470
490
  expect(applySearchTokensSpy).toHaveBeenCalledWith(
471
491
  expect.anything(),
472
492
  expect.objectContaining({
@@ -488,7 +508,6 @@ describe('BasicQueryEngine (Kysely)', () => {
488
508
  ],
489
509
  })
490
510
  const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
491
- const hasSearchTokensSpy = jest.spyOn(engine as any, 'hasSearchTokens').mockResolvedValue(true)
492
511
  const applySearchTokensSpy = jest.spyOn(engine as any, 'applySearchTokens')
493
512
 
494
513
  await engine.query('example:todo', {
@@ -501,7 +520,8 @@ describe('BasicQueryEngine (Kysely)', () => {
501
520
  page: { page: 1, pageSize: 10 },
502
521
  })
503
522
 
504
- expect(hasSearchTokensSpy).not.toHaveBeenCalled()
523
+ const calls = fakeDb._calls as Array<{ _ops: { table: string } }>
524
+ expect(calls.some((call) => call._ops.table === 'search_tokens')).toBe(false)
505
525
  expect(applySearchTokensSpy).not.toHaveBeenCalled()
506
526
  const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === 'todos')
507
527
  expect(baseCall?._ops.wheres).toContainEqual(['todos.search_text', 'ilike', '%avision%'])
@@ -933,4 +953,184 @@ describe('BasicQueryEngine (Kysely)', () => {
933
953
  expect(baseCall._ops.limits).toBe(10)
934
954
  expect(baseCall._ops.offsets).toBe(10)
935
955
  })
956
+
957
+ // A tiebreak sort is only worth configuring if the engine actually emits it.
958
+ // `list.tiebreakSortField` (used by the sales line routes to keep lines with an
959
+ // equal `line_number` in a repeatable order) is the first caller to pass more
960
+ // than one sort element, so pin that every element reaches ORDER BY in order —
961
+ // dropping sort[1] would silently restore the non-determinism it exists to fix.
962
+ test('emits every sort element as an ORDER BY column, in order', async () => {
963
+ const fakeDb = createFakeKysely({
964
+ sales_order_lines: [
965
+ { id: 'b', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
966
+ { id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
967
+ ],
968
+ 'information_schema.columns': [
969
+ { table_name: 'sales_order_lines', column_name: 'id' },
970
+ { table_name: 'sales_order_lines', column_name: 'tenant_id' },
971
+ { table_name: 'sales_order_lines', column_name: 'organization_id' },
972
+ { table_name: 'sales_order_lines', column_name: 'deleted_at' },
973
+ { table_name: 'sales_order_lines', column_name: 'line_number' },
974
+ ],
975
+ })
976
+ const engine = new BasicQueryEngine(
977
+ {} as any,
978
+ () => fakeDb as any,
979
+ () => ({
980
+ isEnabled: () => true,
981
+ getEncryptedFieldNames: async () => [],
982
+ }),
983
+ )
984
+
985
+ await engine.query('sales:sales_order_line', {
986
+ tenantId: 't1',
987
+ organizationId: 'org1',
988
+ fields: ['id', 'line_number'],
989
+ sort: [
990
+ { field: 'line_number', dir: SortDir.Asc },
991
+ { field: 'id', dir: SortDir.Asc },
992
+ ],
993
+ page: { page: 1, pageSize: 10 },
994
+ })
995
+
996
+ const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
997
+ expect(baseCall._ops.orderBys).toEqual([
998
+ ['sales_order_lines.line_number', 'asc'],
999
+ ['sales_order_lines.id', 'asc'],
1000
+ ])
1001
+ })
1002
+
1003
+ test('keeps each sort element on its own direction', async () => {
1004
+ const fakeDb = createFakeKysely({
1005
+ sales_order_lines: [
1006
+ { id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 1 },
1007
+ ],
1008
+ 'information_schema.columns': [
1009
+ { table_name: 'sales_order_lines', column_name: 'id' },
1010
+ { table_name: 'sales_order_lines', column_name: 'tenant_id' },
1011
+ { table_name: 'sales_order_lines', column_name: 'organization_id' },
1012
+ { table_name: 'sales_order_lines', column_name: 'deleted_at' },
1013
+ { table_name: 'sales_order_lines', column_name: 'line_number' },
1014
+ ],
1015
+ })
1016
+ const engine = new BasicQueryEngine(
1017
+ {} as any,
1018
+ () => fakeDb as any,
1019
+ () => ({
1020
+ isEnabled: () => true,
1021
+ getEncryptedFieldNames: async () => [],
1022
+ }),
1023
+ )
1024
+
1025
+ await engine.query('sales:sales_order_line', {
1026
+ tenantId: 't1',
1027
+ organizationId: 'org1',
1028
+ fields: ['id', 'line_number'],
1029
+ sort: [
1030
+ { field: 'line_number', dir: SortDir.Desc },
1031
+ { field: 'id', dir: SortDir.Asc },
1032
+ ],
1033
+ page: { page: 1, pageSize: 10 },
1034
+ })
1035
+
1036
+ const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
1037
+ expect(baseCall._ops.orderBys).toEqual([
1038
+ ['sales_order_lines.line_number', 'desc'],
1039
+ ['sales_order_lines.id', 'asc'],
1040
+ ])
1041
+ })
1042
+
1043
+ describe('search_tokens coverage probe (#4723 parity)', () => {
1044
+ type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
1045
+
1046
+ const countProbes = (fakeDb: ProbeDbLog): number =>
1047
+ fakeDb._calls.filter((call) => call._ops.table === 'search_tokens').length
1048
+
1049
+ const buildEngine = (fakeDb: unknown): BasicQueryEngine => new BasicQueryEngine(
1050
+ {} as ConstructorParameters<typeof BasicQueryEngine>[0],
1051
+ (() => fakeDb) as unknown as NonNullable<ConstructorParameters<typeof BasicQueryEngine>[1]>,
1052
+ )
1053
+
1054
+ const buildDb = () => createFakeKysely({
1055
+ users: [],
1056
+ 'information_schema.tables': [{ table_name: 'search_tokens' }],
1057
+ })
1058
+
1059
+ test('is skipped when the query carries no like/ilike filter', async () => {
1060
+ const fakeDb = buildDb()
1061
+ const engine = buildEngine(fakeDb)
1062
+
1063
+ await engine.query('auth:user', {
1064
+ tenantId: 't1',
1065
+ organizationId: 'org1',
1066
+ filters: { is_active: { $eq: true } },
1067
+ })
1068
+
1069
+ expect(countProbes(fakeDb)).toBe(0)
1070
+ })
1071
+
1072
+ test('still runs when the query actually searches', async () => {
1073
+ const fakeDb = buildDb()
1074
+ const engine = buildEngine(fakeDb)
1075
+
1076
+ await engine.query('auth:user', {
1077
+ tenantId: 't1',
1078
+ organizationId: 'org1',
1079
+ filters: { email: { $ilike: '%abc%' } },
1080
+ })
1081
+
1082
+ expect(countProbes(fakeDb)).toBeGreaterThan(0)
1083
+ })
1084
+ })
1085
+ })
1086
+
1087
+ describe('BasicQueryEngine entity-extension joins', () => {
1088
+ function extensionJoins(fakeDb: any, baseTable: string): any[] {
1089
+ const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === baseTable)
1090
+ expect(baseCall).toBeTruthy()
1091
+ return baseCall._ops.joins.filter((entry: any) => Object.keys(entry.aliasObj)[0].startsWith('ext_'))
1092
+ }
1093
+
1094
+ async function joinFor(entity: string, baseTable: string): Promise<any> {
1095
+ const fakeDb = createFakeKysely()
1096
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
1097
+ await engine.query(entity, {
1098
+ tenantId: 't1',
1099
+ organizationId: 'org1',
1100
+ fields: ['id'],
1101
+ includeExtensions: true,
1102
+ })
1103
+ const joins = extensionJoins(fakeDb, baseTable)
1104
+ expect(joins).toHaveLength(1)
1105
+ return joins[0]
1106
+ }
1107
+
1108
+ test('prefers a declared table over the derived plural', async () => {
1109
+ const join = await joinFor('customers:customer_entity', 'customer_entities')
1110
+ expect(join.aliasObj).toEqual({ ext_example_customer_priority: 'example_customer_priorities' })
1111
+ expect(join.conditions).toEqual([
1112
+ {
1113
+ method: 'on',
1114
+ args: ['ext_example_customer_priority.customer_id', '=', 'customer_entities.id'],
1115
+ },
1116
+ ])
1117
+ })
1118
+
1119
+ test('falls back to the derived plural when no table is declared', async () => {
1120
+ // `policy` ends in `y`, so the correct plural is `policies`. This previously asserted
1121
+ // `policys`, pinning a separate inline `+s` pluralizer that the extension-join path used
1122
+ // instead of the file's own `pluralizeBaseName` — the very bug that made
1123
+ // `example_customer_priority` derive `example_customer_prioritys` and forced the
1124
+ // `table` override into existence.
1125
+ const join = await joinFor('auth:role', 'roles')
1126
+ expect(join.aliasObj).toEqual({ ext_example_role_policy: 'example_role_policies' })
1127
+ expect(join.conditions).toEqual([
1128
+ { method: 'on', args: ['ext_example_role_policy.role_id', '=', 'roles.id'] },
1129
+ ])
1130
+ })
1131
+
1132
+ test('ignores a declared table that is not a bare identifier', async () => {
1133
+ const join = await joinFor('auth:session', 'sessions')
1134
+ expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
1135
+ })
936
1136
  })
@@ -0,0 +1,106 @@
1
+ import { createLogger } from '../logger'
2
+ import type { EntityId } from '../../modules/entities'
3
+
4
+ const logger = createLogger('shared').child({ component: 'query' })
5
+
6
+ /**
7
+ * Minimal slice of `TenantDataEncryptionService` needed to decide whether a
8
+ * text filter targets an encrypted column.
9
+ */
10
+ export type CiphertextWarningEncryptionService = {
11
+ getEncryptedFieldNames?: (
12
+ entityId: EntityId,
13
+ tenantId?: string | null,
14
+ organizationId?: string | null,
15
+ ) => Promise<readonly string[]>
16
+ isEnabled?: () => boolean
17
+ } | null | undefined
18
+
19
+ export type CiphertextLikeFallbackReason =
20
+ | 'no-indexable-tokens'
21
+ | 'no-search-tokens'
22
+ | 'search-disabled'
23
+ | 'raw-orm-filter'
24
+
25
+ const REASON_HINTS: Record<CiphertextLikeFallbackReason, string> = {
26
+ 'search-disabled':
27
+ 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.',
28
+ 'no-indexable-tokens':
29
+ 'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.',
30
+ 'no-search-tokens':
31
+ 'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',
32
+ 'raw-orm-filter':
33
+ 'This filter bypasses the query engine, so it runs as ILIKE against ciphertext and matches nothing. Resolve ids through findEntityIdsBySearchTokens (@open-mercato/shared/lib/search/tokenLookup), filter on the deterministic hash column, or compare in memory after decryption.',
34
+ }
35
+
36
+ // One warning per entity/tenant/field per process — the fallback repeats on
37
+ // every request, and an operator only needs to learn about it once.
38
+ const WARN_CACHE_CAP = 5000
39
+ const warned = new Set<string>()
40
+
41
+ const warnKey = (entity: string, tenantId: string | null, field: string): string =>
42
+ `${entity}|${tenantId ?? 'null'}|${field}`
43
+
44
+ export const normalizeColumnName = (value: string): string =>
45
+ value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
46
+
47
+ /** Test seam: the warn-once cache is process-global by design. */
48
+ export function resetCiphertextLikeWarnCache(): void {
49
+ warned.clear()
50
+ }
51
+
52
+ /**
53
+ * Warn when a `like`/`ilike` filter is about to run against a column that an
54
+ * encryption map covers while the token index is unavailable.
55
+ *
56
+ * Without this the query degrades silently: the predicate compares a plaintext
57
+ * pattern against ciphertext, matches nothing, and the endpoint returns an
58
+ * empty page that is indistinguishable from a genuine no-result. Issue #2990.
59
+ *
60
+ * Never throws and never blocks the query — a failure here is logged at debug
61
+ * level and the caller proceeds unchanged.
62
+ */
63
+ export async function warnOnCiphertextLikeFallback(params: {
64
+ entity: string
65
+ fields: readonly string[]
66
+ tenantId: string | null
67
+ reason: CiphertextLikeFallbackReason
68
+ service: CiphertextWarningEncryptionService
69
+ }): Promise<void> {
70
+ const { entity, tenantId, reason, service } = params
71
+ if (!service || service.isEnabled?.() === false) return
72
+ if (typeof service.getEncryptedFieldNames !== 'function') return
73
+
74
+ const candidates = params.fields
75
+ .filter((field) => typeof field === 'string' && !field.startsWith('cf:'))
76
+ .map((field) => normalizeColumnName(field))
77
+ .filter((field, index, all) => field.length > 0 && all.indexOf(field) === index)
78
+ .filter((field) => !warned.has(warnKey(entity, tenantId, field)))
79
+ if (!candidates.length) return
80
+
81
+ try {
82
+ // `organizationId: null` resolves the global map plus every per-organization
83
+ // override, so a field encrypted for any organization is reported.
84
+ const encrypted = new Set(
85
+ (await service.getEncryptedFieldNames(entity, tenantId, null)).map((field) =>
86
+ normalizeColumnName(String(field)),
87
+ ),
88
+ )
89
+ for (const field of candidates) {
90
+ if (!encrypted.has(field)) continue
91
+ if (warned.size >= WARN_CACHE_CAP) warned.clear()
92
+ warned.add(warnKey(entity, tenantId, field))
93
+ logger.warn('Text search filter cannot match an encrypted column', {
94
+ entity,
95
+ field,
96
+ reason,
97
+ hint: REASON_HINTS[reason],
98
+ })
99
+ }
100
+ } catch (err) {
101
+ logger.debug('Ciphertext search warning check failed', {
102
+ entity,
103
+ err: err instanceof Error ? err.message : String(err),
104
+ })
105
+ }
106
+ }