@open-mercato/shared 0.6.6-develop.6460.1.f94c74174c → 0.6.6-develop.6471.1.9ab5bdd79a
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 +1 -0
- package/dist/lib/commands/command-bus.js +4 -2
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +4 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/commands/flush.js +3 -4
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/helpers.js +3 -1
- package/dist/lib/commands/helpers.js.map +2 -2
- package/dist/lib/commands/registry.js +5 -3
- package/dist/lib/commands/registry.js.map +2 -2
- package/dist/lib/commands/scope.js +5 -3
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/crud/advanced-filter-integration.js +3 -1
- package/dist/lib/crud/advanced-filter-integration.js.map +2 -2
- package/dist/lib/crud/cache.js +3 -1
- package/dist/lib/crud/cache.js.map +2 -2
- package/dist/lib/crud/custom-fields.js +4 -2
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +6 -10
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +10 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-registry.js +3 -3
- package/dist/lib/crud/interceptor-registry.js.map +2 -2
- package/dist/lib/crud/mutation-guard.js +4 -2
- package/dist/lib/crud/mutation-guard.js.map +2 -2
- package/dist/lib/crud/optimistic-lock.js +5 -6
- package/dist/lib/crud/optimistic-lock.js.map +2 -2
- package/dist/lib/crud/route-mutation-guard.js +3 -1
- package/dist/lib/crud/route-mutation-guard.js.map +2 -2
- package/dist/lib/crud/sync-event-runner.js +3 -1
- package/dist/lib/crud/sync-event-runner.js.map +2 -2
- package/dist/lib/data/engine.js +5 -5
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/mikro.js +5 -3
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/di/container.js +3 -1
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +3 -1
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/entityFields.js +3 -1
- package/dist/lib/encryption/entityFields.js.map +2 -2
- package/dist/lib/encryption/entityIds.js +3 -1
- package/dist/lib/encryption/entityIds.js.map +2 -2
- package/dist/lib/encryption/kms.js +25 -32
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +3 -1
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +3 -1
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/indexers/error-log.js +4 -2
- package/dist/lib/indexers/error-log.js.map +2 -2
- package/dist/lib/indexers/status-log.js +5 -3
- package/dist/lib/indexers/status-log.js.map +2 -2
- package/dist/lib/logger/index.js +28 -0
- package/dist/lib/logger/index.js.map +7 -0
- package/dist/lib/logger/level.js +43 -0
- package/dist/lib/logger/level.js.map +7 -0
- package/dist/lib/logger/transport.console.js +39 -0
- package/dist/lib/logger/transport.console.js.map +7 -0
- package/dist/lib/logger/transport.js +17 -0
- package/dist/lib/logger/transport.js.map +7 -0
- package/dist/lib/logger/transport.pretty.js +85 -0
- package/dist/lib/logger/transport.pretty.js.map +7 -0
- package/dist/lib/logger/transport.server.js +85 -0
- package/dist/lib/logger/transport.server.js.map +7 -0
- package/dist/lib/middleware/page-executor.js +3 -1
- package/dist/lib/middleware/page-executor.js.map +2 -2
- package/dist/lib/modules/registry.js +3 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/profiler/index.js +3 -2
- package/dist/lib/profiler/index.js.map +2 -2
- package/dist/lib/query/engine.js +5 -6
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/query-extension-runner.js +5 -11
- package/dist/lib/query/query-extension-runner.js.map +2 -2
- package/dist/lib/redis/connection.js +3 -1
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/url.js +7 -5
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/analytics.js +3 -1
- package/dist/modules/analytics.js.map +2 -2
- package/dist/modules/dashboard/widgets.js +3 -1
- package/dist/modules/dashboard/widgets.js.map +2 -2
- package/dist/modules/events/factory.js +5 -3
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/overrides.js +16 -34
- package/dist/modules/overrides.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js +3 -1
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +6 -6
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/dist/modules/widgets/injection-position.js +3 -1
- package/dist/modules/widgets/injection-position.js.map +2 -2
- package/package.json +7 -2
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +19 -6
- package/src/lib/commands/__tests__/flush.test.ts +24 -15
- package/src/lib/commands/__tests__/normalizeAuthorUserId.test.ts +14 -2
- package/src/lib/commands/__tests__/registry.test.ts +17 -2
- package/src/lib/commands/__tests__/scope.test.ts +21 -9
- package/src/lib/commands/command-bus.ts +5 -2
- package/src/lib/commands/command-interceptor-runner.ts +5 -2
- package/src/lib/commands/flush.ts +4 -5
- package/src/lib/commands/helpers.ts +9 -2
- package/src/lib/commands/registry.ts +6 -3
- package/src/lib/commands/scope.ts +6 -3
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +21 -6
- package/src/lib/crud/__tests__/route-mutation-guard.test.ts +17 -2
- package/src/lib/crud/__tests__/sync-event-runner.test.ts +17 -3
- package/src/lib/crud/advanced-filter-integration.ts +4 -1
- package/src/lib/crud/cache.ts +4 -1
- package/src/lib/crud/custom-fields.ts +5 -2
- package/src/lib/crud/enricher-runner.ts +7 -10
- package/src/lib/crud/factory.ts +11 -10
- package/src/lib/crud/interceptor-registry.ts +4 -3
- package/src/lib/crud/mutation-guard.ts +5 -2
- package/src/lib/crud/optimistic-lock.ts +6 -10
- package/src/lib/crud/route-mutation-guard.ts +4 -1
- package/src/lib/crud/sync-event-runner.ts +4 -1
- package/src/lib/data/__tests__/engine.event-validation.test.ts +26 -15
- package/src/lib/data/engine.ts +6 -6
- package/src/lib/db/mikro.ts +6 -3
- package/src/lib/di/container.ts +4 -1
- package/src/lib/encryption/aes.ts +4 -2
- package/src/lib/encryption/entityFields.ts +5 -1
- package/src/lib/encryption/entityIds.ts +4 -1
- package/src/lib/encryption/kms.ts +27 -33
- package/src/lib/encryption/subscriber.ts +4 -2
- package/src/lib/encryption/tenantDataEncryptionService.ts +4 -2
- package/src/lib/indexers/__tests__/status-log.test.ts +22 -10
- package/src/lib/indexers/error-log.ts +5 -2
- package/src/lib/indexers/status-log.ts +6 -3
- package/src/lib/logger/__tests__/logger.test.ts +539 -0
- package/src/lib/logger/index.ts +33 -0
- package/src/lib/logger/level.ts +50 -0
- package/src/lib/logger/transport.console.ts +41 -0
- package/src/lib/logger/transport.pretty.ts +94 -0
- package/src/lib/logger/transport.server.ts +125 -0
- package/src/lib/logger/transport.ts +17 -0
- package/src/lib/middleware/page-executor.ts +4 -1
- package/src/lib/modules/__tests__/registry.test.ts +28 -21
- package/src/lib/modules/registry.ts +4 -1
- package/src/lib/profiler/index.ts +4 -2
- package/src/lib/query/__tests__/query-extension-runner.test.ts +21 -7
- package/src/lib/query/engine.ts +7 -10
- package/src/lib/query/query-extension-runner.ts +6 -11
- package/src/lib/redis/connection.ts +5 -1
- package/src/lib/url.ts +10 -5
- package/src/modules/__tests__/overrides.test.ts +17 -3
- package/src/modules/__tests__/route-overrides.test.ts +29 -20
- package/src/modules/analytics.ts +5 -1
- package/src/modules/dashboard/widgets.ts +4 -1
- package/src/modules/events/factory.ts +6 -3
- package/src/modules/overrides.ts +18 -34
- package/src/modules/registry.ts +4 -1
- package/src/modules/search.ts +4 -1
- package/src/modules/widgets/injection-loader.ts +7 -6
- package/src/modules/widgets/injection-position.ts +5 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { applyApiInterceptorOverridesToEntries } from "../../modules/overrides.js";
|
|
2
|
+
import { createLogger } from "../logger/index.js";
|
|
3
|
+
const logger = createLogger("shared").child({ component: "umes" });
|
|
2
4
|
let _interceptorEntries = null;
|
|
3
5
|
const GLOBAL_INTERCEPTOR_KEY = "__openMercatoApiInterceptors__";
|
|
4
6
|
function readGlobalInterceptors() {
|
|
@@ -69,9 +71,7 @@ function getApiInterceptorsForRoute(routePath, method) {
|
|
|
69
71
|
const warningKey = `${routePath}:${method}:${prev.interceptor.id}:${current.interceptor.id}:${currentPriority}`;
|
|
70
72
|
if (collisionWarnings.has(warningKey)) continue;
|
|
71
73
|
collisionWarnings.add(warningKey);
|
|
72
|
-
|
|
73
|
-
`[UMES] Interceptors "${prev.interceptor.id}" and "${current.interceptor.id}" have the same priority (${currentPriority}) for route "${routePath}". Execution order is based on module registration order.`
|
|
74
|
-
);
|
|
74
|
+
logger.warn("Interceptors share the same priority for route \u2014 execution order is based on module registration order", { firstInterceptorId: prev.interceptor.id, secondInterceptorId: current.interceptor.id, priority: currentPriority, routePath });
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
return sorted;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/interceptor-registry.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ApiInterceptor, ApiInterceptorMethod, ApiInterceptorRegistryEntry } from './api-interceptor'\nimport { applyApiInterceptorOverridesToEntries } from '../../modules/overrides'\n\nlet _interceptorEntries: ApiInterceptorRegistryEntry[] | null = null\nconst GLOBAL_INTERCEPTOR_KEY = '__openMercatoApiInterceptors__'\n\nfunction readGlobalInterceptors(): ApiInterceptorRegistryEntry[] | null {\n try {\n const value = (globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY]\n return Array.isArray(value) ? (value as ApiInterceptorRegistryEntry[]) : null\n } catch {\n return null\n }\n}\n\nfunction writeGlobalInterceptors(entries: ApiInterceptorRegistryEntry[]) {\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY] = entries\n } catch {\n // ignore global assignment failures\n }\n}\n\nexport function registerApiInterceptors(entries: Array<{ moduleId: string; interceptors: ApiInterceptor[] }>) {\n const finalEntries = applyApiInterceptorOverridesToEntries(entries)\n const flat: ApiInterceptorRegistryEntry[] = []\n finalEntries.forEach((entry, moduleOrder) => {\n entry.interceptors.forEach((interceptor, interceptorOrder) => {\n flat.push({\n moduleId: entry.moduleId,\n interceptor,\n moduleOrder,\n interceptorOrder,\n })\n })\n })\n _interceptorEntries = flat\n writeGlobalInterceptors(flat)\n}\n\nexport function getAllApiInterceptors(): ApiInterceptorRegistryEntry[] {\n const globalEntries = readGlobalInterceptors()\n if (globalEntries) return globalEntries\n if (!_interceptorEntries) return []\n return _interceptorEntries\n}\n\nfunction routeMatches(targetRoute: string, routePath: string): boolean {\n if (targetRoute === '*') return true\n if (targetRoute.endsWith('/*')) {\n const prefix = targetRoute.slice(0, -2)\n return routePath === prefix || routePath.startsWith(`${prefix}/`)\n }\n return targetRoute === routePath\n}\n\nconst collisionWarnings = new Set<string>()\n\nexport function getApiInterceptorsForRoute(routePath: string, method: ApiInterceptorMethod): ApiInterceptorRegistryEntry[] {\n const matching = getAllApiInterceptors().filter((entry) => {\n const methods = entry.interceptor.methods ?? []\n return methods.includes(method) && routeMatches(entry.interceptor.targetRoute, routePath)\n })\n\n const sorted = matching.sort((a, b) => {\n const byPriority = (b.interceptor.priority ?? 0) - (a.interceptor.priority ?? 0)\n if (byPriority !== 0) return byPriority\n const byModule = a.moduleOrder - b.moduleOrder\n if (byModule !== 0) return byModule\n return a.interceptorOrder - b.interceptorOrder\n })\n\n if (process.env.NODE_ENV !== 'production') {\n for (let i = 1; i < sorted.length; i++) {\n const prev = sorted[i - 1]\n const current = sorted[i]\n const prevPriority = prev.interceptor.priority ?? 0\n const currentPriority = current.interceptor.priority ?? 0\n if (prevPriority !== currentPriority) continue\n const warningKey = `${routePath}:${method}:${prev.interceptor.id}:${current.interceptor.id}:${currentPriority}`\n if (collisionWarnings.has(warningKey)) continue\n collisionWarnings.add(warningKey)\n
|
|
5
|
-
"mappings": "AACA,SAAS,6CAA6C;
|
|
4
|
+
"sourcesContent": ["import type { ApiInterceptor, ApiInterceptorMethod, ApiInterceptorRegistryEntry } from './api-interceptor'\nimport { applyApiInterceptorOverridesToEntries } from '../../modules/overrides'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'umes' })\n\nlet _interceptorEntries: ApiInterceptorRegistryEntry[] | null = null\nconst GLOBAL_INTERCEPTOR_KEY = '__openMercatoApiInterceptors__'\n\nfunction readGlobalInterceptors(): ApiInterceptorRegistryEntry[] | null {\n try {\n const value = (globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY]\n return Array.isArray(value) ? (value as ApiInterceptorRegistryEntry[]) : null\n } catch {\n return null\n }\n}\n\nfunction writeGlobalInterceptors(entries: ApiInterceptorRegistryEntry[]) {\n try {\n ;(globalThis as Record<string, unknown>)[GLOBAL_INTERCEPTOR_KEY] = entries\n } catch {\n // ignore global assignment failures\n }\n}\n\nexport function registerApiInterceptors(entries: Array<{ moduleId: string; interceptors: ApiInterceptor[] }>) {\n const finalEntries = applyApiInterceptorOverridesToEntries(entries)\n const flat: ApiInterceptorRegistryEntry[] = []\n finalEntries.forEach((entry, moduleOrder) => {\n entry.interceptors.forEach((interceptor, interceptorOrder) => {\n flat.push({\n moduleId: entry.moduleId,\n interceptor,\n moduleOrder,\n interceptorOrder,\n })\n })\n })\n _interceptorEntries = flat\n writeGlobalInterceptors(flat)\n}\n\nexport function getAllApiInterceptors(): ApiInterceptorRegistryEntry[] {\n const globalEntries = readGlobalInterceptors()\n if (globalEntries) return globalEntries\n if (!_interceptorEntries) return []\n return _interceptorEntries\n}\n\nfunction routeMatches(targetRoute: string, routePath: string): boolean {\n if (targetRoute === '*') return true\n if (targetRoute.endsWith('/*')) {\n const prefix = targetRoute.slice(0, -2)\n return routePath === prefix || routePath.startsWith(`${prefix}/`)\n }\n return targetRoute === routePath\n}\n\nconst collisionWarnings = new Set<string>()\n\nexport function getApiInterceptorsForRoute(routePath: string, method: ApiInterceptorMethod): ApiInterceptorRegistryEntry[] {\n const matching = getAllApiInterceptors().filter((entry) => {\n const methods = entry.interceptor.methods ?? []\n return methods.includes(method) && routeMatches(entry.interceptor.targetRoute, routePath)\n })\n\n const sorted = matching.sort((a, b) => {\n const byPriority = (b.interceptor.priority ?? 0) - (a.interceptor.priority ?? 0)\n if (byPriority !== 0) return byPriority\n const byModule = a.moduleOrder - b.moduleOrder\n if (byModule !== 0) return byModule\n return a.interceptorOrder - b.interceptorOrder\n })\n\n if (process.env.NODE_ENV !== 'production') {\n for (let i = 1; i < sorted.length; i++) {\n const prev = sorted[i - 1]\n const current = sorted[i]\n const prevPriority = prev.interceptor.priority ?? 0\n const currentPriority = current.interceptor.priority ?? 0\n if (prevPriority !== currentPriority) continue\n const warningKey = `${routePath}:${method}:${prev.interceptor.id}:${current.interceptor.id}:${currentPriority}`\n if (collisionWarnings.has(warningKey)) continue\n collisionWarnings.add(warningKey)\n logger.warn('Interceptors share the same priority for route \u2014 execution order is based on module registration order', { firstInterceptorId: prev.interceptor.id, secondInterceptorId: current.interceptor.id, priority: currentPriority, routePath })\n }\n }\n\n return sorted\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,6CAA6C;AACtD,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,OAAO,CAAC;AAEjE,IAAI,sBAA4D;AAChE,MAAM,yBAAyB;AAE/B,SAAS,yBAA+D;AACtE,MAAI;AACF,UAAM,QAAS,WAAuC,sBAAsB;AAC5E,WAAO,MAAM,QAAQ,KAAK,IAAK,QAA0C;AAAA,EAC3E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,wBAAwB,SAAwC;AACvE,MAAI;AACF;AAAC,IAAC,WAAuC,sBAAsB,IAAI;AAAA,EACrE,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,wBAAwB,SAAsE;AAC5G,QAAM,eAAe,sCAAsC,OAAO;AAClE,QAAM,OAAsC,CAAC;AAC7C,eAAa,QAAQ,CAAC,OAAO,gBAAgB;AAC3C,UAAM,aAAa,QAAQ,CAAC,aAAa,qBAAqB;AAC5D,WAAK,KAAK;AAAA,QACR,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AACD,wBAAsB;AACtB,0BAAwB,IAAI;AAC9B;AAEO,SAAS,wBAAuD;AACrE,QAAM,gBAAgB,uBAAuB;AAC7C,MAAI,cAAe,QAAO;AAC1B,MAAI,CAAC,oBAAqB,QAAO,CAAC;AAClC,SAAO;AACT;AAEA,SAAS,aAAa,aAAqB,WAA4B;AACrE,MAAI,gBAAgB,IAAK,QAAO;AAChC,MAAI,YAAY,SAAS,IAAI,GAAG;AAC9B,UAAM,SAAS,YAAY,MAAM,GAAG,EAAE;AACtC,WAAO,cAAc,UAAU,UAAU,WAAW,GAAG,MAAM,GAAG;AAAA,EAClE;AACA,SAAO,gBAAgB;AACzB;AAEA,MAAM,oBAAoB,oBAAI,IAAY;AAEnC,SAAS,2BAA2B,WAAmB,QAA6D;AACzH,QAAM,WAAW,sBAAsB,EAAE,OAAO,CAAC,UAAU;AACzD,UAAM,UAAU,MAAM,YAAY,WAAW,CAAC;AAC9C,WAAO,QAAQ,SAAS,MAAM,KAAK,aAAa,MAAM,YAAY,aAAa,SAAS;AAAA,EAC1F,CAAC;AAED,QAAM,SAAS,SAAS,KAAK,CAAC,GAAG,MAAM;AACrC,UAAM,cAAc,EAAE,YAAY,YAAY,MAAM,EAAE,YAAY,YAAY;AAC9E,QAAI,eAAe,EAAG,QAAO;AAC7B,UAAM,WAAW,EAAE,cAAc,EAAE;AACnC,QAAI,aAAa,EAAG,QAAO;AAC3B,WAAO,EAAE,mBAAmB,EAAE;AAAA,EAChC,CAAC;AAED,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,aAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,YAAM,OAAO,OAAO,IAAI,CAAC;AACzB,YAAM,UAAU,OAAO,CAAC;AACxB,YAAM,eAAe,KAAK,YAAY,YAAY;AAClD,YAAM,kBAAkB,QAAQ,YAAY,YAAY;AACxD,UAAI,iBAAiB,gBAAiB;AACtC,YAAM,aAAa,GAAG,SAAS,IAAI,MAAM,IAAI,KAAK,YAAY,EAAE,IAAI,QAAQ,YAAY,EAAE,IAAI,eAAe;AAC7G,UAAI,kBAAkB,IAAI,UAAU,EAAG;AACvC,wBAAkB,IAAI,UAAU;AAChC,aAAO,KAAK,+GAA0G,EAAE,oBAAoB,KAAK,YAAY,IAAI,qBAAqB,QAAQ,YAAY,IAAI,UAAU,iBAAiB,UAAU,CAAC;AAAA,IACtP;AAAA,EACF;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createLogger } from "../logger/index.js";
|
|
2
|
+
const logger = createLogger("shared").child({ component: "crud" });
|
|
1
3
|
function resolveCrudMutationGuardService(container) {
|
|
2
4
|
try {
|
|
3
5
|
const service = container.resolve("crudMutationGuardService");
|
|
@@ -20,12 +22,12 @@ async function runCrudMutationGuardAfterSuccess(container, input) {
|
|
|
20
22
|
try {
|
|
21
23
|
await service.afterMutationSuccess(input);
|
|
22
24
|
} catch (error) {
|
|
23
|
-
|
|
25
|
+
logger.error("Mutation guard after-success hook failed", {
|
|
24
26
|
resourceKind: input.resourceKind,
|
|
25
27
|
resourceId: input.resourceId,
|
|
26
28
|
operation: input.operation,
|
|
27
29
|
requestMethod: input.requestMethod,
|
|
28
|
-
|
|
30
|
+
err: error
|
|
29
31
|
});
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/mutation-guard.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\n\nexport type CrudMutationGuardValidationSuccess = {\n ok: true\n shouldRunAfterSuccess: boolean\n metadata?: Record<string, unknown> | null\n}\n\nexport type CrudMutationGuardValidationFailure = {\n ok: false\n status: number\n body: Record<string, unknown>\n}\n\nexport type CrudMutationGuardValidationResult =\n | CrudMutationGuardValidationSuccess\n | CrudMutationGuardValidationFailure\n\nexport type CrudMutationGuardValidateInput = {\n tenantId: string\n organizationId?: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete' | 'custom'\n requestMethod: string\n requestHeaders: Headers\n mutationPayload?: Record<string, unknown> | null\n}\n\nexport type CrudMutationGuardAfterSuccessInput = {\n tenantId: string\n organizationId?: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete' | 'custom'\n requestMethod: string\n requestHeaders: Headers\n metadata?: Record<string, unknown> | null\n}\n\ntype CrudMutationGuardServiceLike = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n}\n\nfunction resolveCrudMutationGuardService(container: AwilixContainer): CrudMutationGuardServiceLike | null {\n try {\n const service = container.resolve<CrudMutationGuardServiceLike>('crudMutationGuardService')\n if (!service) return null\n if (typeof service.validateMutation !== 'function') return null\n if (typeof service.afterMutationSuccess !== 'function') return null\n return service\n } catch {\n return null\n }\n}\n\n/**\n * @deprecated Resolves ONLY the single DI-registered `crudMutationGuardService`,\n * so it silently bypasses every guard in the global mutation-guard store\n * (`getAllMutationGuardInstances()`). Use the full registry instead:\n * `runRouteMutationGuards()` from `@open-mercato/shared/lib/crud/route-mutation-guard`\n * for custom write routes, or `runMutationGuards()` from\n * `@open-mercato/shared/lib/crud/mutation-guard-registry` directly. The legacy\n * service is still honored on the modern path via `bridgeLegacyGuard()`. This\n * function will be removed in a future release.\n */\nexport async function validateCrudMutationGuard(\n container: AwilixContainer,\n input: CrudMutationGuardValidateInput,\n): Promise<CrudMutationGuardValidationResult | null> {\n const service = resolveCrudMutationGuardService(container)\n if (!service) return null\n return service.validateMutation(input)\n}\n\n/**\n * @deprecated Runs ONLY the single DI-registered `crudMutationGuardService`'s\n * after-success hook, skipping the registry guards' `afterSuccess` callbacks.\n * Use the `runAfterSuccess()` returned by `runRouteMutationGuards()` from\n * `@open-mercato/shared/lib/crud/route-mutation-guard`, or the\n * `afterSuccessCallbacks` returned by `runMutationGuards()` from\n * `@open-mercato/shared/lib/crud/mutation-guard-registry`. This function will be\n * removed in a future release.\n */\nexport async function runCrudMutationGuardAfterSuccess(\n container: AwilixContainer,\n input: CrudMutationGuardAfterSuccessInput,\n): Promise<void> {\n const service = resolveCrudMutationGuardService(container)\n if (!service) return\n try {\n await service.afterMutationSuccess(input)\n } catch (error) {\n
|
|
5
|
-
"mappings": "AA+
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'crud' })\n\nexport type CrudMutationGuardValidationSuccess = {\n ok: true\n shouldRunAfterSuccess: boolean\n metadata?: Record<string, unknown> | null\n}\n\nexport type CrudMutationGuardValidationFailure = {\n ok: false\n status: number\n body: Record<string, unknown>\n}\n\nexport type CrudMutationGuardValidationResult =\n | CrudMutationGuardValidationSuccess\n | CrudMutationGuardValidationFailure\n\nexport type CrudMutationGuardValidateInput = {\n tenantId: string\n organizationId?: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete' | 'custom'\n requestMethod: string\n requestHeaders: Headers\n mutationPayload?: Record<string, unknown> | null\n}\n\nexport type CrudMutationGuardAfterSuccessInput = {\n tenantId: string\n organizationId?: string | null\n userId: string\n resourceKind: string\n resourceId: string\n operation: 'create' | 'update' | 'delete' | 'custom'\n requestMethod: string\n requestHeaders: Headers\n metadata?: Record<string, unknown> | null\n}\n\ntype CrudMutationGuardServiceLike = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n}\n\nfunction resolveCrudMutationGuardService(container: AwilixContainer): CrudMutationGuardServiceLike | null {\n try {\n const service = container.resolve<CrudMutationGuardServiceLike>('crudMutationGuardService')\n if (!service) return null\n if (typeof service.validateMutation !== 'function') return null\n if (typeof service.afterMutationSuccess !== 'function') return null\n return service\n } catch {\n return null\n }\n}\n\n/**\n * @deprecated Resolves ONLY the single DI-registered `crudMutationGuardService`,\n * so it silently bypasses every guard in the global mutation-guard store\n * (`getAllMutationGuardInstances()`). Use the full registry instead:\n * `runRouteMutationGuards()` from `@open-mercato/shared/lib/crud/route-mutation-guard`\n * for custom write routes, or `runMutationGuards()` from\n * `@open-mercato/shared/lib/crud/mutation-guard-registry` directly. The legacy\n * service is still honored on the modern path via `bridgeLegacyGuard()`. This\n * function will be removed in a future release.\n */\nexport async function validateCrudMutationGuard(\n container: AwilixContainer,\n input: CrudMutationGuardValidateInput,\n): Promise<CrudMutationGuardValidationResult | null> {\n const service = resolveCrudMutationGuardService(container)\n if (!service) return null\n return service.validateMutation(input)\n}\n\n/**\n * @deprecated Runs ONLY the single DI-registered `crudMutationGuardService`'s\n * after-success hook, skipping the registry guards' `afterSuccess` callbacks.\n * Use the `runAfterSuccess()` returned by `runRouteMutationGuards()` from\n * `@open-mercato/shared/lib/crud/route-mutation-guard`, or the\n * `afterSuccessCallbacks` returned by `runMutationGuards()` from\n * `@open-mercato/shared/lib/crud/mutation-guard-registry`. This function will be\n * removed in a future release.\n */\nexport async function runCrudMutationGuardAfterSuccess(\n container: AwilixContainer,\n input: CrudMutationGuardAfterSuccessInput,\n): Promise<void> {\n const service = resolveCrudMutationGuardService(container)\n if (!service) return\n try {\n await service.afterMutationSuccess(input)\n } catch (error) {\n logger.error('Mutation guard after-success hook failed', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n requestMethod: input.requestMethod,\n err: error,\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,OAAO,CAAC;AA+CjE,SAAS,gCAAgC,WAAiE;AACxG,MAAI;AACF,UAAM,UAAU,UAAU,QAAsC,0BAA0B;AAC1F,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,OAAO,QAAQ,qBAAqB,WAAY,QAAO;AAC3D,QAAI,OAAO,QAAQ,yBAAyB,WAAY,QAAO;AAC/D,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAYA,eAAsB,0BACpB,WACA,OACmD;AACnD,QAAM,UAAU,gCAAgC,SAAS;AACzD,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,QAAQ,iBAAiB,KAAK;AACvC;AAWA,eAAsB,iCACpB,WACA,OACe;AACf,QAAM,UAAU,gCAAgC,SAAS;AACzD,MAAI,CAAC,QAAS;AACd,MAAI;AACF,UAAM,QAAQ,qBAAqB,KAAK;AAAA,EAC1C,SAAS,OAAO;AACd,WAAO,MAAM,4CAA4C;AAAA,MACvD,cAAc,MAAM;AAAA,MACpB,YAAY,MAAM;AAAA,MAClB,WAAW,MAAM;AAAA,MACjB,eAAe,MAAM;AAAA,MACrB,KAAK;AAAA,IACP,CAAC;AAAA,EACH;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
OPTIMISTIC_LOCK_HEADER_NAME
|
|
6
6
|
} from "./optimistic-lock-headers.js";
|
|
7
7
|
import { getAllOptimisticLockReaders } from "./optimistic-lock-store.js";
|
|
8
|
+
import { createLogger } from "../logger/index.js";
|
|
9
|
+
const logger = createLogger("shared").child({ component: "optimistic-lock" });
|
|
8
10
|
const OPTIMISTIC_LOCK_OFF_TOKENS = /* @__PURE__ */ new Set([
|
|
9
11
|
"off",
|
|
10
12
|
"false",
|
|
@@ -47,10 +49,7 @@ function createGenericOptimisticLockReader(opts) {
|
|
|
47
49
|
if (typeof value === "string" && value.length > 0) return value;
|
|
48
50
|
return null;
|
|
49
51
|
} catch (err) {
|
|
50
|
-
|
|
51
|
-
`[optimistic-lock] reader query failed for resourceKind="${resourceKind}" \u2014 optimistic locking is DISABLED for this entity until fixed. Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,
|
|
52
|
-
err
|
|
53
|
-
);
|
|
52
|
+
logger.error("Reader query failed \u2014 optimistic locking is DISABLED for this entity until fixed; most likely a softDeleteField/tenantField/orgField filters on a column the table does not have", { resourceKind, err });
|
|
54
53
|
return null;
|
|
55
54
|
}
|
|
56
55
|
};
|
|
@@ -127,7 +126,7 @@ function createOptimisticLockGuardService(opts) {
|
|
|
127
126
|
}
|
|
128
127
|
if (currentIso === expectedIso) {
|
|
129
128
|
if (debugEnabled) {
|
|
130
|
-
|
|
129
|
+
logger.info("Optimistic lock match", {
|
|
131
130
|
resourceKind: input.resourceKind,
|
|
132
131
|
resourceId: input.resourceId,
|
|
133
132
|
operation: input.operation,
|
|
@@ -138,7 +137,7 @@ function createOptimisticLockGuardService(opts) {
|
|
|
138
137
|
return { ok: true, shouldRunAfterSuccess: false };
|
|
139
138
|
}
|
|
140
139
|
if (debugEnabled) {
|
|
141
|
-
|
|
140
|
+
logger.info("Optimistic lock conflict", {
|
|
142
141
|
resourceKind: input.resourceKind,
|
|
143
142
|
resourceId: input.resourceId,
|
|
144
143
|
operation: input.operation,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/optimistic-lock.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * OSS optimistic-locking guard service.\n *\n * Registered as `crudMutationGuardService` (by the platform DI bootstrap and\n * by hand-wiring modules that override the default reader). Compares the\n * client-sent expected `updated_at` (carried via the extension header\n * defined in `optimistic-lock-headers.ts`) against the current DB\n * `updated_at` for the target entity; on mismatch returns HTTP 409 with the\n * structured `OptimisticLockConflictBody`.\n *\n * **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via\n * `OM_OPTIMISTIC_LOCK`:\n * - unset / empty / whitespace \u2192 ON for every CRUD entity (`{ mode: 'all' }`)\n * - `all` \u2192 all entities (explicit form of the default)\n * - `customers.company,sales.order` \u2192 allow-list (lowercased, trimmed, deduped)\n * - `off` / `false` / `0` / `no` / `disabled` / `none` \u2192 fully disabled\n *\n * The guard is still strictly additive at runtime: clients that do not send\n * the `x-om-ext-optimistic-lock-expected-updated-at` header pass through\n * unchanged, so flipping the default to ON cannot introduce new 409s on\n * existing API consumers. Pages that opt into the round-trip (via\n * `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling\n * `buildOptimisticLockHeader`) gain protection without any per-deployment\n * env change.\n *\n * Cannot be registered as a static `data/guards.ts` `MutationGuard` because\n * the static `validate(input)` receives only `MutationGuardInput` \u2014 no\n * container / em access. Stateful checks that need to read current DB state\n * MUST go through the DI service path (this file).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type {\n CrudMutationGuardValidateInput,\n CrudMutationGuardValidationResult,\n CrudMutationGuardAfterSuccessInput,\n} from './mutation-guard'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport { getAllOptimisticLockReaders } from './optimistic-lock-store'\n\nexport type OptimisticLockConfig =\n | { mode: 'off' }\n | { mode: 'all' }\n | { mode: 'allowlist'; entities: ReadonlySet<string> }\n\n/**\n * Tokens (case-insensitive, single-token-only) that explicitly disable the\n * guard. Spelled out as a fixed set so tests can pin them; deliberately the\n * same shape `parseBooleanToken` recognises so operators can mirror existing\n * habit. Mixing an off-token with other entities is invalid input \u2014 we treat\n * any presence of an off-token in the comma list as a request to disable.\n */\nconst OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([\n 'off',\n 'false',\n '0',\n 'no',\n 'disabled',\n 'none',\n])\n\n/**\n * Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can\n * exercise the grammar without spinning up the full service.\n *\n * Default is **ON** (`{ mode: 'all' }`) \u2014 unset / empty / whitespace input\n * activates the guard for every CRUD entity. Operators opt out via\n * `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).\n */\nexport function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {\n if (raw == null) return { mode: 'all' }\n const trimmed = String(raw).trim()\n if (trimmed === '') return { mode: 'all' }\n\n const tokens = trimmed\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token.length > 0)\n\n if (tokens.length === 0) return { mode: 'all' }\n if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }\n if (tokens.includes('all')) return { mode: 'all' }\n\n return { mode: 'allowlist', entities: new Set(tokens) }\n}\n\nexport type OptimisticLockResolverInput = {\n expectedFromHeader: string | null\n resourceKind: string\n resourceId: string\n}\n\n/**\n * Hook reserved for the enterprise `record_locks` module to override token\n * resolution (e.g. read the expected token from a lock record instead of\n * the request header). OSS keeps the default = \"what the client sent\".\n *\n * Documented as part of the enterprise extension contract; not used in OSS\n * itself.\n */\nexport type ResolveExpectedUpdatedAt = (\n input: OptimisticLockResolverInput,\n) => Promise<string | null> | string | null\n\nconst defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>\n expectedFromHeader\n\nexport type OptimisticLockCurrentReader = (\n em: EntityManager,\n input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },\n) => Promise<string | null>\n\nexport type GenericOptimisticLockReaderOptions = {\n /** MikroORM entity class. */\n entity: unknown\n /** Primary key field. Defaults to `id`. */\n idField?: string\n /** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare \u2014 only when the entity itself has no `tenantId` column). */\n tenantField?: string | null\n /** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */\n orgField?: string | null\n /** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */\n softDeleteField?: string | null\n /** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */\n extraFilter?: Record<string, unknown>\n /** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */\n updatedAtField?: string\n}\n\n/**\n * Build a generic optimistic-lock reader for any ORM entity that follows the\n * platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +\n * `updatedAt`). The reader projects only the timestamp column so PII never\n * materializes.\n *\n * Used by `makeCrudRoute` to auto-register one reader per CRUD route at\n * module-load time (see Phase 13 of the OSS optimistic-locking spec).\n * Module authors who need bespoke filtering (e.g. discriminator on a shared\n * table) keep registering their own reader via `registerOptimisticLockReaders`\n * \u2014 those hand-wired registrations win because they land first.\n *\n * Fail-open contract: if the underlying `findOne` throws (missing column,\n * schema drift, mid-migration) the reader returns `null`, which the guard\n * treats as \"entity already gone\" and lets the CRUD path's own 404 fire.\n * We MUST NOT throw out of the reader \u2014 that would 500 every mutation on\n * the affected entity instead of opting it out of the optimistic check.\n *\n * Because a genuine not-found resolves to `null` *without* throwing (MikroORM's\n * `findOne` returns `null`, it does not raise), the catch below only fires on a\n * real query failure \u2014 most commonly a `softDeleteField`/`tenantField`/`orgField`\n * misconfig that filters on a column the entity's table does not have. That class\n * of bug silently disables locking for the whole entity, so the catch logs loudly\n * with the `resourceKind` instead of swallowing the error. The control flow stays\n * fail-open (returns `null`) to honor the no-500 contract; the durable defense is\n * the static reader-resolution guard (`optimistic-lock-editable-entities.test.ts`)\n * which fails the build when a route would land in this path.\n */\nexport function createGenericOptimisticLockReader(\n opts: GenericOptimisticLockReaderOptions,\n): OptimisticLockCurrentReader {\n const idField = opts.idField ?? 'id'\n const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'\n const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'\n const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'\n const updatedAtField = opts.updatedAtField ?? 'updatedAt'\n const extraFilter = opts.extraFilter ?? {}\n\n return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {\n const filter: Record<string, unknown> = { [idField]: resourceId }\n if (tenantField) filter[tenantField] = tenantId\n if (orgField && organizationId) filter[orgField] = organizationId\n if (softDeleteField) filter[softDeleteField] = null\n for (const [key, value] of Object.entries(extraFilter)) filter[key] = value\n\n try {\n const row = await em.findOne(opts.entity as never, filter as never, {\n fields: [updatedAtField] as never,\n })\n if (!row || typeof row !== 'object') return null\n const value = (row as Record<string, unknown>)[updatedAtField]\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'string' && value.length > 0) return value\n return null\n } catch (err) {\n // A genuine not-found returns null above without throwing; reaching here\n // means the query itself failed (most likely a softDeleteField/tenant/org\n // misconfig filtering on a column the table lacks), which silently disables\n // locking for `resourceKind`. Log loudly so the misconfig is visible.\n // Control flow stays fail-open (return null) to honor the no-500 contract.\n // eslint-disable-next-line no-console\n console.error(\n `[optimistic-lock] reader query failed for resourceKind=\"${resourceKind}\" \u2014 optimistic locking is DISABLED for this entity until fixed. ` +\n `Most likely a softDeleteField/tenantField/orgField filters on a column the table does not have.`,\n err,\n )\n return null\n }\n }\n}\n\nexport type OptimisticLockGuardOptions = {\n /** EntityManager resolver. Container-bound via DI in real usage. */\n getEm: () => EntityManager\n /**\n * Maps `resourceKind` \u2192 reader that returns the current\n * `updated_at` as an ISO string (or null when not found).\n *\n * The reader receives the EM so module authors can choose the\n * right `findOne` shape for their entity (`findOneWithDecryption`\n * when sensitive, plain `findOne` otherwise \u2014 but only requesting\n * `updated_at` so no PII materializes).\n *\n * When omitted, the service pulls readers from the shared\n * `optimistic-lock-store` (the recommended pattern for multi-module\n * deployments \u2014 each module registers its own readers via\n * `registerOptimisticLockReaders(...)` at module-load time).\n */\n readers?: Record<string, OptimisticLockCurrentReader>\n /** Override env source (mostly for tests). Defaults to `process.env`. */\n envValue?: string | null\n /** Override the token resolver. Defaults to \"use the header value\". */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\nexport type OptimisticLockGuardService = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n /** Exposed for tests / introspection. */\n getConfig: () => OptimisticLockConfig\n}\n\nfunction readHeader(headers: Headers, name: string): string | null {\n const direct = headers.get(name)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\n/**\n * Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`\n * when the input cannot be parsed. Exported so the command-level helper\n * (`optimistic-lock-command.ts`) compares timestamps with the EXACT same\n * normalization as the CRUD guard \u2014 otherwise the same instant could compare\n * unequal across the two paths.\n */\nexport function normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\nfunction buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\n/**\n * Factory for the optimistic-lock guard service.\n *\n * Usage from a module's `di.ts`:\n *\n * ```ts\n * import { asFunction } from 'awilix'\n * import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\n *\n * container.register({\n * crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({\n * getEm: () => cradle.em,\n * readers: {\n * 'customers.company': async (em, { resourceId, tenantId }) => {\n * const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })\n * return row?.updatedAt ? row.updatedAt.toISOString() : null\n * },\n * },\n * })).singleton(),\n * })\n * ```\n */\nexport function createOptimisticLockGuardService(\n opts: OptimisticLockGuardOptions,\n): OptimisticLockGuardService {\n const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]\n const config = parseOptimisticLockEnv(envValue)\n const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt\n const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'\n\n function isEntityEnabled(resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n }\n\n async function validateMutation(\n input: CrudMutationGuardValidateInput,\n ): Promise<CrudMutationGuardValidationResult> {\n if (config.mode === 'off') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (input.operation !== 'update' && input.operation !== 'delete') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (!isEntityEnabled(input.resourceKind)) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const readers = opts.readers ?? getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)\n const resolvedExpected = await resolveExpected({\n expectedFromHeader: expectedRaw,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n })\n if (resolvedExpected == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedIso = normalizeIsoToken(resolvedExpected)\n if (expectedIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const em = opts.getEm()\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (currentIso === expectedIso) {\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] match', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n currentIso,\n expectedIso,\n })\n }\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (debugEnabled) {\n // eslint-disable-next-line no-console\n console.log('[optimistic-lock] CONFLICT', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n expectedRaw: resolvedExpected,\n expectedIso,\n currentRaw,\n currentIso,\n })\n }\n\n return {\n ok: false,\n status: 409,\n body: buildConflictBody(currentIso, expectedIso),\n }\n }\n\n async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {\n // no-op: optimistic check has no post-success cleanup\n }\n\n function getConfig(): OptimisticLockConfig {\n return config\n }\n\n return {\n validateMutation,\n afterMutationSuccess,\n getConfig,\n }\n}\n"],
|
|
5
|
-
"mappings": "AAsCA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["/**\n * OSS optimistic-locking guard service.\n *\n * Registered as `crudMutationGuardService` (by the platform DI bootstrap and\n * by hand-wiring modules that override the default reader). Compares the\n * client-sent expected `updated_at` (carried via the extension header\n * defined in `optimistic-lock-headers.ts`) against the current DB\n * `updated_at` for the target entity; on mismatch returns HTTP 409 with the\n * structured `OptimisticLockConflictBody`.\n *\n * **Default ON** (Phase 14, 2026-05-27). Activate / scope / disable via\n * `OM_OPTIMISTIC_LOCK`:\n * - unset / empty / whitespace \u2192 ON for every CRUD entity (`{ mode: 'all' }`)\n * - `all` \u2192 all entities (explicit form of the default)\n * - `customers.company,sales.order` \u2192 allow-list (lowercased, trimmed, deduped)\n * - `off` / `false` / `0` / `no` / `disabled` / `none` \u2192 fully disabled\n *\n * The guard is still strictly additive at runtime: clients that do not send\n * the `x-om-ext-optimistic-lock-expected-updated-at` header pass through\n * unchanged, so flipping the default to ON cannot introduce new 409s on\n * existing API consumers. Pages that opt into the round-trip (via\n * `CrudForm`'s `optimisticLockUpdatedAt` prop or by calling\n * `buildOptimisticLockHeader`) gain protection without any per-deployment\n * env change.\n *\n * Cannot be registered as a static `data/guards.ts` `MutationGuard` because\n * the static `validate(input)` receives only `MutationGuardInput` \u2014 no\n * container / em access. Stateful checks that need to read current DB state\n * MUST go through the DI service path (this file).\n *\n * Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n */\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type {\n CrudMutationGuardValidateInput,\n CrudMutationGuardValidationResult,\n CrudMutationGuardAfterSuccessInput,\n} from './mutation-guard'\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_CONFLICT_ERROR,\n OPTIMISTIC_LOCK_ENV_VAR,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from './optimistic-lock-headers'\nimport { getAllOptimisticLockReaders } from './optimistic-lock-store'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'optimistic-lock' })\n\nexport type OptimisticLockConfig =\n | { mode: 'off' }\n | { mode: 'all' }\n | { mode: 'allowlist'; entities: ReadonlySet<string> }\n\n/**\n * Tokens (case-insensitive, single-token-only) that explicitly disable the\n * guard. Spelled out as a fixed set so tests can pin them; deliberately the\n * same shape `parseBooleanToken` recognises so operators can mirror existing\n * habit. Mixing an off-token with other entities is invalid input \u2014 we treat\n * any presence of an off-token in the comma list as a request to disable.\n */\nconst OPTIMISTIC_LOCK_OFF_TOKENS: ReadonlySet<string> = new Set([\n 'off',\n 'false',\n '0',\n 'no',\n 'disabled',\n 'none',\n])\n\n/**\n * Pure parser for `OM_OPTIMISTIC_LOCK`. Exported separately so tests can\n * exercise the grammar without spinning up the full service.\n *\n * Default is **ON** (`{ mode: 'all' }`) \u2014 unset / empty / whitespace input\n * activates the guard for every CRUD entity. Operators opt out via\n * `OM_OPTIMISTIC_LOCK=off` (or `false` / `0` / `no` / `disabled` / `none`).\n */\nexport function parseOptimisticLockEnv(raw: string | undefined | null): OptimisticLockConfig {\n if (raw == null) return { mode: 'all' }\n const trimmed = String(raw).trim()\n if (trimmed === '') return { mode: 'all' }\n\n const tokens = trimmed\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token.length > 0)\n\n if (tokens.length === 0) return { mode: 'all' }\n if (tokens.some((token) => OPTIMISTIC_LOCK_OFF_TOKENS.has(token))) return { mode: 'off' }\n if (tokens.includes('all')) return { mode: 'all' }\n\n return { mode: 'allowlist', entities: new Set(tokens) }\n}\n\nexport type OptimisticLockResolverInput = {\n expectedFromHeader: string | null\n resourceKind: string\n resourceId: string\n}\n\n/**\n * Hook reserved for the enterprise `record_locks` module to override token\n * resolution (e.g. read the expected token from a lock record instead of\n * the request header). OSS keeps the default = \"what the client sent\".\n *\n * Documented as part of the enterprise extension contract; not used in OSS\n * itself.\n */\nexport type ResolveExpectedUpdatedAt = (\n input: OptimisticLockResolverInput,\n) => Promise<string | null> | string | null\n\nconst defaultResolveExpectedUpdatedAt: ResolveExpectedUpdatedAt = ({ expectedFromHeader }) =>\n expectedFromHeader\n\nexport type OptimisticLockCurrentReader = (\n em: EntityManager,\n input: { resourceKind: string; resourceId: string; tenantId: string; organizationId: string | null },\n) => Promise<string | null>\n\nexport type GenericOptimisticLockReaderOptions = {\n /** MikroORM entity class. */\n entity: unknown\n /** Primary key field. Defaults to `id`. */\n idField?: string\n /** Tenant scope field. Defaults to `tenantId`. Pass `null` to skip tenant scoping (rare \u2014 only when the entity itself has no `tenantId` column). */\n tenantField?: string | null\n /** Organization scope field. Defaults to `organizationId`. Pass `null` to skip organization scoping. */\n orgField?: string | null\n /** Soft-delete column. Defaults to `deletedAt`. Pass `null` to skip the implicit not-deleted filter. */\n softDeleteField?: string | null\n /** Optional fixed filter merged into every query (e.g. `{ kind: 'company' }` for a discriminated table). */\n extraFilter?: Record<string, unknown>\n /** Optional ORM field name carrying the timestamp. Defaults to `updatedAt`. */\n updatedAtField?: string\n}\n\n/**\n * Build a generic optimistic-lock reader for any ORM entity that follows the\n * platform conventions (`id` + `tenantId` + `organizationId` + `deletedAt` +\n * `updatedAt`). The reader projects only the timestamp column so PII never\n * materializes.\n *\n * Used by `makeCrudRoute` to auto-register one reader per CRUD route at\n * module-load time (see Phase 13 of the OSS optimistic-locking spec).\n * Module authors who need bespoke filtering (e.g. discriminator on a shared\n * table) keep registering their own reader via `registerOptimisticLockReaders`\n * \u2014 those hand-wired registrations win because they land first.\n *\n * Fail-open contract: if the underlying `findOne` throws (missing column,\n * schema drift, mid-migration) the reader returns `null`, which the guard\n * treats as \"entity already gone\" and lets the CRUD path's own 404 fire.\n * We MUST NOT throw out of the reader \u2014 that would 500 every mutation on\n * the affected entity instead of opting it out of the optimistic check.\n *\n * Because a genuine not-found resolves to `null` *without* throwing (MikroORM's\n * `findOne` returns `null`, it does not raise), the catch below only fires on a\n * real query failure \u2014 most commonly a `softDeleteField`/`tenantField`/`orgField`\n * misconfig that filters on a column the entity's table does not have. That class\n * of bug silently disables locking for the whole entity, so the catch logs loudly\n * with the `resourceKind` instead of swallowing the error. The control flow stays\n * fail-open (returns `null`) to honor the no-500 contract; the durable defense is\n * the static reader-resolution guard (`optimistic-lock-editable-entities.test.ts`)\n * which fails the build when a route would land in this path.\n */\nexport function createGenericOptimisticLockReader(\n opts: GenericOptimisticLockReaderOptions,\n): OptimisticLockCurrentReader {\n const idField = opts.idField ?? 'id'\n const tenantField = opts.tenantField === null ? null : opts.tenantField ?? 'tenantId'\n const orgField = opts.orgField === null ? null : opts.orgField ?? 'organizationId'\n const softDeleteField = opts.softDeleteField === null ? null : opts.softDeleteField ?? 'deletedAt'\n const updatedAtField = opts.updatedAtField ?? 'updatedAt'\n const extraFilter = opts.extraFilter ?? {}\n\n return async (em, { resourceKind, resourceId, tenantId, organizationId }) => {\n const filter: Record<string, unknown> = { [idField]: resourceId }\n if (tenantField) filter[tenantField] = tenantId\n if (orgField && organizationId) filter[orgField] = organizationId\n if (softDeleteField) filter[softDeleteField] = null\n for (const [key, value] of Object.entries(extraFilter)) filter[key] = value\n\n try {\n const row = await em.findOne(opts.entity as never, filter as never, {\n fields: [updatedAtField] as never,\n })\n if (!row || typeof row !== 'object') return null\n const value = (row as Record<string, unknown>)[updatedAtField]\n if (value instanceof Date) return value.toISOString()\n if (typeof value === 'string' && value.length > 0) return value\n return null\n } catch (err) {\n // A genuine not-found returns null above without throwing; reaching here\n // means the query itself failed (most likely a softDeleteField/tenant/org\n // misconfig filtering on a column the table lacks), which silently disables\n // locking for `resourceKind`. Log loudly so the misconfig is visible.\n // Control flow stays fail-open (return null) to honor the no-500 contract.\n logger.error('Reader query failed \u2014 optimistic locking is DISABLED for this entity until fixed; most likely a softDeleteField/tenantField/orgField filters on a column the table does not have', { resourceKind, err })\n return null\n }\n }\n}\n\nexport type OptimisticLockGuardOptions = {\n /** EntityManager resolver. Container-bound via DI in real usage. */\n getEm: () => EntityManager\n /**\n * Maps `resourceKind` \u2192 reader that returns the current\n * `updated_at` as an ISO string (or null when not found).\n *\n * The reader receives the EM so module authors can choose the\n * right `findOne` shape for their entity (`findOneWithDecryption`\n * when sensitive, plain `findOne` otherwise \u2014 but only requesting\n * `updated_at` so no PII materializes).\n *\n * When omitted, the service pulls readers from the shared\n * `optimistic-lock-store` (the recommended pattern for multi-module\n * deployments \u2014 each module registers its own readers via\n * `registerOptimisticLockReaders(...)` at module-load time).\n */\n readers?: Record<string, OptimisticLockCurrentReader>\n /** Override env source (mostly for tests). Defaults to `process.env`. */\n envValue?: string | null\n /** Override the token resolver. Defaults to \"use the header value\". */\n resolveExpected?: ResolveExpectedUpdatedAt\n}\n\nexport type OptimisticLockGuardService = {\n validateMutation: (input: CrudMutationGuardValidateInput) => Promise<CrudMutationGuardValidationResult>\n afterMutationSuccess: (input: CrudMutationGuardAfterSuccessInput) => Promise<void>\n /** Exposed for tests / introspection. */\n getConfig: () => OptimisticLockConfig\n}\n\nfunction readHeader(headers: Headers, name: string): string | null {\n const direct = headers.get(name)\n if (typeof direct === 'string' && direct.trim().length > 0) return direct.trim()\n return null\n}\n\n/**\n * Normalize an `updated_at` token to a canonical ISO-8601 string, or `null`\n * when the input cannot be parsed. Exported so the command-level helper\n * (`optimistic-lock-command.ts`) compares timestamps with the EXACT same\n * normalization as the CRUD guard \u2014 otherwise the same instant could compare\n * unequal across the two paths.\n */\nexport function normalizeIsoToken(raw: string): string | null {\n const ms = Date.parse(raw)\n if (!Number.isFinite(ms)) return null\n return new Date(ms).toISOString()\n}\n\nfunction buildConflictBody(currentIso: string, expectedIso: string): OptimisticLockConflictBody {\n return {\n error: OPTIMISTIC_LOCK_CONFLICT_ERROR,\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt: currentIso,\n expectedUpdatedAt: expectedIso,\n }\n}\n\n/**\n * Factory for the optimistic-lock guard service.\n *\n * Usage from a module's `di.ts`:\n *\n * ```ts\n * import { asFunction } from 'awilix'\n * import { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\n *\n * container.register({\n * crudMutationGuardService: asFunction((cradle) => createOptimisticLockGuardService({\n * getEm: () => cradle.em,\n * readers: {\n * 'customers.company': async (em, { resourceId, tenantId }) => {\n * const row = await em.findOne(Company, { id: resourceId, tenantId }, { fields: ['updatedAt'] })\n * return row?.updatedAt ? row.updatedAt.toISOString() : null\n * },\n * },\n * })).singleton(),\n * })\n * ```\n */\nexport function createOptimisticLockGuardService(\n opts: OptimisticLockGuardOptions,\n): OptimisticLockGuardService {\n const envValue = opts.envValue !== undefined ? opts.envValue : process.env[OPTIMISTIC_LOCK_ENV_VAR]\n const config = parseOptimisticLockEnv(envValue)\n const resolveExpected = opts.resolveExpected ?? defaultResolveExpectedUpdatedAt\n const debugEnabled = process.env.OM_OPTIMISTIC_LOCK_DEBUG === '1'\n\n function isEntityEnabled(resourceKind: string): boolean {\n if (config.mode === 'off') return false\n if (config.mode === 'all') return true\n return config.entities.has(resourceKind.toLowerCase())\n }\n\n async function validateMutation(\n input: CrudMutationGuardValidateInput,\n ): Promise<CrudMutationGuardValidationResult> {\n if (config.mode === 'off') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (input.operation !== 'update' && input.operation !== 'delete') {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n if (!isEntityEnabled(input.resourceKind)) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const readers = opts.readers ?? getAllOptimisticLockReaders()\n const reader = readers[input.resourceKind]\n if (!reader) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedRaw = readHeader(input.requestHeaders, OPTIMISTIC_LOCK_HEADER_NAME)\n const resolvedExpected = await resolveExpected({\n expectedFromHeader: expectedRaw,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n })\n if (resolvedExpected == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const expectedIso = normalizeIsoToken(resolvedExpected)\n if (expectedIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n const em = opts.getEm()\n const currentRaw = await reader(em, {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n })\n if (currentRaw == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n const currentIso = normalizeIsoToken(currentRaw)\n if (currentIso == null) {\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (currentIso === expectedIso) {\n if (debugEnabled) {\n logger.info('Optimistic lock match', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n currentIso,\n expectedIso,\n })\n }\n return { ok: true, shouldRunAfterSuccess: false }\n }\n\n if (debugEnabled) {\n logger.info('Optimistic lock conflict', {\n resourceKind: input.resourceKind,\n resourceId: input.resourceId,\n operation: input.operation,\n tenantId: input.tenantId,\n organizationId: input.organizationId ?? null,\n expectedRaw: resolvedExpected,\n expectedIso,\n currentRaw,\n currentIso,\n })\n }\n\n return {\n ok: false,\n status: 409,\n body: buildConflictBody(currentIso, expectedIso),\n }\n }\n\n async function afterMutationSuccess(_input: CrudMutationGuardAfterSuccessInput): Promise<void> {\n // no-op: optimistic check has no post-success cleanup\n }\n\n function getConfig(): OptimisticLockConfig {\n return config\n }\n\n return {\n validateMutation,\n afterMutationSuccess,\n getConfig,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
runMutationGuards
|
|
4
4
|
} from "./mutation-guard-registry.js";
|
|
5
5
|
import { getAllMutationGuardInstances } from "./mutation-guard-store.js";
|
|
6
|
+
import { createLogger } from "../logger/index.js";
|
|
7
|
+
const logger = createLogger("shared").child({ component: "crud" });
|
|
6
8
|
function toRegistryMutationOperation(operation) {
|
|
7
9
|
if (operation === "create" || operation === "delete") return operation;
|
|
8
10
|
return "update";
|
|
@@ -71,7 +73,7 @@ async function runRouteMutationGuards(params) {
|
|
|
71
73
|
metadata
|
|
72
74
|
});
|
|
73
75
|
} catch (error) {
|
|
74
|
-
|
|
76
|
+
logger.error("Mutation guard afterSuccess failed", { guardId: guard.id, err: error });
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/route-mutation-guard.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport {\n bridgeLegacyGuard,\n runMutationGuards,\n type MutationGuard,\n} from './mutation-guard-registry'\nimport { getAllMutationGuardInstances } from './mutation-guard-store'\n\n/**\n * Shared registry-based mutation-guard wrapper for custom write routes that do\n * not use `makeCrudRoute`.\n *\n * This mirrors `collectAndRunGuards()` in `factory.ts`: it runs **every** guard\n * collected from the global mutation-guard store (`getAllMutationGuardInstances()`)\n * plus the bridged legacy DI service (`bridgeLegacyGuard()`), so a custom route\n * enforces the same guard set as every `makeCrudRoute` write.\n *\n * Prefer this over the deprecated `validateCrudMutationGuard()` /\n * `runCrudMutationGuardAfterSuccess()` pair, which resolve only the single\n * DI-registered `crudMutationGuardService` and silently skip registry guards.\n */\n\nexport type RouteMutationGuardOperation = 'create' | 'update' | 'delete' | 'custom'\n\nexport type RouteMutationGuardAuth = {\n userId: string\n tenantId: string\n organizationId?: string | null\n /**\n * Pre-resolved granted features for the caller. When omitted, the wrapper\n * resolves them via `rbacService.getGrantedFeatures` (same source the CRUD\n * factory uses). Feature-gated registry guards only run when their required\n * features are present, so supplying the wrong set silently skips guards.\n */\n userFeatures?: string[]\n}\n\nexport type RouteMutationGuardInput = {\n resourceKind: string\n resourceId?: string | null\n /**\n * The route's logical operation. `'custom'` (state-changing action endpoints)\n * is mapped to the closest registry operation, `'update'`, because\n * `runMutationGuards` only understands `create | update | delete`.\n */\n operation?: RouteMutationGuardOperation\n mutationPayload?: Record<string, unknown> | null\n}\n\nexport type RouteMutationGuardBlocked = {\n ok: false\n errorStatus: number\n errorBody: Record<string, unknown>\n /** Ready-to-return JSON response built from `errorBody` / `errorStatus`. */\n response: Response\n}\n\nexport type RouteMutationGuardPassed = {\n ok: true\n /** Merged payload when a guard transformed it; `undefined` when unchanged. */\n modifiedPayload?: Record<string, unknown>\n /**\n * Runs every guard's `afterSuccess` callback that requested it. Callback\n * failures are caught and logged so a committed write still succeeds \u2014 call\n * this only after the mutation has committed.\n */\n runAfterSuccess: () => Promise<void>\n}\n\nexport type RouteMutationGuardResult = RouteMutationGuardBlocked | RouteMutationGuardPassed\n\ntype RbacServiceLike = {\n getGrantedFeatures: (\n userId: string,\n opts: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n}\n\n/**\n * Map a route-level operation to the registry operation set. State-changing\n * action endpoints (`'custom'`) and `'update'` both map to `'update'` per the\n * `packages/core/AGENTS.md` \u2192 API Routes guidance.\n */\nexport function toRegistryMutationOperation(\n operation: RouteMutationGuardOperation | undefined,\n): 'create' | 'update' | 'delete' {\n if (operation === 'create' || operation === 'delete') return operation\n return 'update'\n}\n\nasync function resolveRouteUserFeatures(\n container: AwilixContainer,\n auth: RouteMutationGuardAuth,\n): Promise<string[]> {\n if (auth.userFeatures) return auth.userFeatures\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (rbac?.getGrantedFeatures) {\n return await rbac.getGrantedFeatures(auth.userId, {\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n })\n }\n } catch {\n // rbacService not available \u2014 guards without feature requirements still run.\n }\n return []\n}\n\n/**\n * Collect every registered mutation guard plus the bridged legacy DI service,\n * run them against the route's mutation, and return either a blocked result\n * (with a ready response) or a passed result carrying the merged payload and an\n * after-success runner.\n */\nexport async function runRouteMutationGuards(params: {\n container: AwilixContainer\n req: Request\n auth: RouteMutationGuardAuth\n input: RouteMutationGuardInput\n}): Promise<RouteMutationGuardResult> {\n const { container, req, auth, input } = params\n const operation = toRegistryMutationOperation(input.operation)\n\n const allGuards: MutationGuard[] = [...getAllMutationGuardInstances()]\n const legacyGuard = bridgeLegacyGuard(container)\n if (legacyGuard) allGuards.push(legacyGuard)\n\n const userFeatures = await resolveRouteUserFeatures(container, auth)\n\n const guardResult = await runMutationGuards(\n allGuards,\n {\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n userId: auth.userId,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId ?? null,\n operation,\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input.mutationPayload ?? null,\n },\n { userFeatures },\n )\n\n if (!guardResult.ok) {\n const errorStatus = guardResult.errorStatus ?? 422\n const errorBody = guardResult.errorBody ?? { error: 'Operation blocked by guard' }\n return {\n ok: false,\n errorStatus,\n errorBody,\n response: Response.json(errorBody, { status: errorStatus }),\n }\n }\n\n return {\n ok: true,\n modifiedPayload: guardResult.modifiedPayload,\n runAfterSuccess: async () => {\n for (const { guard, metadata } of guardResult.afterSuccessCallbacks) {\n try {\n await guard.afterSuccess!({\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n userId: auth.userId,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId ?? '',\n operation,\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata,\n })\n } catch (error) {\n
|
|
5
|
-
"mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oCAAoC;
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport {\n bridgeLegacyGuard,\n runMutationGuards,\n type MutationGuard,\n} from './mutation-guard-registry'\nimport { getAllMutationGuardInstances } from './mutation-guard-store'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'crud' })\n\n/**\n * Shared registry-based mutation-guard wrapper for custom write routes that do\n * not use `makeCrudRoute`.\n *\n * This mirrors `collectAndRunGuards()` in `factory.ts`: it runs **every** guard\n * collected from the global mutation-guard store (`getAllMutationGuardInstances()`)\n * plus the bridged legacy DI service (`bridgeLegacyGuard()`), so a custom route\n * enforces the same guard set as every `makeCrudRoute` write.\n *\n * Prefer this over the deprecated `validateCrudMutationGuard()` /\n * `runCrudMutationGuardAfterSuccess()` pair, which resolve only the single\n * DI-registered `crudMutationGuardService` and silently skip registry guards.\n */\n\nexport type RouteMutationGuardOperation = 'create' | 'update' | 'delete' | 'custom'\n\nexport type RouteMutationGuardAuth = {\n userId: string\n tenantId: string\n organizationId?: string | null\n /**\n * Pre-resolved granted features for the caller. When omitted, the wrapper\n * resolves them via `rbacService.getGrantedFeatures` (same source the CRUD\n * factory uses). Feature-gated registry guards only run when their required\n * features are present, so supplying the wrong set silently skips guards.\n */\n userFeatures?: string[]\n}\n\nexport type RouteMutationGuardInput = {\n resourceKind: string\n resourceId?: string | null\n /**\n * The route's logical operation. `'custom'` (state-changing action endpoints)\n * is mapped to the closest registry operation, `'update'`, because\n * `runMutationGuards` only understands `create | update | delete`.\n */\n operation?: RouteMutationGuardOperation\n mutationPayload?: Record<string, unknown> | null\n}\n\nexport type RouteMutationGuardBlocked = {\n ok: false\n errorStatus: number\n errorBody: Record<string, unknown>\n /** Ready-to-return JSON response built from `errorBody` / `errorStatus`. */\n response: Response\n}\n\nexport type RouteMutationGuardPassed = {\n ok: true\n /** Merged payload when a guard transformed it; `undefined` when unchanged. */\n modifiedPayload?: Record<string, unknown>\n /**\n * Runs every guard's `afterSuccess` callback that requested it. Callback\n * failures are caught and logged so a committed write still succeeds \u2014 call\n * this only after the mutation has committed.\n */\n runAfterSuccess: () => Promise<void>\n}\n\nexport type RouteMutationGuardResult = RouteMutationGuardBlocked | RouteMutationGuardPassed\n\ntype RbacServiceLike = {\n getGrantedFeatures: (\n userId: string,\n opts: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n}\n\n/**\n * Map a route-level operation to the registry operation set. State-changing\n * action endpoints (`'custom'`) and `'update'` both map to `'update'` per the\n * `packages/core/AGENTS.md` \u2192 API Routes guidance.\n */\nexport function toRegistryMutationOperation(\n operation: RouteMutationGuardOperation | undefined,\n): 'create' | 'update' | 'delete' {\n if (operation === 'create' || operation === 'delete') return operation\n return 'update'\n}\n\nasync function resolveRouteUserFeatures(\n container: AwilixContainer,\n auth: RouteMutationGuardAuth,\n): Promise<string[]> {\n if (auth.userFeatures) return auth.userFeatures\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (rbac?.getGrantedFeatures) {\n return await rbac.getGrantedFeatures(auth.userId, {\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n })\n }\n } catch {\n // rbacService not available \u2014 guards without feature requirements still run.\n }\n return []\n}\n\n/**\n * Collect every registered mutation guard plus the bridged legacy DI service,\n * run them against the route's mutation, and return either a blocked result\n * (with a ready response) or a passed result carrying the merged payload and an\n * after-success runner.\n */\nexport async function runRouteMutationGuards(params: {\n container: AwilixContainer\n req: Request\n auth: RouteMutationGuardAuth\n input: RouteMutationGuardInput\n}): Promise<RouteMutationGuardResult> {\n const { container, req, auth, input } = params\n const operation = toRegistryMutationOperation(input.operation)\n\n const allGuards: MutationGuard[] = [...getAllMutationGuardInstances()]\n const legacyGuard = bridgeLegacyGuard(container)\n if (legacyGuard) allGuards.push(legacyGuard)\n\n const userFeatures = await resolveRouteUserFeatures(container, auth)\n\n const guardResult = await runMutationGuards(\n allGuards,\n {\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n userId: auth.userId,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId ?? null,\n operation,\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input.mutationPayload ?? null,\n },\n { userFeatures },\n )\n\n if (!guardResult.ok) {\n const errorStatus = guardResult.errorStatus ?? 422\n const errorBody = guardResult.errorBody ?? { error: 'Operation blocked by guard' }\n return {\n ok: false,\n errorStatus,\n errorBody,\n response: Response.json(errorBody, { status: errorStatus }),\n }\n }\n\n return {\n ok: true,\n modifiedPayload: guardResult.modifiedPayload,\n runAfterSuccess: async () => {\n for (const { guard, metadata } of guardResult.afterSuccessCallbacks) {\n try {\n await guard.afterSuccess!({\n tenantId: auth.tenantId,\n organizationId: auth.organizationId ?? null,\n userId: auth.userId,\n resourceKind: input.resourceKind,\n resourceId: input.resourceId ?? '',\n operation,\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata,\n })\n } catch (error) {\n logger.error('Mutation guard afterSuccess failed', { guardId: guard.id, err: error })\n }\n }\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oCAAoC;AAC7C,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,OAAO,CAAC;AA6E1D,SAAS,4BACd,WACgC;AAChC,MAAI,cAAc,YAAY,cAAc,SAAU,QAAO;AAC7D,SAAO;AACT;AAEA,eAAe,yBACb,WACA,MACmB;AACnB,MAAI,KAAK,aAAc,QAAO,KAAK;AACnC,MAAI;AACF,UAAM,OAAO,UAAU,QAAQ,aAAa;AAC5C,QAAI,MAAM,oBAAoB;AAC5B,aAAO,MAAM,KAAK,mBAAmB,KAAK,QAAQ;AAAA,QAChD,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,kBAAkB;AAAA,MACzC,CAAC;AAAA,IACH;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO,CAAC;AACV;AAQA,eAAsB,uBAAuB,QAKP;AACpC,QAAM,EAAE,WAAW,KAAK,MAAM,MAAM,IAAI;AACxC,QAAM,YAAY,4BAA4B,MAAM,SAAS;AAE7D,QAAM,YAA6B,CAAC,GAAG,6BAA6B,CAAC;AACrE,QAAM,cAAc,kBAAkB,SAAS;AAC/C,MAAI,YAAa,WAAU,KAAK,WAAW;AAE3C,QAAM,eAAe,MAAM,yBAAyB,WAAW,IAAI;AAEnE,QAAM,cAAc,MAAM;AAAA,IACxB;AAAA,IACA;AAAA,MACE,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,kBAAkB;AAAA,MACvC,QAAQ,KAAK;AAAA,MACb,cAAc,MAAM;AAAA,MACpB,YAAY,MAAM,cAAc;AAAA,MAChC;AAAA,MACA,eAAe,IAAI;AAAA,MACnB,gBAAgB,IAAI;AAAA,MACpB,iBAAiB,MAAM,mBAAmB;AAAA,IAC5C;AAAA,IACA,EAAE,aAAa;AAAA,EACjB;AAEA,MAAI,CAAC,YAAY,IAAI;AACnB,UAAM,cAAc,YAAY,eAAe;AAC/C,UAAM,YAAY,YAAY,aAAa,EAAE,OAAO,6BAA6B;AACjF,WAAO;AAAA,MACL,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,MACA,UAAU,SAAS,KAAK,WAAW,EAAE,QAAQ,YAAY,CAAC;AAAA,IAC5D;AAAA,EACF;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,iBAAiB,YAAY;AAAA,IAC7B,iBAAiB,YAAY;AAC3B,iBAAW,EAAE,OAAO,SAAS,KAAK,YAAY,uBAAuB;AACnE,YAAI;AACF,gBAAM,MAAM,aAAc;AAAA,YACxB,UAAU,KAAK;AAAA,YACf,gBAAgB,KAAK,kBAAkB;AAAA,YACvC,QAAQ,KAAK;AAAA,YACb,cAAc,MAAM;AAAA,YACpB,YAAY,MAAM,cAAc;AAAA,YAChC;AAAA,YACA,eAAe,IAAI;AAAA,YACnB,gBAAgB,IAAI;AAAA,YACpB;AAAA,UACF,CAAC;AAAA,QACH,SAAS,OAAO;AACd,iBAAO,MAAM,sCAAsC,EAAE,SAAS,MAAM,IAAI,KAAK,MAAM,CAAC;AAAA,QACtF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { matchWildcardPattern } from "@open-mercato/shared/lib/patterns/wildcard";
|
|
2
|
+
import { createLogger } from "../logger/index.js";
|
|
3
|
+
const logger = createLogger("shared").child({ component: "crud" });
|
|
2
4
|
function matchesEventPattern(pattern, eventId) {
|
|
3
5
|
return matchWildcardPattern(eventId, pattern);
|
|
4
6
|
}
|
|
@@ -24,7 +26,7 @@ async function runSyncAfterEvent(subscribers, payload, ctx) {
|
|
|
24
26
|
try {
|
|
25
27
|
await subscriber.handler(payload, ctx);
|
|
26
28
|
} catch (error) {
|
|
27
|
-
|
|
29
|
+
logger.error("Sync-event after-subscriber failed", { subscriberId: subscriber.metadata.id, err: error });
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/sync-event-runner.ts"],
|
|
4
|
-
"sourcesContent": ["import type { SyncCrudEventPayload } from './sync-event-types'\nimport type { SyncSubscriberEntry } from './sync-subscriber-store'\nimport { matchWildcardPattern } from '@open-mercato/shared/lib/patterns/wildcard'\n\n// ---------------------------------------------------------------------------\n// Event pattern matching\n// ---------------------------------------------------------------------------\n\nexport function matchesEventPattern(pattern: string, eventId: string): boolean {\n return matchWildcardPattern(eventId, pattern)\n}\n\n// ---------------------------------------------------------------------------\n// Collect matching subscribers\n// ---------------------------------------------------------------------------\n\nexport function collectSyncSubscribers(\n allSyncSubscribers: SyncSubscriberEntry[],\n eventId: string,\n): SyncSubscriberEntry[] {\n return allSyncSubscribers\n .filter((s) => matchesEventPattern(s.metadata.event, eventId))\n .sort((a, b) => (a.metadata.priority ?? 50) - (b.metadata.priority ?? 50))\n}\n\n// ---------------------------------------------------------------------------\n// Run sync before-event subscribers\n// ---------------------------------------------------------------------------\n\nexport async function runSyncBeforeEvent(\n subscribers: SyncSubscriberEntry[],\n payload: SyncCrudEventPayload,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<{ ok: boolean; errorBody?: Record<string, unknown>; errorStatus?: number; modifiedPayload?: Record<string, unknown> }> {\n let currentPayload = payload.payload\n\n for (const subscriber of subscribers) {\n const result = await subscriber.handler({ ...payload, payload: currentPayload }, ctx)\n\n if (result?.ok === false) {\n const body = result.body ?? { error: result.message ?? 'Operation blocked', subscriberId: subscriber.metadata.id }\n return { ok: false, errorBody: body, errorStatus: result.status ?? 422 }\n }\n\n if (result?.modifiedPayload) {\n currentPayload = { ...currentPayload, ...result.modifiedPayload }\n }\n }\n\n return { ok: true, modifiedPayload: currentPayload !== payload.payload ? currentPayload : undefined }\n}\n\n// ---------------------------------------------------------------------------\n// Run sync after-event subscribers\n// ---------------------------------------------------------------------------\n\nexport async function runSyncAfterEvent(\n subscribers: SyncSubscriberEntry[],\n payload: SyncCrudEventPayload,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<void> {\n for (const subscriber of subscribers) {\n try {\n await subscriber.handler(payload, ctx)\n } catch (error) {\n
|
|
5
|
-
"mappings": "AAEA,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import type { SyncCrudEventPayload } from './sync-event-types'\nimport type { SyncSubscriberEntry } from './sync-subscriber-store'\nimport { matchWildcardPattern } from '@open-mercato/shared/lib/patterns/wildcard'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'crud' })\n\n// ---------------------------------------------------------------------------\n// Event pattern matching\n// ---------------------------------------------------------------------------\n\nexport function matchesEventPattern(pattern: string, eventId: string): boolean {\n return matchWildcardPattern(eventId, pattern)\n}\n\n// ---------------------------------------------------------------------------\n// Collect matching subscribers\n// ---------------------------------------------------------------------------\n\nexport function collectSyncSubscribers(\n allSyncSubscribers: SyncSubscriberEntry[],\n eventId: string,\n): SyncSubscriberEntry[] {\n return allSyncSubscribers\n .filter((s) => matchesEventPattern(s.metadata.event, eventId))\n .sort((a, b) => (a.metadata.priority ?? 50) - (b.metadata.priority ?? 50))\n}\n\n// ---------------------------------------------------------------------------\n// Run sync before-event subscribers\n// ---------------------------------------------------------------------------\n\nexport async function runSyncBeforeEvent(\n subscribers: SyncSubscriberEntry[],\n payload: SyncCrudEventPayload,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<{ ok: boolean; errorBody?: Record<string, unknown>; errorStatus?: number; modifiedPayload?: Record<string, unknown> }> {\n let currentPayload = payload.payload\n\n for (const subscriber of subscribers) {\n const result = await subscriber.handler({ ...payload, payload: currentPayload }, ctx)\n\n if (result?.ok === false) {\n const body = result.body ?? { error: result.message ?? 'Operation blocked', subscriberId: subscriber.metadata.id }\n return { ok: false, errorBody: body, errorStatus: result.status ?? 422 }\n }\n\n if (result?.modifiedPayload) {\n currentPayload = { ...currentPayload, ...result.modifiedPayload }\n }\n }\n\n return { ok: true, modifiedPayload: currentPayload !== payload.payload ? currentPayload : undefined }\n}\n\n// ---------------------------------------------------------------------------\n// Run sync after-event subscribers\n// ---------------------------------------------------------------------------\n\nexport async function runSyncAfterEvent(\n subscribers: SyncSubscriberEntry[],\n payload: SyncCrudEventPayload,\n ctx: { resolve: <T = unknown>(name: string) => T },\n): Promise<void> {\n for (const subscriber of subscribers) {\n try {\n await subscriber.handler(payload, ctx)\n } catch (error) {\n logger.error('Sync-event after-subscriber failed', { subscriberId: subscriber.metadata.id, err: error })\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,4BAA4B;AACrC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,OAAO,CAAC;AAM1D,SAAS,oBAAoB,SAAiB,SAA0B;AAC7E,SAAO,qBAAqB,SAAS,OAAO;AAC9C;AAMO,SAAS,uBACd,oBACA,SACuB;AACvB,SAAO,mBACJ,OAAO,CAAC,MAAM,oBAAoB,EAAE,SAAS,OAAO,OAAO,CAAC,EAC5D,KAAK,CAAC,GAAG,OAAO,EAAE,SAAS,YAAY,OAAO,EAAE,SAAS,YAAY,GAAG;AAC7E;AAMA,eAAsB,mBACpB,aACA,SACA,KACgI;AAChI,MAAI,iBAAiB,QAAQ;AAE7B,aAAW,cAAc,aAAa;AACpC,UAAM,SAAS,MAAM,WAAW,QAAQ,EAAE,GAAG,SAAS,SAAS,eAAe,GAAG,GAAG;AAEpF,QAAI,QAAQ,OAAO,OAAO;AACxB,YAAM,OAAO,OAAO,QAAQ,EAAE,OAAO,OAAO,WAAW,qBAAqB,cAAc,WAAW,SAAS,GAAG;AACjH,aAAO,EAAE,IAAI,OAAO,WAAW,MAAM,aAAa,OAAO,UAAU,IAAI;AAAA,IACzE;AAEA,QAAI,QAAQ,iBAAiB;AAC3B,uBAAiB,EAAE,GAAG,gBAAgB,GAAG,OAAO,gBAAgB;AAAA,IAClE;AAAA,EACF;AAEA,SAAO,EAAE,IAAI,MAAM,iBAAiB,mBAAmB,QAAQ,UAAU,iBAAiB,OAAU;AACtG;AAMA,eAAsB,kBACpB,aACA,SACA,KACe;AACf,aAAW,cAAc,aAAa;AACpC,QAAI;AACF,YAAM,WAAW,QAAQ,SAAS,GAAG;AAAA,IACvC,SAAS,OAAO;AACd,aAAO,MAAM,sCAAsC,EAAE,cAAc,WAAW,SAAS,IAAI,KAAK,MAAM,CAAC;AAAA,IACzG;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/data/engine.js
CHANGED
|
@@ -8,14 +8,14 @@ import { getEntityIds } from "../encryption/entityIds.js";
|
|
|
8
8
|
import { normalizeCustomFieldValues } from "../custom-fields/normalize.js";
|
|
9
9
|
import { parseBooleanToken } from "../boolean.js";
|
|
10
10
|
import { isEventDeclared } from "../../modules/events/index.js";
|
|
11
|
+
import { createLogger } from "../logger/index.js";
|
|
12
|
+
const logger = createLogger("shared").child({ component: "data-engine" });
|
|
11
13
|
const undeclaredEventWarned = /* @__PURE__ */ new Set();
|
|
12
14
|
function warnIfUndeclaredEvent(eventName, context) {
|
|
13
15
|
if (isEventDeclared(eventName)) return;
|
|
14
16
|
if (undeclaredEventWarned.has(eventName)) return;
|
|
15
17
|
undeclaredEventWarned.add(eventName);
|
|
16
|
-
|
|
17
|
-
`[data-engine] ${context} is emitting undeclared event "${eventName}". Declare it in the owning module's events.ts (createModuleEvents) so the event registry stays authoritative.`
|
|
18
|
-
);
|
|
18
|
+
logger.warn("Emitting undeclared event \u2014 declare it in the owning module events.ts (createModuleEvents) so the event registry stays authoritative", { context, eventName });
|
|
19
19
|
}
|
|
20
20
|
function __resetUndeclaredEventWarningsForTests() {
|
|
21
21
|
undeclaredEventWarned.clear();
|
|
@@ -411,7 +411,7 @@ class DefaultDataEngine {
|
|
|
411
411
|
if (coverageBaseDelta !== void 0) enrichedPayload.coverageBaseDelta = coverageBaseDelta;
|
|
412
412
|
if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin;
|
|
413
413
|
await bus.emitEvent("query_index.delete_one", enrichedPayload).catch((err) => {
|
|
414
|
-
|
|
414
|
+
logger.error("query_index.delete_one emit failed", { err });
|
|
415
415
|
});
|
|
416
416
|
} else {
|
|
417
417
|
const payload = indexer.buildUpsertPayload ? indexer.buildUpsertPayload(ctx) : {
|
|
@@ -425,7 +425,7 @@ class DefaultDataEngine {
|
|
|
425
425
|
if (coverageBaseDelta !== void 0) enrichedPayload.coverageBaseDelta = coverageBaseDelta;
|
|
426
426
|
if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin;
|
|
427
427
|
await bus.emitEvent("query_index.upsert_one", enrichedPayload).catch((err) => {
|
|
428
|
-
|
|
428
|
+
logger.error("query_index.upsert_one emit failed", { err });
|
|
429
429
|
});
|
|
430
430
|
}
|
|
431
431
|
if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
|