@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
@@ -1,12 +1,53 @@
1
1
  import { selectTransport } from "./transport.js";
2
- import { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from "./level.js";
2
+ import { getLoggerExtension } from "./extension.js";
3
+ import { isLevelEnabled } from "./level.js";
4
+ import { getLogLevel, isLevelEnabled as isLevelEnabled2, resetLogLevelCache, OM_LOG_LEVEL_ENV } from "./level.js";
3
5
  import { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from "./transport.server.js";
4
6
  import { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from "./transport.pretty.js";
7
+ import {
8
+ getLoggerExtension as getLoggerExtension2,
9
+ registerLoggerExtension,
10
+ resetLoggerExtension
11
+ } from "./extension.js";
5
12
  const loggerRegistry = /* @__PURE__ */ new Map();
13
+ function createExtendedLogger(namespace, transport, bindings = {}) {
14
+ const emit = (level, msg, fields) => {
15
+ if (!isLevelEnabled(level)) return;
16
+ const extension = getLoggerExtension();
17
+ let context = {};
18
+ try {
19
+ context = extension?.enrich?.() ?? {};
20
+ } catch {
21
+ }
22
+ const mergedFields = { ...fields, ...context };
23
+ transport[level](msg, mergedFields);
24
+ try {
25
+ extension?.emit?.({
26
+ level,
27
+ namespace,
28
+ message: msg,
29
+ fields: { ...bindings, ...mergedFields },
30
+ time: Date.now()
31
+ });
32
+ } catch {
33
+ }
34
+ };
35
+ return {
36
+ debug: (msg, fields) => emit("debug", msg, fields),
37
+ info: (msg, fields) => emit("info", msg, fields),
38
+ warn: (msg, fields) => emit("warn", msg, fields),
39
+ error: (msg, fields) => emit("error", msg, fields),
40
+ child: (childBindings) => createExtendedLogger(
41
+ namespace,
42
+ transport.child(childBindings),
43
+ { ...bindings, ...childBindings }
44
+ )
45
+ };
46
+ }
6
47
  function createLogger(namespace) {
7
48
  const existing = loggerRegistry.get(namespace);
8
49
  if (existing) return existing;
9
- const created = selectTransport(namespace);
50
+ const created = createExtendedLogger(namespace, selectTransport(namespace));
10
51
  loggerRegistry.set(namespace, created);
11
52
  return created;
12
53
  }
@@ -19,9 +60,12 @@ export {
19
60
  OM_LOG_PRETTY_ENV,
20
61
  createLogger,
21
62
  getLogLevel,
22
- isLevelEnabled,
63
+ getLoggerExtension2 as getLoggerExtension,
64
+ isLevelEnabled2 as isLevelEnabled,
65
+ registerLoggerExtension,
23
66
  resetLogLevelCache,
24
67
  resetLogPrettyCache,
68
+ resetLoggerExtension,
25
69
  resetLoggerRegistry,
26
70
  resetServerLoggerCache
27
71
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/logger/index.ts"],
4
- "sourcesContent": ["import { selectTransport } from './transport'\n\nexport type { LogLevel } from './level'\nexport { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'\nexport { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'\nexport { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'\n\nexport type LogBindings = Record<string, unknown>\n\nexport interface Logger {\n debug(msg: string, fields?: LogBindings): void\n info(msg: string, fields?: LogBindings): void\n warn(msg: string, fields?: LogBindings): void\n error(msg: string, fields?: LogBindings): void\n /** Returns a logger with `bindings` merged into every subsequent line. */\n child(bindings: LogBindings): Logger\n}\n\nconst loggerRegistry = new Map<string, Logger>()\n\n/** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */\nexport function createLogger(namespace: string): Logger {\n const existing = loggerRegistry.get(namespace)\n if (existing) return existing\n const created = selectTransport(namespace)\n loggerRegistry.set(namespace, created)\n return created\n}\n\n/** Internal: clear cached loggers so tests can re-run transport selection. */\nexport function resetLoggerRegistry(): void {\n loggerRegistry.clear()\n}\n"],
5
- "mappings": "AAAA,SAAS,uBAAuB;AAGhC,SAAS,aAAa,gBAAgB,oBAAoB,wBAAwB;AAClF,SAAS,wBAAwB,8BAA8B;AAC/D,SAAS,qBAAqB,yBAAyB;AAavD,MAAM,iBAAiB,oBAAI,IAAoB;AAGxC,SAAS,aAAa,WAA2B;AACtD,QAAM,WAAW,eAAe,IAAI,SAAS;AAC7C,MAAI,SAAU,QAAO;AACrB,QAAM,UAAU,gBAAgB,SAAS;AACzC,iBAAe,IAAI,WAAW,OAAO;AACrC,SAAO;AACT;AAGO,SAAS,sBAA4B;AAC1C,iBAAe,MAAM;AACvB;",
6
- "names": []
4
+ "sourcesContent": ["import { selectTransport } from './transport'\nimport { getLoggerExtension } from './extension'\nimport { isLevelEnabled, type LogLevel } from './level'\n\nexport type { LogLevel } from './level'\nexport { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'\nexport { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'\nexport { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'\nexport {\n getLoggerExtension,\n registerLoggerExtension,\n resetLoggerExtension,\n} from './extension'\nexport type { LoggerExtension, LoggerExtensionRecord } from './extension'\n\nexport type LogBindings = Record<string, unknown>\n\nexport interface Logger {\n debug(msg: string, fields?: LogBindings): void\n info(msg: string, fields?: LogBindings): void\n warn(msg: string, fields?: LogBindings): void\n error(msg: string, fields?: LogBindings): void\n /** Returns a logger with `bindings` merged into every subsequent line. */\n child(bindings: LogBindings): Logger\n}\n\nconst loggerRegistry = new Map<string, Logger>()\n\nfunction createExtendedLogger(\n namespace: string,\n transport: Logger,\n bindings: LogBindings = {},\n): Logger {\n const emit = (\n level: LogLevel,\n msg: string,\n fields?: LogBindings,\n ): void => {\n if (!isLevelEnabled(level)) return\n\n const extension = getLoggerExtension()\n let context: LogBindings = {}\n try {\n context = extension?.enrich?.() ?? {}\n } catch {\n // Observability must never alter application behavior.\n }\n\n const mergedFields = { ...fields, ...context }\n transport[level](msg, mergedFields)\n\n try {\n extension?.emit?.({\n level,\n namespace,\n message: msg,\n fields: { ...bindings, ...mergedFields },\n time: Date.now(),\n })\n } catch {\n // Remote logging is best-effort; the local line has already been written.\n }\n }\n\n return {\n debug: (msg, fields) => emit('debug', msg, fields),\n info: (msg, fields) => emit('info', msg, fields),\n warn: (msg, fields) => emit('warn', msg, fields),\n error: (msg, fields) => emit('error', msg, fields),\n child: (childBindings) =>\n createExtendedLogger(\n namespace,\n transport.child(childBindings),\n { ...bindings, ...childBindings },\n ),\n }\n}\n\n/** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */\nexport function createLogger(namespace: string): Logger {\n const existing = loggerRegistry.get(namespace)\n if (existing) return existing\n const created = createExtendedLogger(namespace, selectTransport(namespace))\n loggerRegistry.set(namespace, created)\n return created\n}\n\n/** Internal: clear cached loggers so tests can re-run transport selection. */\nexport function resetLoggerRegistry(): void {\n loggerRegistry.clear()\n}\n"],
5
+ "mappings": "AAAA,SAAS,uBAAuB;AAChC,SAAS,0BAA0B;AACnC,SAAS,sBAAqC;AAG9C,SAAS,aAAa,kBAAAA,iBAAgB,oBAAoB,wBAAwB;AAClF,SAAS,wBAAwB,8BAA8B;AAC/D,SAAS,qBAAqB,yBAAyB;AACvD;AAAA,EACE,sBAAAC;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAcP,MAAM,iBAAiB,oBAAI,IAAoB;AAE/C,SAAS,qBACP,WACA,WACA,WAAwB,CAAC,GACjB;AACR,QAAM,OAAO,CACX,OACA,KACA,WACS;AACT,QAAI,CAAC,eAAe,KAAK,EAAG;AAE5B,UAAM,YAAY,mBAAmB;AACrC,QAAI,UAAuB,CAAC;AAC5B,QAAI;AACF,gBAAU,WAAW,SAAS,KAAK,CAAC;AAAA,IACtC,QAAQ;AAAA,IAER;AAEA,UAAM,eAAe,EAAE,GAAG,QAAQ,GAAG,QAAQ;AAC7C,cAAU,KAAK,EAAE,KAAK,YAAY;AAElC,QAAI;AACF,iBAAW,OAAO;AAAA,QAChB;AAAA,QACA;AAAA,QACA,SAAS;AAAA,QACT,QAAQ,EAAE,GAAG,UAAU,GAAG,aAAa;AAAA,QACvC,MAAM,KAAK,IAAI;AAAA,MACjB,CAAC;AAAA,IACH,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO;AAAA,IACL,OAAO,CAAC,KAAK,WAAW,KAAK,SAAS,KAAK,MAAM;AAAA,IACjD,MAAM,CAAC,KAAK,WAAW,KAAK,QAAQ,KAAK,MAAM;AAAA,IAC/C,MAAM,CAAC,KAAK,WAAW,KAAK,QAAQ,KAAK,MAAM;AAAA,IAC/C,OAAO,CAAC,KAAK,WAAW,KAAK,SAAS,KAAK,MAAM;AAAA,IACjD,OAAO,CAAC,kBACN;AAAA,MACE;AAAA,MACA,UAAU,MAAM,aAAa;AAAA,MAC7B,EAAE,GAAG,UAAU,GAAG,cAAc;AAAA,IAClC;AAAA,EACJ;AACF;AAGO,SAAS,aAAa,WAA2B;AACtD,QAAM,WAAW,eAAe,IAAI,SAAS;AAC7C,MAAI,SAAU,QAAO;AACrB,QAAM,UAAU,qBAAqB,WAAW,gBAAgB,SAAS,CAAC;AAC1E,iBAAe,IAAI,WAAW,OAAO;AACrC,SAAO;AACT;AAGO,SAAS,sBAA4B;AAC1C,iBAAe,MAAM;AACvB;",
6
+ "names": ["isLevelEnabled", "getLoggerExtension"]
7
7
  }
@@ -1,5 +1,5 @@
1
1
  import { applyModuleOverridesToModules } from "@open-mercato/shared/modules/overrides";
2
- import { invalidateDictionaryCache } from "../i18n/dictionary-cache.js";
2
+ import { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from "../i18n/dictionary-cache.js";
3
3
  import { createLogger } from "../logger/index.js";
4
4
  const logger = createLogger("shared").child({ component: "modules-registry" });
5
5
  const GLOBAL_KEY = "__openMercatoModulesRegistry__";
@@ -24,7 +24,10 @@ function mergeI18nModules(existing, incoming) {
24
24
  if (!i18nModule?.translations) return entry;
25
25
  return {
26
26
  ...entry,
27
- translations: i18nModule.translations
27
+ translations: {
28
+ ...entry.translations ?? {},
29
+ ...i18nModule.translations
30
+ }
28
31
  };
29
32
  });
30
33
  for (const entry of incoming) {
@@ -32,15 +35,38 @@ function mergeI18nModules(existing, incoming) {
32
35
  }
33
36
  return merged;
34
37
  }
38
+ function getTranslationLocales(modules) {
39
+ const locales = /* @__PURE__ */ new Set();
40
+ for (const entry of modules) {
41
+ for (const locale of Object.keys(entry.translations ?? {})) {
42
+ locales.add(locale);
43
+ }
44
+ }
45
+ return [...locales];
46
+ }
47
+ function preserveExistingTranslations(existing, incoming) {
48
+ const existingById = new Map(existing.map((entry) => [entry.id, entry]));
49
+ return incoming.map((entry) => {
50
+ if (entry.translations) return entry;
51
+ const translations = existingById.get(entry.id)?.translations;
52
+ return translations ? { ...entry, translations } : entry;
53
+ });
54
+ }
35
55
  function registerModules(modules) {
36
56
  const existing = getGlobalModules();
37
57
  if (existing !== null && process.env.NODE_ENV === "development") {
38
58
  logger.debug("Modules re-registered (this may occur during HMR)");
39
59
  }
40
60
  const nextModules = applyModuleOverridesToModules(modules);
41
- const shouldMergeI18nOnly = existing !== null && existing.some(hasRuntimeContracts) && isI18nOnlyRegistration(nextModules);
42
- setGlobalModules(shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : nextModules);
43
- invalidateDictionaryCache();
61
+ const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules);
62
+ const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration;
63
+ const registeredModules = shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : preserveExistingTranslations(existing ?? [], nextModules);
64
+ setGlobalModules(registeredModules);
65
+ if (i18nOnlyRegistration) {
66
+ invalidateDictionaryCacheLocales(getTranslationLocales(nextModules));
67
+ } else {
68
+ invalidateDictionaryCache();
69
+ }
44
70
  }
45
71
  function getModules() {
46
72
  const modules = getGlobalModules();
@@ -49,8 +75,12 @@ function getModules() {
49
75
  }
50
76
  return modules;
51
77
  }
78
+ function tryGetModules() {
79
+ return getGlobalModules();
80
+ }
52
81
  export {
53
82
  getModules,
54
- registerModules
83
+ registerModules,
84
+ tryGetModules
55
85
  };
56
86
  //# sourceMappingURL=registry.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/modules/registry.ts"],
4
- "sourcesContent": ["import type { Module } from '@open-mercato/shared/modules/registry'\nimport { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'\nimport { invalidateDictionaryCache } from '../i18n/dictionary-cache'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'modules-registry' })\n\n// Registration pattern for publishable packages.\n// Use globalThis to survive tsx/esbuild module duplication where the same\n// registry.ts file can be loaded as multiple module instances when mixing\n// dynamic and static imports \u2014 for example a standalone integration test\n// bootstraps via the source path while a worker handler resolves it through\n// node_modules/@open-mercato/shared/dist/. Mirrors the same workaround used\n// by `getDiRegistrars()` in `../di/container.ts`.\nconst GLOBAL_KEY = '__openMercatoModulesRegistry__'\n\nfunction getGlobalModules(): Module[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalModules(modules: Module[]): void {\n ;(globalThis as any)[GLOBAL_KEY] = modules\n}\n\nfunction hasRuntimeContracts(entry: Module): boolean {\n return Object.keys(entry).some((key) => key !== 'id' && key !== 'translations')\n}\n\nfunction isI18nOnlyRegistration(modules: Module[]): boolean {\n return modules.length > 0 && modules.every((entry) => !hasRuntimeContracts(entry))\n}\n\nfunction mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {\n const incomingById = new Map(incoming.map((entry) => [entry.id, entry]))\n const existingIds = new Set(existing.map((entry) => entry.id))\n const merged = existing.map((entry) => {\n const i18nModule = incomingById.get(entry.id)\n if (!i18nModule?.translations) return entry\n return {\n ...entry,\n translations: i18nModule.translations,\n }\n })\n\n for (const entry of incoming) {\n if (!existingIds.has(entry.id)) merged.push(entry)\n }\n\n return merged\n}\n\nexport function registerModules(modules: Module[]) {\n const existing = getGlobalModules()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Modules re-registered (this may occur during HMR)')\n }\n const nextModules = applyModuleOverridesToModules(modules)\n const shouldMergeI18nOnly = existing !== null\n && existing.some(hasRuntimeContracts)\n && isI18nOnlyRegistration(nextModules)\n setGlobalModules(shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : nextModules)\n invalidateDictionaryCache()\n}\n\nexport function getModules(): Module[] {\n const modules = getGlobalModules()\n if (!modules) {\n throw new Error('[Bootstrap] Modules not registered. Call registerModules() at bootstrap.')\n }\n return modules\n}\n"],
5
- "mappings": "AACA,SAAS,qCAAqC;AAC9C,SAAS,iCAAiC;AAC1C,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAS7E,MAAM,aAAa;AAEnB,SAAS,mBAAoC;AAC3C,SAAQ,WAAmB,UAAU,KAAK;AAC5C;AAEA,SAAS,iBAAiB,SAAyB;AACjD;AAAC,EAAC,WAAmB,UAAU,IAAI;AACrC;AAEA,SAAS,oBAAoB,OAAwB;AACnD,SAAO,OAAO,KAAK,KAAK,EAAE,KAAK,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,cAAc;AAChF;AAEA,SAAS,uBAAuB,SAA4B;AAC1D,SAAO,QAAQ,SAAS,KAAK,QAAQ,MAAM,CAAC,UAAU,CAAC,oBAAoB,KAAK,CAAC;AACnF;AAEA,SAAS,iBAAiB,UAAoB,UAA8B;AAC1E,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;AACvE,QAAM,cAAc,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,MAAM,EAAE,CAAC;AAC7D,QAAM,SAAS,SAAS,IAAI,CAAC,UAAU;AACrC,UAAM,aAAa,aAAa,IAAI,MAAM,EAAE;AAC5C,QAAI,CAAC,YAAY,aAAc,QAAO;AACtC,WAAO;AAAA,MACL,GAAG;AAAA,MACH,cAAc,WAAW;AAAA,IAC3B;AAAA,EACF,CAAC;AAED,aAAW,SAAS,UAAU;AAC5B,QAAI,CAAC,YAAY,IAAI,MAAM,EAAE,EAAG,QAAO,KAAK,KAAK;AAAA,EACnD;AAEA,SAAO;AACT;AAEO,SAAS,gBAAgB,SAAmB;AACjD,QAAM,WAAW,iBAAiB;AAClC,MAAI,aAAa,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC/D,WAAO,MAAM,mDAAmD;AAAA,EAClE;AACA,QAAM,cAAc,8BAA8B,OAAO;AACzD,QAAM,sBAAsB,aAAa,QACpC,SAAS,KAAK,mBAAmB,KACjC,uBAAuB,WAAW;AACvC,mBAAiB,sBAAsB,iBAAiB,UAAU,WAAW,IAAI,WAAW;AAC5F,4BAA0B;AAC5B;AAEO,SAAS,aAAuB;AACrC,QAAM,UAAU,iBAAiB;AACjC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0EAA0E;AAAA,EAC5F;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["import type { Module } from '@open-mercato/shared/modules/registry'\nimport { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'\nimport type { Locale } from '../i18n/config'\nimport { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from '../i18n/dictionary-cache'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'modules-registry' })\n\n// Registration pattern for publishable packages.\n// Use globalThis to survive tsx/esbuild module duplication where the same\n// registry.ts file can be loaded as multiple module instances when mixing\n// dynamic and static imports \u2014 for example a standalone integration test\n// bootstraps via the source path while a worker handler resolves it through\n// node_modules/@open-mercato/shared/dist/. Mirrors the same workaround used\n// by `getDiRegistrars()` in `../di/container.ts`.\nconst GLOBAL_KEY = '__openMercatoModulesRegistry__'\n\nfunction getGlobalModules(): Module[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalModules(modules: Module[]): void {\n ;(globalThis as any)[GLOBAL_KEY] = modules\n}\n\nfunction hasRuntimeContracts(entry: Module): boolean {\n return Object.keys(entry).some((key) => key !== 'id' && key !== 'translations')\n}\n\nfunction isI18nOnlyRegistration(modules: Module[]): boolean {\n return modules.length > 0 && modules.every((entry) => !hasRuntimeContracts(entry))\n}\n\nfunction mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {\n const incomingById = new Map(incoming.map((entry) => [entry.id, entry]))\n const existingIds = new Set(existing.map((entry) => entry.id))\n const merged = existing.map((entry) => {\n const i18nModule = incomingById.get(entry.id)\n if (!i18nModule?.translations) return entry\n return {\n ...entry,\n translations: {\n ...(entry.translations ?? {}),\n ...i18nModule.translations,\n },\n }\n })\n\n for (const entry of incoming) {\n if (!existingIds.has(entry.id)) merged.push(entry)\n }\n\n return merged\n}\n\nfunction getTranslationLocales(modules: Module[]): Locale[] {\n const locales = new Set<Locale>()\n for (const entry of modules) {\n for (const locale of Object.keys(entry.translations ?? {})) {\n locales.add(locale as Locale)\n }\n }\n return [...locales]\n}\n\nfunction preserveExistingTranslations(existing: Module[], incoming: Module[]): Module[] {\n const existingById = new Map(existing.map((entry) => [entry.id, entry]))\n return incoming.map((entry) => {\n if (entry.translations) return entry\n const translations = existingById.get(entry.id)?.translations\n return translations ? { ...entry, translations } : entry\n })\n}\n\nexport function registerModules(modules: Module[]) {\n const existing = getGlobalModules()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Modules re-registered (this may occur during HMR)')\n }\n const nextModules = applyModuleOverridesToModules(modules)\n const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules)\n const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration\n const registeredModules = shouldMergeI18nOnly\n ? mergeI18nModules(existing, nextModules)\n : preserveExistingTranslations(existing ?? [], nextModules)\n setGlobalModules(registeredModules)\n if (i18nOnlyRegistration) {\n invalidateDictionaryCacheLocales(getTranslationLocales(nextModules))\n } else {\n invalidateDictionaryCache()\n }\n}\n\nexport function getModules(): Module[] {\n const modules = getGlobalModules()\n if (!modules) {\n throw new Error('[Bootstrap] Modules not registered. Call registerModules() at bootstrap.')\n }\n return modules\n}\n\n/**\n * Non-throwing counterpart of `getModules()` for call sites that have a\n * meaningful degraded behavior when bootstrap has not run \u2014 route unit tests\n * exercise a single handler without `registerModules()`, and a hard throw there\n * turns an unrelated assertion into a bootstrap error.\n */\nexport function tryGetModules(): Module[] | null {\n return getGlobalModules()\n}\n"],
5
+ "mappings": "AACA,SAAS,qCAAqC;AAE9C,SAAS,2BAA2B,wCAAwC;AAC5E,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAS7E,MAAM,aAAa;AAEnB,SAAS,mBAAoC;AAC3C,SAAQ,WAAmB,UAAU,KAAK;AAC5C;AAEA,SAAS,iBAAiB,SAAyB;AACjD;AAAC,EAAC,WAAmB,UAAU,IAAI;AACrC;AAEA,SAAS,oBAAoB,OAAwB;AACnD,SAAO,OAAO,KAAK,KAAK,EAAE,KAAK,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,cAAc;AAChF;AAEA,SAAS,uBAAuB,SAA4B;AAC1D,SAAO,QAAQ,SAAS,KAAK,QAAQ,MAAM,CAAC,UAAU,CAAC,oBAAoB,KAAK,CAAC;AACnF;AAEA,SAAS,iBAAiB,UAAoB,UAA8B;AAC1E,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;AACvE,QAAM,cAAc,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,MAAM,EAAE,CAAC;AAC7D,QAAM,SAAS,SAAS,IAAI,CAAC,UAAU;AACrC,UAAM,aAAa,aAAa,IAAI,MAAM,EAAE;AAC5C,QAAI,CAAC,YAAY,aAAc,QAAO;AACtC,WAAO;AAAA,MACL,GAAG;AAAA,MACH,cAAc;AAAA,QACZ,GAAI,MAAM,gBAAgB,CAAC;AAAA,QAC3B,GAAG,WAAW;AAAA,MAChB;AAAA,IACF;AAAA,EACF,CAAC;AAED,aAAW,SAAS,UAAU;AAC5B,QAAI,CAAC,YAAY,IAAI,MAAM,EAAE,EAAG,QAAO,KAAK,KAAK;AAAA,EACnD;AAEA,SAAO;AACT;AAEA,SAAS,sBAAsB,SAA6B;AAC1D,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,SAAS;AAC3B,eAAW,UAAU,OAAO,KAAK,MAAM,gBAAgB,CAAC,CAAC,GAAG;AAC1D,cAAQ,IAAI,MAAgB;AAAA,IAC9B;AAAA,EACF;AACA,SAAO,CAAC,GAAG,OAAO;AACpB;AAEA,SAAS,6BAA6B,UAAoB,UAA8B;AACtF,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;AACvE,SAAO,SAAS,IAAI,CAAC,UAAU;AAC7B,QAAI,MAAM,aAAc,QAAO;AAC/B,UAAM,eAAe,aAAa,IAAI,MAAM,EAAE,GAAG;AACjD,WAAO,eAAe,EAAE,GAAG,OAAO,aAAa,IAAI;AAAA,EACrD,CAAC;AACH;AAEO,SAAS,gBAAgB,SAAmB;AACjD,QAAM,WAAW,iBAAiB;AAClC,MAAI,aAAa,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC/D,WAAO,MAAM,mDAAmD;AAAA,EAClE;AACA,QAAM,cAAc,8BAA8B,OAAO;AACzD,QAAM,uBAAuB,uBAAuB,WAAW;AAC/D,QAAM,sBAAsB,aAAa,QAAQ;AACjD,QAAM,oBAAoB,sBACtB,iBAAiB,UAAU,WAAW,IACtC,6BAA6B,YAAY,CAAC,GAAG,WAAW;AAC5D,mBAAiB,iBAAiB;AAClC,MAAI,sBAAsB;AACxB,qCAAiC,sBAAsB,WAAW,CAAC;AAAA,EACrE,OAAO;AACL,8BAA0B;AAAA,EAC5B;AACF;AAEO,SAAS,aAAuB;AACrC,QAAM,UAAU,iBAAiB;AACjC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0EAA0E;AAAA,EAC5F;AACA,SAAO;AACT;AAQO,SAAS,gBAAiC;AAC/C,SAAO,iBAAiB;AAC1B;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,47 @@
1
+ import { createHash } from "node:crypto";
2
+ import { getBackendRouteManifests } from "../../modules/registry.js";
3
+ import { getModules } from "./registry.js";
4
+ const UNKNOWN_FINGERPRINT = "unknown";
5
+ let cachedFingerprint = null;
6
+ let cachedManifestsRef = null;
7
+ let cachedModulesRef = null;
8
+ function readModulesRef() {
9
+ try {
10
+ return getModules();
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+ function byCodeUnit(a, b) {
16
+ return a < b ? -1 : a > b ? 1 : 0;
17
+ }
18
+ function describeModules(modules) {
19
+ if (!modules) return [];
20
+ return modules.map((mod) => {
21
+ const features = (Array.isArray(mod.features) ? mod.features : []).filter((feature) => feature && typeof feature.id === "string" && feature.id.length > 0).map((feature) => `${feature.id}:${feature.module || mod.id}`).sort(byCodeUnit);
22
+ return JSON.stringify({ id: mod.id, features });
23
+ }).sort(byCodeUnit);
24
+ }
25
+ function getModuleSurfaceFingerprint() {
26
+ const manifests = getBackendRouteManifests();
27
+ const modulesRef = readModulesRef();
28
+ if (cachedFingerprint !== null && cachedManifestsRef === manifests && cachedModulesRef === modulesRef) {
29
+ return cachedFingerprint;
30
+ }
31
+ let fingerprint;
32
+ try {
33
+ const modules = describeModules(modulesRef);
34
+ const routes = manifests.map((route) => JSON.stringify(route)).sort(byCodeUnit);
35
+ fingerprint = createHash("sha1").update(JSON.stringify({ modules, routes })).digest("hex").slice(0, 12);
36
+ } catch {
37
+ fingerprint = UNKNOWN_FINGERPRINT;
38
+ }
39
+ cachedManifestsRef = manifests;
40
+ cachedModulesRef = modulesRef;
41
+ cachedFingerprint = fingerprint;
42
+ return fingerprint;
43
+ }
44
+ export {
45
+ getModuleSurfaceFingerprint
46
+ };
47
+ //# sourceMappingURL=surfaceFingerprint.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/modules/surfaceFingerprint.ts"],
4
+ "sourcesContent": ["/**\n * Deploy-time fingerprint of the registered module surface.\n *\n * Some cached payloads are derived from state that only changes when the app\n * is redeployed \u2014 the enabled module set and the backend route manifest. That\n * state has no database write to hang a tag invalidation off, so a cache key\n * that omits it keeps serving the pre-deploy payload to anyone with a warm\n * entry (see `/api/auth/admin/nav`, whose payload embeds\n * `filterGrantsByEnabledModules(...)` computed at write time).\n *\n * Mixing this fingerprint into such keys makes new processes write to new\n * keys, so old and new pods coexist during a rolling deploy without a purge\n * step and without any ordering constraint between purge and rollout.\n *\n * Covered inputs: the enabled module ids, each module's declared `features`\n * (which drive `filterGrantsByEnabledModules`, including the `*` superadmin\n * expansion and off-convention prefixes), and every JSON-serializable field\n * of the backend route manifest.\n *\n * NOT covered: a route's `icon`. It is a React element whose `type` is a\n * function, which `JSON.stringify` drops \u2014 two different icon components\n * serialize identically. Swapping an icon heals on the caller's TTL, not on\n * the fingerprint. Callers MUST therefore still pass a `ttl`.\n */\nimport { createHash } from 'node:crypto'\nimport type { Module } from '../../modules/registry'\nimport { getBackendRouteManifests } from '../../modules/registry'\nimport { getModules } from './registry'\n\nconst UNKNOWN_FINGERPRINT = 'unknown'\n\nlet cachedFingerprint: string | null = null\nlet cachedManifestsRef: unknown = null\nlet cachedModulesRef: unknown = null\n\nfunction readModulesRef(): readonly Module[] | null {\n try {\n return getModules()\n } catch {\n return null\n }\n}\n\nfunction byCodeUnit(a: string, b: string): number {\n return a < b ? -1 : a > b ? 1 : 0\n}\n\nfunction describeModules(modules: readonly Module[] | null): string[] {\n if (!modules) return []\n return modules\n .map((mod) => {\n const features = (Array.isArray(mod.features) ? mod.features : [])\n .filter((feature) => feature && typeof feature.id === 'string' && feature.id.length > 0)\n .map((feature) => `${feature.id}:${feature.module || mod.id}`)\n .sort(byCodeUnit)\n return JSON.stringify({ id: mod.id, features })\n })\n .sort(byCodeUnit)\n}\n\nexport function getModuleSurfaceFingerprint(): string {\n const manifests = getBackendRouteManifests()\n const modulesRef = readModulesRef()\n if (cachedFingerprint !== null && cachedManifestsRef === manifests && cachedModulesRef === modulesRef) {\n return cachedFingerprint\n }\n // The manifest carries third-party route metadata and module-scope React\n // elements, so serialization is not fully under this repo's control. Every\n // other fallible step in the nav handler degrades rather than throws, and a\n // throw here would blank the entire admin chrome \u2014 fall back to a constant,\n // which is exactly the pre-fingerprint behavior.\n let fingerprint: string\n try {\n const modules = describeModules(modulesRef)\n const routes = manifests.map((route) => JSON.stringify(route)).sort(byCodeUnit)\n fingerprint = createHash('sha1')\n .update(JSON.stringify({ modules, routes }))\n .digest('hex')\n .slice(0, 12)\n } catch {\n fingerprint = UNKNOWN_FINGERPRINT\n }\n cachedManifestsRef = manifests\n cachedModulesRef = modulesRef\n cachedFingerprint = fingerprint\n return fingerprint\n}\n"],
5
+ "mappings": "AAwBA,SAAS,kBAAkB;AAE3B,SAAS,gCAAgC;AACzC,SAAS,kBAAkB;AAE3B,MAAM,sBAAsB;AAE5B,IAAI,oBAAmC;AACvC,IAAI,qBAA8B;AAClC,IAAI,mBAA4B;AAEhC,SAAS,iBAA2C;AAClD,MAAI;AACF,WAAO,WAAW;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,WAAW,GAAW,GAAmB;AAChD,SAAO,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI;AAClC;AAEA,SAAS,gBAAgB,SAA6C;AACpE,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,SAAO,QACJ,IAAI,CAAC,QAAQ;AACZ,UAAM,YAAY,MAAM,QAAQ,IAAI,QAAQ,IAAI,IAAI,WAAW,CAAC,GAC7D,OAAO,CAAC,YAAY,WAAW,OAAO,QAAQ,OAAO,YAAY,QAAQ,GAAG,SAAS,CAAC,EACtF,IAAI,CAAC,YAAY,GAAG,QAAQ,EAAE,IAAI,QAAQ,UAAU,IAAI,EAAE,EAAE,EAC5D,KAAK,UAAU;AAClB,WAAO,KAAK,UAAU,EAAE,IAAI,IAAI,IAAI,SAAS,CAAC;AAAA,EAChD,CAAC,EACA,KAAK,UAAU;AACpB;AAEO,SAAS,8BAAsC;AACpD,QAAM,YAAY,yBAAyB;AAC3C,QAAM,aAAa,eAAe;AAClC,MAAI,sBAAsB,QAAQ,uBAAuB,aAAa,qBAAqB,YAAY;AACrG,WAAO;AAAA,EACT;AAMA,MAAI;AACJ,MAAI;AACF,UAAM,UAAU,gBAAgB,UAAU;AAC1C,UAAM,SAAS,UAAU,IAAI,CAAC,UAAU,KAAK,UAAU,KAAK,CAAC,EAAE,KAAK,UAAU;AAC9E,kBAAc,WAAW,MAAM,EAC5B,OAAO,KAAK,UAAU,EAAE,SAAS,OAAO,CAAC,CAAC,EAC1C,OAAO,KAAK,EACZ,MAAM,GAAG,EAAE;AAAA,EAChB,QAAQ;AACN,kBAAc;AAAA,EAChB;AACA,uBAAqB;AACrB,qBAAmB;AACnB,sBAAoB;AACpB,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,51 @@
1
+ import { createLogger } from "../logger/index.js";
2
+ const logger = createLogger("shared").child({ component: "query" });
3
+ const REASON_HINTS = {
4
+ "search-disabled": "OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.",
5
+ "no-indexable-tokens": "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.",
6
+ "no-search-tokens": "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.",
7
+ "raw-orm-filter": "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."
8
+ };
9
+ const WARN_CACHE_CAP = 5e3;
10
+ const warned = /* @__PURE__ */ new Set();
11
+ const warnKey = (entity, tenantId, field) => `${entity}|${tenantId ?? "null"}|${field}`;
12
+ const normalizeColumnName = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
13
+ function resetCiphertextLikeWarnCache() {
14
+ warned.clear();
15
+ }
16
+ async function warnOnCiphertextLikeFallback(params) {
17
+ const { entity, tenantId, reason, service } = params;
18
+ if (!service || service.isEnabled?.() === false) return;
19
+ if (typeof service.getEncryptedFieldNames !== "function") return;
20
+ const candidates = params.fields.filter((field) => typeof field === "string" && !field.startsWith("cf:")).map((field) => normalizeColumnName(field)).filter((field, index, all) => field.length > 0 && all.indexOf(field) === index).filter((field) => !warned.has(warnKey(entity, tenantId, field)));
21
+ if (!candidates.length) return;
22
+ try {
23
+ const encrypted = new Set(
24
+ (await service.getEncryptedFieldNames(entity, tenantId, null)).map(
25
+ (field) => normalizeColumnName(String(field))
26
+ )
27
+ );
28
+ for (const field of candidates) {
29
+ if (!encrypted.has(field)) continue;
30
+ if (warned.size >= WARN_CACHE_CAP) warned.clear();
31
+ warned.add(warnKey(entity, tenantId, field));
32
+ logger.warn("Text search filter cannot match an encrypted column", {
33
+ entity,
34
+ field,
35
+ reason,
36
+ hint: REASON_HINTS[reason]
37
+ });
38
+ }
39
+ } catch (err) {
40
+ logger.debug("Ciphertext search warning check failed", {
41
+ entity,
42
+ err: err instanceof Error ? err.message : String(err)
43
+ });
44
+ }
45
+ }
46
+ export {
47
+ normalizeColumnName,
48
+ resetCiphertextLikeWarnCache,
49
+ warnOnCiphertextLikeFallback
50
+ };
51
+ //# sourceMappingURL=ciphertext-search-warning.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/query/ciphertext-search-warning.ts"],
4
+ "sourcesContent": ["import { createLogger } from '../logger'\nimport type { EntityId } from '../../modules/entities'\n\nconst logger = createLogger('shared').child({ component: 'query' })\n\n/**\n * Minimal slice of `TenantDataEncryptionService` needed to decide whether a\n * text filter targets an encrypted column.\n */\nexport type CiphertextWarningEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\n} | null | undefined\n\nexport type CiphertextLikeFallbackReason =\n | 'no-indexable-tokens'\n | 'no-search-tokens'\n | 'search-disabled'\n | 'raw-orm-filter'\n\nconst REASON_HINTS: Record<CiphertextLikeFallbackReason, string> = {\n 'search-disabled':\n 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.',\n 'no-indexable-tokens':\n '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.',\n 'no-search-tokens':\n '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.',\n 'raw-orm-filter':\n '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.',\n}\n\n// One warning per entity/tenant/field per process \u2014 the fallback repeats on\n// every request, and an operator only needs to learn about it once.\nconst WARN_CACHE_CAP = 5000\nconst warned = new Set<string>()\n\nconst warnKey = (entity: string, tenantId: string | null, field: string): string =>\n `${entity}|${tenantId ?? 'null'}|${field}`\n\nexport const normalizeColumnName = (value: string): string =>\n value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()\n\n/** Test seam: the warn-once cache is process-global by design. */\nexport function resetCiphertextLikeWarnCache(): void {\n warned.clear()\n}\n\n/**\n * Warn when a `like`/`ilike` filter is about to run against a column that an\n * encryption map covers while the token index is unavailable.\n *\n * Without this the query degrades silently: the predicate compares a plaintext\n * pattern against ciphertext, matches nothing, and the endpoint returns an\n * empty page that is indistinguishable from a genuine no-result. Issue #2990.\n *\n * Never throws and never blocks the query \u2014 a failure here is logged at debug\n * level and the caller proceeds unchanged.\n */\nexport async function warnOnCiphertextLikeFallback(params: {\n entity: string\n fields: readonly string[]\n tenantId: string | null\n reason: CiphertextLikeFallbackReason\n service: CiphertextWarningEncryptionService\n}): Promise<void> {\n const { entity, tenantId, reason, service } = params\n if (!service || service.isEnabled?.() === false) return\n if (typeof service.getEncryptedFieldNames !== 'function') return\n\n const candidates = params.fields\n .filter((field) => typeof field === 'string' && !field.startsWith('cf:'))\n .map((field) => normalizeColumnName(field))\n .filter((field, index, all) => field.length > 0 && all.indexOf(field) === index)\n .filter((field) => !warned.has(warnKey(entity, tenantId, field)))\n if (!candidates.length) return\n\n try {\n // `organizationId: null` resolves the global map plus every per-organization\n // override, so a field encrypted for any organization is reported.\n const encrypted = new Set(\n (await service.getEncryptedFieldNames(entity, tenantId, null)).map((field) =>\n normalizeColumnName(String(field)),\n ),\n )\n for (const field of candidates) {\n if (!encrypted.has(field)) continue\n if (warned.size >= WARN_CACHE_CAP) warned.clear()\n warned.add(warnKey(entity, tenantId, field))\n logger.warn('Text search filter cannot match an encrypted column', {\n entity,\n field,\n reason,\n hint: REASON_HINTS[reason],\n })\n }\n } catch (err) {\n logger.debug('Ciphertext search warning check failed', {\n entity,\n err: err instanceof Error ? err.message : String(err),\n })\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAqBlE,MAAM,eAA6D;AAAA,EACjE,mBACE;AAAA,EACF,uBACE;AAAA,EACF,oBACE;AAAA,EACF,kBACE;AACJ;AAIA,MAAM,iBAAiB;AACvB,MAAM,SAAS,oBAAI,IAAY;AAE/B,MAAM,UAAU,CAAC,QAAgB,UAAyB,UACxD,GAAG,MAAM,IAAI,YAAY,MAAM,IAAI,KAAK;AAEnC,MAAM,sBAAsB,CAAC,UAClC,MAAM,QAAQ,sBAAsB,OAAO,EAAE,YAAY;AAGpD,SAAS,+BAAqC;AACnD,SAAO,MAAM;AACf;AAaA,eAAsB,6BAA6B,QAMjC;AAChB,QAAM,EAAE,QAAQ,UAAU,QAAQ,QAAQ,IAAI;AAC9C,MAAI,CAAC,WAAW,QAAQ,YAAY,MAAM,MAAO;AACjD,MAAI,OAAO,QAAQ,2BAA2B,WAAY;AAE1D,QAAM,aAAa,OAAO,OACvB,OAAO,CAAC,UAAU,OAAO,UAAU,YAAY,CAAC,MAAM,WAAW,KAAK,CAAC,EACvE,IAAI,CAAC,UAAU,oBAAoB,KAAK,CAAC,EACzC,OAAO,CAAC,OAAO,OAAO,QAAQ,MAAM,SAAS,KAAK,IAAI,QAAQ,KAAK,MAAM,KAAK,EAC9E,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,QAAQ,QAAQ,UAAU,KAAK,CAAC,CAAC;AAClE,MAAI,CAAC,WAAW,OAAQ;AAExB,MAAI;AAGF,UAAM,YAAY,IAAI;AAAA,OACnB,MAAM,QAAQ,uBAAuB,QAAQ,UAAU,IAAI,GAAG;AAAA,QAAI,CAAC,UAClE,oBAAoB,OAAO,KAAK,CAAC;AAAA,MACnC;AAAA,IACF;AACA,eAAW,SAAS,YAAY;AAC9B,UAAI,CAAC,UAAU,IAAI,KAAK,EAAG;AAC3B,UAAI,OAAO,QAAQ,eAAgB,QAAO,MAAM;AAChD,aAAO,IAAI,QAAQ,QAAQ,UAAU,KAAK,CAAC;AAC3C,aAAO,KAAK,uDAAuD;AAAA,QACjE;AAAA,QACA;AAAA,QACA;AAAA,QACA,MAAM,aAAa,MAAM;AAAA,MAC3B,CAAC;AAAA,IACH;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,MAAM,0CAA0C;AAAA,MACrD;AAAA,MACA,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,IACtD,CAAC;AAAA,EACH;AACF;",
6
+ "names": []
7
+ }
@@ -6,12 +6,17 @@ import {
6
6
  resolveJoins
7
7
  } from "./join-utils.js";
8
8
  import { resolveSearchConfig } from "../search/config.js";
9
+ import {
10
+ createSearchTokenAvailability,
11
+ isSearchFilterOp
12
+ } from "../search/availability.js";
9
13
  import { tokenizeText } from "../search/tokenize.js";
10
14
  import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
11
15
  import {
12
16
  buildCustomFieldDefinitionIndexFromRows,
13
17
  resolveCfDefIndexOrgCandidates
14
18
  } from "../crud/custom-field-definition-index.js";
19
+ import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
15
20
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
16
21
  import { mapWithConcurrency } from "./bounded-decrypt.js";
17
22
  import { createLogger } from "../logger/index.js";
@@ -26,6 +31,8 @@ const pluralizeBaseName = (name) => {
26
31
  if (name.endsWith("y")) return `${name.slice(0, -1)}ies`;
27
32
  return `${name}s`;
28
33
  };
34
+ const PLAIN_TABLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
+ const isPlainTableIdentifier = (value) => typeof value === "string" && PLAIN_TABLE_IDENTIFIER_PATTERN.test(value);
29
36
  const toPascalCase = (value) => {
30
37
  return value.split(/[_\s]+/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join("");
31
38
  };
@@ -133,8 +140,8 @@ class BasicQueryEngine {
133
140
  this.getDbFn = getDbFn;
134
141
  this.resolveEncryptionService = resolveEncryptionService;
135
142
  this.columnCache = /* @__PURE__ */ new Map();
136
- this.tableCache = /* @__PURE__ */ new Map();
137
143
  this.searchAliasSeq = 0;
144
+ this.searchAvailabilityInstance = null;
138
145
  }
139
146
  getEncryptionService() {
140
147
  try {
@@ -149,6 +156,21 @@ class BasicQueryEngine {
149
156
  if (typeof emAny?.getKysely === "function") return emAny.getKysely();
150
157
  throw new Error("BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)");
151
158
  }
159
+ searchAvailability() {
160
+ if (!this.searchAvailabilityInstance) {
161
+ this.searchAvailabilityInstance = createSearchTokenAvailability({
162
+ getDb: () => this.getDb(),
163
+ getConfig: resolveSearchConfig,
164
+ applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
165
+ query,
166
+ column,
167
+ scope
168
+ ),
169
+ logDebug: (event, payload) => this.logSearchDebug(event, payload)
170
+ });
171
+ }
172
+ return this.searchAvailabilityInstance;
173
+ }
152
174
  async query(entity, opts = {}) {
153
175
  const ext = opts.extensions;
154
176
  let effectiveOpts = opts;
@@ -204,11 +226,10 @@ class BasicQueryEngine {
204
226
  const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap);
205
227
  const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith("cf:"));
206
228
  const searchConfig = resolveSearchConfig();
207
- const searchEnabled = !skipAutoScope && searchConfig.enabled && await this.tableExists("search_tokens");
208
- const hasSearchTokens = searchEnabled ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
229
+ const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op));
230
+ const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
231
+ const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
209
232
  const searchActive = searchEnabled && hasSearchTokens;
210
- const joinSearchAvailability = /* @__PURE__ */ new Map();
211
- const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === "like" || filter.op === "ilike");
212
233
  if (searchFilters.length) {
213
234
  const fields = searchFilters.map((filter) => String(filter.field));
214
235
  this.logSearchDebug("search:init", {
@@ -238,6 +259,35 @@ class BasicQueryEngine {
238
259
  organizationScope: orgScope
239
260
  });
240
261
  }
262
+ const fallbackFields = searchFilters.filter((filter) => !searchActive || typeof filter.value !== "string" || tokenizeText(filter.value, searchConfig).hashes.length === 0).map((filter) => String(filter.field));
263
+ if (fallbackFields.length) {
264
+ await warnOnCiphertextLikeFallback({
265
+ entity: String(entity),
266
+ fields: fallbackFields,
267
+ tenantId: opts.tenantId ?? null,
268
+ // `searchEnabled` also folds in the missing-table and
269
+ // omitAutomaticTenantOrgScope cases, which are "no usable tokens"
270
+ // rather than "the operator switched search off".
271
+ reason: searchActive ? "no-indexable-tokens" : searchConfig.enabled ? "no-search-tokens" : "search-disabled",
272
+ service: this.getEncryptionService()
273
+ });
274
+ }
275
+ }
276
+ for (const [alias, joinedFilters] of joinFilters) {
277
+ const filters = joinedFilters.filter((entry) => entry.op === "like" || entry.op === "ilike");
278
+ if (!filters.length) continue;
279
+ const join = joinMap.get(alias);
280
+ if (!join?.entityId) continue;
281
+ const hasJoinedTokens = searchEnabled ? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope) : false;
282
+ const fallbackFields = filters.filter((filter) => !hasJoinedTokens || typeof filter.value !== "string" || tokenizeText(filter.value, searchConfig).hashes.length === 0).map((filter) => filter.column);
283
+ if (!fallbackFields.length) continue;
284
+ await warnOnCiphertextLikeFallback({
285
+ entity: join.entityId,
286
+ fields: fallbackFields,
287
+ tenantId: opts.tenantId ?? null,
288
+ reason: hasJoinedTokens ? "no-indexable-tokens" : searchConfig.enabled ? "no-search-tokens" : "search-disabled",
289
+ service: this.getEncryptionService()
290
+ });
241
291
  }
