@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +62 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +190 -70
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/commands/command-bus.js +9 -3
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +9 -4
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/errors.js +25 -4
- package/dist/lib/commands/errors.js.map +2 -2
- package/dist/lib/commands/index.js +7 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +104 -23
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +36 -6
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +51 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +60 -43
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +20 -10
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +64 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +346 -96
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
- package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
- package/src/lib/commands/command-bus.ts +9 -3
- package/src/lib/commands/command-interceptor-runner.ts +27 -6
- package/src/lib/commands/command-interceptor.ts +8 -0
- package/src/lib/commands/errors.ts +67 -2
- package/src/lib/commands/index.ts +7 -1
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +157 -22
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +46 -7
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
- package/src/lib/query/__tests__/engine.test.ts +213 -13
- package/src/lib/query/ciphertext-search-warning.ts +106 -0
- package/src/lib/query/engine.ts +102 -61
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/entities.ts +15 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
package/dist/lib/logger/index.js
CHANGED
|
@@ -1,12 +1,53 @@
|
|
|
1
1
|
import { selectTransport } from "./transport.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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;
|
|
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:
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
5
|
-
"mappings": "AACA,SAAS,qCAAqC;
|
|
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
|
+
}
|
package/dist/lib/query/engine.js
CHANGED
|
@@ -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
|
|
208
|
-
const
|
|
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
|
-
|
|
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
|
|
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":
|