242
292
  const recordIdColumn = qualify("id");
243
293
  const applyFilterOp = (builder, column, op, value, fieldName) => {
@@ -278,11 +328,7 @@ class BasicQueryEngine {
278
328
  if (!searchEnabled || !join.entityId) return { applied: false, builder };
279
329
  if (!["like", "ilike"].includes(filter.op)) return { applied: false, builder };
280
330
  if (typeof filter.value !== "string" || filter.value.trim().length === 0) return { applied: false, builder };
281
- let searchAvailable = joinSearchAvailability.get(join.entityId);
282
- if (searchAvailable === void 0) {
283
- searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope);
284
- joinSearchAvailability.set(join.entityId, searchAvailable);
285
- }
331
+ const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope);
286
332
  if (!searchAvailable) return { applied: false, builder };
287
333
  const tokens = tokenizeText(String(filter.value), searchConfig);
288
334
  if (!tokens.hashes.length) return { applied: false, builder };
@@ -652,7 +698,8 @@ class BasicQueryEngine {
652
698
  const chosen = Array.isArray(opts.includeExtensions) ? exts.filter((e) => opts.includeExtensions.includes(e.extension)) : exts;
653
699
  for (const e of chosen) {
654
700
  const [, extName] = e.extension.split(":");
655
- const extTable = extName.endsWith("s") ? extName : `${extName}s`;
701
+ const derivedTable = pluralizeBaseName(extName);
702
+ const extTable = isPlainTableIdentifier(e.table) ? e.table : derivedTable;
656
703
  const alias = `ext_${sanitize(extName)}`;
657
704
  q = q.leftJoin(
658
705
  `${extTable} as ${alias}`,
@@ -867,36 +914,6 @@ class BasicQueryEngine {
867
914
  else this.columnCache.delete(key);
868
915
  return present;
869
916
  }
870
- async tableExists(table) {
871
- if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false;
872
- const db = this.getDb();
873
- const exists = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", table).limit(1).executeTakeFirst();
874
- const present = !!exists;
875
- this.tableCache.set(table, present);
876
- return present;
877
- }
878
- async hasSearchTokens(entity, tenantId, orgScope) {
879
- try {
880
- const db = this.getDb();
881
- let query = db.selectFrom("search_tokens").select(sql`1`.as("one")).where("entity_type", "=", entity).limit(1);
882
- if (tenantId !== void 0) {
883
- query = query.where(sql`tenant_id is not distinct from ${tenantId}`);
884
- }
885
- if (orgScope) {
886
- query = this.applyOrganizationScope(query, "search_tokens.organization_id", orgScope);
887
- }
888
- const row = await query.executeTakeFirst();
889
- return !!row;
890
- } catch (err) {
891
- this.logSearchDebug("search:has-tokens-error", {
892
- entity,
893
- tenantId,
894
- organizationScope: orgScope,
895
- error: err instanceof Error ? err.message : String(err)
896
- });
897
- return false;
898
- }
899
- }
900
917
  applySearchTokens(q, opts) {
901
918
  if (!opts.hashes.length) {
902
919
  this.logSearchDebug("search:skip-no-hashes", {
@@ -993,10 +1010,10 @@ class BasicQueryEngine {
993
1010
  const textExpr = sql`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`;
994
1011
  switch (opts.op) {
995
1012
  case "eq":
996
- sub = sub.where(sql`${textExpr} = ${opts.value}`);
1013
+ sub = opts.value === null ? sub.where(sql`${textExpr} is null`) : sub.where(sql`${textExpr} = ${opts.value}`);
997
1014
  break;
998
1015
  case "ne":
999
- sub = sub.where(sql`${textExpr} <> ${opts.value}`);
1016
+ sub = opts.value === null ? sub.where(sql`${textExpr} is not null`) : sub.where(sql`${textExpr} <> ${opts.value}`);
1000
1017
  break;
1001
1018
  case "gt":
1002
1019
  case "gte":