@open-mercato/shared 0.6.7-develop.6768.1.9d2c4efc43 → 0.6.7-develop.6770.1.b52298cf20
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/dist/lib/crud/factory.js +46 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/di/container.js +3 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +189 -0
- package/src/lib/crud/factory.ts +52 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +8 -3
package/dist/lib/di/container.js
CHANGED
|
@@ -151,10 +151,11 @@ async function createRequestContainer() {
|
|
|
151
151
|
() => createCommandOptimisticLockGuardService()
|
|
152
152
|
).scoped()
|
|
153
153
|
});
|
|
154
|
-
for (const reg of diRegistrars) {
|
|
154
|
+
for (const [registrarIndex, reg] of diRegistrars.entries()) {
|
|
155
155
|
try {
|
|
156
156
|
reg?.(container);
|
|
157
|
-
} catch {
|
|
157
|
+
} catch (error) {
|
|
158
|
+
logger.error("Module DI registrar failed", { registrarIndex, err: error });
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/di/container.ts"],
|
|
4
|
-
"sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\nimport { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'di' })\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\nexport type AppDiRegistrar = (container: AppContainer) => void | Promise<void>\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\nconst APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\nfunction getAppDiRegistrar(): AppDiRegistrar | null {\n const registrar = (globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY]\n return typeof registrar === 'function' ? registrar as AppDiRegistrar : null\n}\n\nexport function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {\n ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction((em: EntityManager) =>\n createOptimisticLockGuardService({\n getEm: () => em,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n // Default OSS command-level optimistic-lock guard, awaited by\n // `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.\n // Header/explicit-token compare only (no `resolveExpected`), so it is\n // behaviourally identical to calling `enforceCommandOptimisticLock`\n // directly. The enterprise `record_locks` module overrides this DI key\n // with a lock-backed `resolveExpected` via Awilix replace semantics.\n // Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)\n commandOptimisticLockGuardService: asFunction(() =>\n createCommandOptimisticLockGuardService(),\n ).scoped(),\n })\n // Allow modules to override/extend\n for (const reg of diRegistrars) {\n try { reg?.(container) } catch {}\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override. Scaffolded apps register this callback explicitly\n // from their own bootstrap module so app aliases resolve in app context.\n const appDiRegistrar = getAppDiRegistrar()\n if (appDiRegistrar) {\n try {\n await appDiRegistrar(container)\n } catch (error) {\n logger.error('App-level DI registrar failed', { err: error })\n }\n } else {\n // Backward-compatible fallback for apps that have not adopted explicit wiring.\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch (error) {\n logger.error('App-level DI registrar failed', { err: error })\n }\n }\n } catch {}\n }\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { asFunction, createContainer, asValue, AwilixContainer, InjectionMode, type Resolver } from 'awilix'\nimport { RequestContext } from '@mikro-orm/core'\nimport { getOrm } from '@open-mercato/shared/lib/db/mikro'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'\nimport { DefaultDataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { commandRegistry, CommandBus } from '@open-mercato/shared/lib/commands'\nimport { applyDiOverridesToContainer } from '@open-mercato/shared/modules/overrides'\nimport { createOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock'\nimport { getAllOptimisticLockReaders } from '@open-mercato/shared/lib/crud/optimistic-lock-store'\nimport { createCommandOptimisticLockGuardService } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'di' })\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => void\nexport type AppDiRegistrar = (container: AppContainer) => void | Promise<void>\n\n// Registration pattern for publishable packages\n// Use globalThis to survive tsx/esbuild module duplication issue where the same\n// file can be loaded as multiple module instances when mixing dynamic and static imports\nconst GLOBAL_KEY = '__openMercatoDiRegistrars__'\nconst APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'\n// Phase 5 \u2014 process-scoped bootstrap cache. The cache/event-bus/encryption\n// services bootstrap() creates are inherently process-scoped (they hold\n// state across requests). Caching them on globalThis after the first\n// successful bootstrap call lets every subsequent request skip the\n// `await bootstrap(container)` body and just re-register the cached\n// instances. Same globalThis pattern as registerDiRegistrars so HMR\n// keeps working.\nconst BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'\nconst ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'\n\nconst BOOTSTRAP_CACHE_KEYS = [\n 'cache',\n 'eventBus',\n 'kmsService',\n 'tenantEncryptionService',\n 'rateLimiterService',\n 'searchModuleConfigs',\n 'searchIndexer',\n] as const\n\ntype BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>\n\n// Phase 5 is opt-in. Some bootstrap services close over per-request state\n// (e.g. tenantEncryptionService captures the first request's `em.fork`, the\n// event-bus's resolver closes over the first container) so naively replaying\n// them on later requests yields stale references \u2014 observed as a 500 from\n// CRUD list endpoints in `next start`. Default OFF preserves develop's\n// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each\n// cached service is verified safe for cross-request reuse.\nfunction isBootstrapCacheEnabled(): boolean {\n const raw = process.env.OM_BOOTSTRAP_CACHE\n if (raw === undefined) return false\n const normalized = raw.trim().toLowerCase()\n if (!normalized.length) return false\n if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false\n return true\n}\n\nfunction getBootstrapCache(): BootstrapCacheEntry | null {\n if (!isBootstrapCacheEnabled()) return null\n const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]\n return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null\n}\n\nfunction setBootstrapCache(entry: BootstrapCacheEntry): void {\n if (!isBootstrapCacheEnabled()) return\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry\n}\n\nfunction harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {\n const entry: BootstrapCacheEntry = {}\n for (const key of BOOTSTRAP_CACHE_KEYS) {\n try {\n const value: unknown = container.resolve(key as never)\n if (value !== undefined && value !== null) entry[key] = value\n } catch {\n // not registered \u2014 skip\n }\n }\n return entry\n}\n\ntype EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined\n\nfunction getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {\n if (!service || typeof service.isEnabled !== 'function') return false\n const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]\n if (typeof cached === 'boolean') return cached\n try {\n const result = !!service.isEnabled()\n ;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result\n return result\n } catch {\n return null\n }\n}\n\nfunction getGlobalRegistrars(): DiRegistrar[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalRegistrars(registrars: DiRegistrar[]): void {\n (globalThis as any)[GLOBAL_KEY] = registrars\n}\n\nexport function registerDiRegistrars(registrars: DiRegistrar[]) {\n const existing = getGlobalRegistrars()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\n // Force re-bootstrap on HMR \u2014 module subscribers may have changed.\n ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nexport function getDiRegistrars(): DiRegistrar[] {\n const registrars = getGlobalRegistrars()\n if (!registrars) {\n throw new Error('[Bootstrap] DI registrars not registered. Call registerDiRegistrars() at bootstrap.')\n }\n return registrars\n}\n\nfunction getAppDiRegistrar(): AppDiRegistrar | null {\n const registrar = (globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY]\n return typeof registrar === 'function' ? registrar as AppDiRegistrar : null\n}\n\nexport function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {\n ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar\n}\n\n/** Test-only helper to drop the process-scoped bootstrap cache. */\nexport function resetBootstrapCache(): void {\n (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null\n ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined\n}\n\nfunction isAwilixResolver(value: unknown): value is Resolver<unknown> {\n return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')\n}\n\nfunction toAwilixRegistrations(registrations: Record<string, unknown>): Record<string, Resolver<any>> {\n return Object.fromEntries(\n Object.entries(registrations).map(([key, value]) => [\n key,\n isAwilixResolver(value) ? value : asValue(value),\n ]),\n )\n}\n\nexport async function createRequestContainer(): Promise<AppContainer> {\n const diRegistrars = getDiRegistrars()\n const orm = await getOrm()\n // Use a fresh event manager so request-level subscribers (e.g., encryption) don't pile up globally\n const baseEm = (RequestContext.getEntityManager() as any) ?? orm.em\n const em = baseEm.fork({ clear: true, freshEventManager: true, useContext: true }) as unknown as EntityManager\n const container = createContainer<DynamicCradle>({ injectionMode: InjectionMode.CLASSIC })\n // Core registrations\n container.register({\n em: asValue(em),\n queryEngine: asValue(new BasicQueryEngine(em, undefined, () => {\n try { return container.resolve('tenantEncryptionService') as any } catch { return null }\n })),\n dataEngine: asValue(new DefaultDataEngine(em, container as any)),\n commandRegistry: asValue(commandRegistry),\n commandBus: asValue(new CommandBus()),\n // Default OSS optimistic-lock guard. Reads from the global reader store\n // (populated by `makeCrudRoute` auto-registration + any module-DI\n // hand-wired calls to `registerOptimisticLockReaders`). Service is\n // strictly additive: when `OM_OPTIMISTIC_LOCK=off` (or no header is\n // sent) it short-circuits at validateMutation. Module-level di.ts\n // registrations override this default via Awilix replace semantics \u2014\n // see the enterprise `record_locks` module for the canonical override.\n // Spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md\n crudMutationGuardService: asFunction((em: EntityManager) =>\n createOptimisticLockGuardService({\n getEm: () => em,\n readers: getAllOptimisticLockReaders(),\n }),\n ).scoped(),\n // Default OSS command-level optimistic-lock guard, awaited by\n // `enforceCommandOptimisticLockWithGuards` for Command-pattern writes.\n // Header/explicit-token compare only (no `resolveExpected`), so it is\n // behaviourally identical to calling `enforceCommandOptimisticLock`\n // directly. The enterprise `record_locks` module overrides this DI key\n // with a lock-backed `resolveExpected` via Awilix replace semantics.\n // Spec: .ai/specs/enterprise/2026-06-09-record-locks-unified-coverage.md (Phase 0)\n commandOptimisticLockGuardService: asFunction(() =>\n createCommandOptimisticLockGuardService(),\n ).scoped(),\n })\n // Allow modules to override/extend. Fail-open by design (one broken module's\n // di.ts must not take down every request container), but never silently: the\n // module's services would otherwise vanish with no trace until an unrelated\n // Awilix resolution error surfaces much later.\n for (const [registrarIndex, reg] of diRegistrars.entries()) {\n try { reg?.(container) } catch (error) {\n logger.error('Module DI registrar failed', { registrarIndex, err: error })\n }\n }\n // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)\n // Phase 5 \u2014 process-scoped once-guard. The first request runs the full\n // bootstrap() body; later requests re-register the cached services\n // directly on this request's container without re-importing or\n // re-initializing anything. HMR clears the cache (see\n // registerDiRegistrars). Skippable if a caller already wired eventBus.\n const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus\n if (!alreadyBootstrappedOnThisContainer) {\n const cached = getBootstrapCache()\n if (cached) {\n const replay: Record<string, any> = {}\n for (const [key, value] of Object.entries(cached)) {\n if (value !== undefined && value !== null) replay[key] = asValue(value)\n }\n if (Object.keys(replay).length > 0) container.register(replay)\n } else {\n try {\n const { bootstrap } = await import('@open-mercato/core/bootstrap') as any\n if (bootstrap && typeof bootstrap === 'function') {\n await bootstrap(container)\n setBootstrapCache(harvestBootstrapCache(container))\n }\n } catch { /* optional */ }\n }\n }\n // App-level DI override. Scaffolded apps register this callback explicitly\n // from their own bootstrap module so app aliases resolve in app context.\n const appDiRegistrar = getAppDiRegistrar()\n if (appDiRegistrar) {\n try {\n await appDiRegistrar(container)\n } catch (error) {\n logger.error('App-level DI registrar failed', { err: error })\n }\n } else {\n // Backward-compatible fallback for apps that have not adopted explicit wiring.\n try {\n // @ts-ignore - @/di only exists in app context, not in packages\n const appDi = await import('@/di') as any\n if (appDi?.register) {\n try {\n const maybe = appDi.register(container)\n if (maybe && typeof maybe.then === 'function') await maybe\n } catch (error) {\n logger.error('App-level DI registrar failed', { err: error })\n }\n }\n } catch {}\n }\n applyDiOverridesToContainer({\n register: (registrations) => container.register(toAwilixRegistrations(registrations)),\n unregister: (key) => container.register({ [key]: asValue(undefined) }),\n })\n // Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM\n // Phase 5 \u2014 cache `tenantEncryptionService.isEnabled()` for the process\n // lifetime. The result depends only on config that does not change at\n // runtime, so reading it once skips a config lookup per request.\n try {\n const emForEnc = container.resolve('em') as any\n const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')\n ? (container.resolve('tenantEncryptionService') as any)\n : null\n if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {\n const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')\n registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)\n }\n } catch {\n // best-effort; do not block container creation\n }\n return container\n}\ntry {\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n require('server-only')\n} catch {\n // allow CLI/generator usage where Next server-only is not present\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["em"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6770.1.b52298cf20'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6770.1.b52298cf20",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"@mikro-orm/core": "^7.1.5",
|
|
102
102
|
"@mikro-orm/decorators": "^7.1.5",
|
|
103
103
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
104
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
104
|
+
"@open-mercato/cache": "0.6.7-develop.6770.1.b52298cf20",
|
|
105
105
|
"@types/sanitize-html": "^2.16.1",
|
|
106
106
|
"dotenv": "^17.4.2",
|
|
107
107
|
"pino": "^10.3.1",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Regression coverage for the CRUD list cache key missing a caller-identity
|
|
2
|
+
// segment: two callers in the same tenant/org scope requesting the identical
|
|
3
|
+
// URL must NOT share cache entries, because list payloads can vary per caller —
|
|
4
|
+
// buildFilters may narrow by ctx.auth (e.g. ?mine=true on staff time-projects,
|
|
5
|
+
// the isSuperAdmin branch on scheduler jobs), before-interceptor query rewrites
|
|
6
|
+
// are feature-gated per user, and afterList/after-interceptor output is
|
|
7
|
+
// embedded in the stored payload.
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/cache', () => ({
|
|
10
|
+
runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
|
|
11
|
+
}), { virtual: true })
|
|
12
|
+
|
|
13
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
14
|
+
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
|
|
17
|
+
const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
18
|
+
const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
19
|
+
|
|
20
|
+
let mockAuthSub = 'user-a'
|
|
21
|
+
let mockAuthKeyId: string | null = null
|
|
22
|
+
|
|
23
|
+
const em = {}
|
|
24
|
+
|
|
25
|
+
// Returns rows derived from the per-caller filter buildFilters produced, so a
|
|
26
|
+
// cross-user cache hit is observable as one user's rows in another's response.
|
|
27
|
+
const queryEngine = {
|
|
28
|
+
query: jest.fn(async (_entity: string, opts: Record<string, any>) => {
|
|
29
|
+
const owner = opts?.filters?.owner_user_id?.$eq ?? 'unfiltered'
|
|
30
|
+
return {
|
|
31
|
+
items: [{ id: `todo-of-${owner}`, title: `Owned by ${owner}`, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
|
|
32
|
+
total: 1,
|
|
33
|
+
}
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const store = new Map<string, unknown>()
|
|
38
|
+
const cache = {
|
|
39
|
+
get: jest.fn(async (key: string) => (store.has(key) ? store.get(key) : null)),
|
|
40
|
+
set: jest.fn(async (key: string, value: unknown) => { store.set(key, value) }),
|
|
41
|
+
delete: jest.fn(async (key: string) => { store.delete(key) }),
|
|
42
|
+
deleteByTags: jest.fn(async () => 0),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const accessLogService = { log: jest.fn(async () => {}) }
|
|
46
|
+
|
|
47
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
48
|
+
createRequestContainer: async () => ({
|
|
49
|
+
resolve: (name: string) => ({
|
|
50
|
+
em,
|
|
51
|
+
queryEngine,
|
|
52
|
+
cache,
|
|
53
|
+
accessLogService,
|
|
54
|
+
} as any)[name],
|
|
55
|
+
}),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => {
|
|
59
|
+
const buildAuth = () => ({
|
|
60
|
+
sub: mockAuthSub,
|
|
61
|
+
keyId: mockAuthKeyId ?? undefined,
|
|
62
|
+
orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
63
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
64
|
+
roles: ['admin'],
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
getAuthFromCookies: async () => buildAuth(),
|
|
68
|
+
getAuthFromRequest: async () => buildAuth(),
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
|
|
73
|
+
resolveOrganizationScopeForRequest: jest.fn(async () => ({
|
|
74
|
+
selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
|
75
|
+
filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
76
|
+
allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
|
|
77
|
+
tenantId: '123e4567-e89b-12d3-a456-426614174000',
|
|
78
|
+
})),
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
|
|
82
|
+
setRecordCustomFields: jest.fn(async () => {}),
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
class Todo {}
|
|
86
|
+
|
|
87
|
+
const querySchema = z.object({
|
|
88
|
+
page: z.coerce.number().default(1),
|
|
89
|
+
pageSize: z.coerce.number().default(50),
|
|
90
|
+
sortField: z.string().default('id'),
|
|
91
|
+
sortDir: z.enum(['asc', 'desc']).default('asc'),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const route = makeCrudRoute({
|
|
95
|
+
metadata: { GET: { requireAuth: true } },
|
|
96
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
97
|
+
indexer: { entityType: 'example.todo' },
|
|
98
|
+
list: {
|
|
99
|
+
schema: querySchema,
|
|
100
|
+
entityId: 'example.todo',
|
|
101
|
+
fields: ['id', 'title'],
|
|
102
|
+
sortFieldMap: { id: 'id', title: 'title' },
|
|
103
|
+
buildFilters: (_query: any, ctx: any) => ({ owner_user_id: { $eq: ctx.auth?.sub ?? null } } as any),
|
|
104
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title }),
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
|
|
109
|
+
|
|
110
|
+
describe('CRUD Factory — list cache is partitioned per caller identity', () => {
|
|
111
|
+
const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
|
|
112
|
+
|
|
113
|
+
beforeAll(() => {
|
|
114
|
+
process.env.ENABLE_CRUD_API_CACHE = 'true'
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
|
|
119
|
+
else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.clearAllMocks()
|
|
124
|
+
store.clear()
|
|
125
|
+
mockAuthSub = 'user-a'
|
|
126
|
+
mockAuthKeyId = null
|
|
127
|
+
registerApiInterceptors([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('caches per user and still serves same-user repeat requests from cache', async () => {
|
|
131
|
+
const first = await route.GET(new Request(url))
|
|
132
|
+
expect(first.status).toBe(200)
|
|
133
|
+
expect(first.headers.get('x-om-cache')).toBe('miss')
|
|
134
|
+
expect((await first.json()).items[0].id).toBe('todo-of-user-a')
|
|
135
|
+
|
|
136
|
+
const second = await route.GET(new Request(url))
|
|
137
|
+
expect(second.headers.get('x-om-cache')).toBe('hit')
|
|
138
|
+
expect((await second.json()).items[0].id).toBe('todo-of-user-a')
|
|
139
|
+
expect(queryEngine.query).toHaveBeenCalledTimes(1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not serve one user\'s cached rows to another user on the identical URL', async () => {
|
|
143
|
+
mockAuthSub = 'user-a'
|
|
144
|
+
const aRes = await route.GET(new Request(url))
|
|
145
|
+
expect((await aRes.json()).items[0].id).toBe('todo-of-user-a')
|
|
146
|
+
expect(store.size).toBe(1)
|
|
147
|
+
|
|
148
|
+
mockAuthSub = 'user-b'
|
|
149
|
+
const bRes = await route.GET(new Request(url))
|
|
150
|
+
expect(bRes.headers.get('x-om-cache')).toBe('miss')
|
|
151
|
+
expect((await bRes.json()).items[0].id).toBe('todo-of-user-b')
|
|
152
|
+
expect(store.size).toBe(2)
|
|
153
|
+
|
|
154
|
+
mockAuthSub = 'user-a'
|
|
155
|
+
const aAgain = await route.GET(new Request(url))
|
|
156
|
+
expect(aAgain.headers.get('x-om-cache')).toBe('hit')
|
|
157
|
+
expect((await aAgain.json()).items[0].id).toBe('todo-of-user-a')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('keys every entry with an identity segment, preferring the API key id over the user id', async () => {
|
|
161
|
+
await route.GET(new Request(url))
|
|
162
|
+
const userKey = Array.from(store.keys())[0] as string
|
|
163
|
+
expect(userKey).toContain('user:user-a')
|
|
164
|
+
|
|
165
|
+
store.clear()
|
|
166
|
+
mockAuthKeyId = 'api-key-9'
|
|
167
|
+
await route.GET(new Request(url))
|
|
168
|
+
const apiKeyKey = Array.from(store.keys())[0] as string
|
|
169
|
+
expect(apiKeyKey).toContain('user:api-key-9')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
registerOptimisticLockReaders,
|
|
11
11
|
} from '@open-mercato/shared/lib/crud/optimistic-lock-store'
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
|
+
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
13
14
|
import { z } from 'zod'
|
|
14
15
|
|
|
15
16
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -201,6 +202,7 @@ describe('CRUD Factory', () => {
|
|
|
201
202
|
}
|
|
202
203
|
crudMutationGuardService = null
|
|
203
204
|
registerApiInterceptors([])
|
|
205
|
+
registerMutationGuards([])
|
|
204
206
|
})
|
|
205
207
|
|
|
206
208
|
const querySchema = z.object({
|
|
@@ -721,6 +723,193 @@ describe('CRUD Factory', () => {
|
|
|
721
723
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
722
724
|
})
|
|
723
725
|
|
|
726
|
+
it('POST command route runs mutation guards before executing the command', async () => {
|
|
727
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
|
|
728
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
729
|
+
id: 'example.block-command-create',
|
|
730
|
+
targetEntity: 'example.todo',
|
|
731
|
+
operations: ['create'],
|
|
732
|
+
validate: guardValidate,
|
|
733
|
+
}] }])
|
|
734
|
+
const commandRoute = makeCrudRoute({
|
|
735
|
+
metadata: { POST: { requireAuth: true } },
|
|
736
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
737
|
+
indexer: { entityType: 'example.todo' },
|
|
738
|
+
actions: {
|
|
739
|
+
create: {
|
|
740
|
+
commandId: 'example.todo.create',
|
|
741
|
+
schema: createSchema,
|
|
742
|
+
response: () => ({ ok: true }),
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
748
|
+
method: 'POST',
|
|
749
|
+
body: JSON.stringify({ title: 'A' }),
|
|
750
|
+
headers: { 'content-type': 'application/json' },
|
|
751
|
+
}))
|
|
752
|
+
|
|
753
|
+
expect(res.status).toBe(403)
|
|
754
|
+
expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
|
|
755
|
+
resourceKind: 'example.todo',
|
|
756
|
+
resourceId: null,
|
|
757
|
+
operation: 'create',
|
|
758
|
+
mutationPayload: expect.objectContaining({ title: 'A' }),
|
|
759
|
+
}))
|
|
760
|
+
expect(commandBus.execute).not.toHaveBeenCalled()
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
|
|
764
|
+
commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
|
|
765
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
766
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
767
|
+
id: 'example.rewrite-command-create',
|
|
768
|
+
targetEntity: 'example.todo',
|
|
769
|
+
operations: ['create'],
|
|
770
|
+
validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
|
|
771
|
+
afterSuccess: guardAfterSuccess,
|
|
772
|
+
}] }])
|
|
773
|
+
const commandRoute = makeCrudRoute({
|
|
774
|
+
metadata: { POST: { requireAuth: true } },
|
|
775
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
776
|
+
indexer: { entityType: 'example.todo' },
|
|
777
|
+
actions: {
|
|
778
|
+
create: {
|
|
779
|
+
commandId: 'example.todo.create',
|
|
780
|
+
schema: createSchema,
|
|
781
|
+
response: () => ({ ok: true }),
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
787
|
+
method: 'POST',
|
|
788
|
+
body: JSON.stringify({ title: 'A' }),
|
|
789
|
+
headers: { 'content-type': 'application/json' },
|
|
790
|
+
}))
|
|
791
|
+
|
|
792
|
+
expect(res.status).toBe(201)
|
|
793
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
|
|
794
|
+
input: expect.objectContaining({ title: 'FROM-GUARD' }),
|
|
795
|
+
}))
|
|
796
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
797
|
+
resourceId: 'cmd-created-1',
|
|
798
|
+
operation: 'create',
|
|
799
|
+
}))
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
|
|
803
|
+
commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
|
|
804
|
+
const guardAfterSuccess = jest.fn(async () => {})
|
|
805
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
806
|
+
id: 'example.after-command-create',
|
|
807
|
+
targetEntity: 'example.todo',
|
|
808
|
+
operations: ['create'],
|
|
809
|
+
validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
|
|
810
|
+
afterSuccess: guardAfterSuccess,
|
|
811
|
+
}] }])
|
|
812
|
+
const commandRoute = makeCrudRoute({
|
|
813
|
+
metadata: { POST: { requireAuth: true } },
|
|
814
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
815
|
+
indexer: { entityType: 'example.todo' },
|
|
816
|
+
actions: {
|
|
817
|
+
create: {
|
|
818
|
+
commandId: 'example.todo.create',
|
|
819
|
+
schema: createSchema,
|
|
820
|
+
response: ({ result }: any) => ({ id: result.lineId }),
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
body: JSON.stringify({ title: 'A' }),
|
|
828
|
+
headers: { 'content-type': 'application/json' },
|
|
829
|
+
}))
|
|
830
|
+
|
|
831
|
+
expect(res.status).toBe(201)
|
|
832
|
+
expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
|
833
|
+
resourceId: 'line-42',
|
|
834
|
+
operation: 'create',
|
|
835
|
+
}))
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
|
|
839
|
+
// candidateId and thereby OPT OUT of row-level mutation guards, leaving the
|
|
840
|
+
// command-level optimistic-lock check as the sole guard — a documented contract
|
|
841
|
+
// (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
|
|
842
|
+
// line/adjustment routes rely on.
|
|
843
|
+
it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
|
|
844
|
+
crudMutationGuardService = {
|
|
845
|
+
validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
|
|
846
|
+
afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
|
|
847
|
+
}
|
|
848
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
|
|
849
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
850
|
+
id: 'example.idless-update-opt-out',
|
|
851
|
+
targetEntity: 'example.todo',
|
|
852
|
+
operations: ['update'],
|
|
853
|
+
validate: guardValidate,
|
|
854
|
+
}] }])
|
|
855
|
+
const commandRoute = makeCrudRoute({
|
|
856
|
+
metadata: { PUT: { requireAuth: true } },
|
|
857
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
858
|
+
indexer: { entityType: 'example.todo' },
|
|
859
|
+
actions: {
|
|
860
|
+
update: {
|
|
861
|
+
commandId: 'example.todo.update',
|
|
862
|
+
schema: z.object({ title: z.string() }),
|
|
863
|
+
mapInput: ({ parsed }: any) => ({ body: parsed }),
|
|
864
|
+
response: () => ({ ok: true }),
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
|
|
870
|
+
method: 'PUT',
|
|
871
|
+
body: JSON.stringify({ title: 'nested id shape' }),
|
|
872
|
+
headers: { 'content-type': 'application/json' },
|
|
873
|
+
}))
|
|
874
|
+
|
|
875
|
+
expect(res.status).toBe(200)
|
|
876
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
877
|
+
expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
|
|
878
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
|
|
882
|
+
const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
|
|
883
|
+
registerMutationGuards([{ moduleId: 'example', guards: [{
|
|
884
|
+
id: 'example.idless-delete-opt-out',
|
|
885
|
+
targetEntity: 'example.todo',
|
|
886
|
+
operations: ['delete'],
|
|
887
|
+
validate: guardValidate,
|
|
888
|
+
}] }])
|
|
889
|
+
const commandRoute = makeCrudRoute({
|
|
890
|
+
metadata: { DELETE: { requireAuth: true } },
|
|
891
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
892
|
+
indexer: { entityType: 'example.todo' },
|
|
893
|
+
actions: {
|
|
894
|
+
delete: {
|
|
895
|
+
commandId: 'example.todo.delete',
|
|
896
|
+
schema: z.any(),
|
|
897
|
+
response: () => ({ ok: true }),
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
|
|
903
|
+
method: 'DELETE',
|
|
904
|
+
body: JSON.stringify({}),
|
|
905
|
+
headers: { 'content-type': 'application/json' },
|
|
906
|
+
}))
|
|
907
|
+
|
|
908
|
+
expect(res.status).toBe(200)
|
|
909
|
+
expect(guardValidate).not.toHaveBeenCalled()
|
|
910
|
+
expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
|
|
911
|
+
})
|
|
912
|
+
|
|
724
913
|
it('POST is blocked by interceptor before hook', async () => {
|
|
725
914
|
registerApiInterceptors([
|
|
726
915
|
{
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
isCrudCacheEnabled,
|
|
58
58
|
normalizeIdentifierValue,
|
|
59
59
|
normalizeTagSegment,
|
|
60
|
+
pickFirstIdentifier,
|
|
60
61
|
resolveCrudCache,
|
|
61
62
|
} from './cache'
|
|
62
63
|
import { deriveCrudSegmentTag } from './cache-stats'
|
|
@@ -915,6 +916,12 @@ function buildCrudCacheKey(
|
|
|
915
916
|
`tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
|
|
916
917
|
`selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
|
|
917
918
|
`scope:${scopeSegment}`,
|
|
919
|
+
// List payloads can vary per caller identity beyond tenant/org scope:
|
|
920
|
+
// buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
|
|
921
|
+
// query rewrites are feature-gated per user, and afterList/after-interceptor
|
|
922
|
+
// output is embedded in the stored payload — so entries MUST be partitioned
|
|
923
|
+
// per actor (API key or user), never shared across identities.
|
|
924
|
+
`user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
|
|
918
925
|
`query:${serializeSearchParams(url.searchParams)}`,
|
|
919
926
|
]
|
|
920
927
|
// The cached list payload already embeds enricher output (enrichment runs before
|
|
@@ -2140,6 +2147,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2140
2147
|
}
|
|
2141
2148
|
}
|
|
2142
2149
|
|
|
2150
|
+
// Mutation guard registry — command path (mirrors the direct create branch)
|
|
2151
|
+
const createCmdUserFeatures = await resolveUserFeatures(ctx)
|
|
2152
|
+
const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
|
|
2153
|
+
let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2154
|
+
if (createCmdAllGuards.length && ctx.auth.tenantId) {
|
|
2155
|
+
const guardResult = await runMutationGuards(createCmdAllGuards, {
|
|
2156
|
+
tenantId: ctx.auth.tenantId,
|
|
2157
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2158
|
+
userId: ctx.auth.sub,
|
|
2159
|
+
resourceKind,
|
|
2160
|
+
resourceId: null,
|
|
2161
|
+
operation: 'create',
|
|
2162
|
+
requestMethod: request.method,
|
|
2163
|
+
requestHeaders: request.headers,
|
|
2164
|
+
mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
|
|
2165
|
+
}, { userFeatures: createCmdUserFeatures ?? [] })
|
|
2166
|
+
if (!guardResult.ok) {
|
|
2167
|
+
return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
|
|
2168
|
+
}
|
|
2169
|
+
if (guardResult.modifiedPayload && typeof input === 'object' && input) {
|
|
2170
|
+
input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
|
|
2171
|
+
}
|
|
2172
|
+
createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2143
2175
|
const baseMetadata: CommandLogMetadata = {
|
|
2144
2176
|
tenantId: ctx.auth?.tenantId ?? null,
|
|
2145
2177
|
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
@@ -2182,6 +2214,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2182
2214
|
const status = action.status ?? 201
|
|
2183
2215
|
const response = json(resolvedPayload, { status })
|
|
2184
2216
|
attachOperationHeader(response, logEntry)
|
|
2217
|
+
const commandResultId = pickFirstIdentifier(
|
|
2218
|
+
(result as Record<string, unknown> | null | undefined)?.id,
|
|
2219
|
+
(resolvedPayload as Record<string, unknown> | null | undefined)?.id,
|
|
2220
|
+
)
|
|
2221
|
+
if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
|
|
2222
|
+
await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
|
|
2223
|
+
tenantId: ctx.auth.tenantId,
|
|
2224
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
2225
|
+
userId: ctx.auth.sub,
|
|
2226
|
+
resourceKind,
|
|
2227
|
+
resourceId: commandResultId,
|
|
2228
|
+
operation: 'create',
|
|
2229
|
+
requestMethod: request.method,
|
|
2230
|
+
requestHeaders: request.headers,
|
|
2231
|
+
})
|
|
2232
|
+
}
|
|
2185
2233
|
// Note: side effects (events + indexing) are already flushed by CommandBus.execute()
|
|
2186
2234
|
// via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
|
|
2187
2235
|
// duplicate event emissions.
|
|
@@ -2422,6 +2470,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2422
2470
|
const updateUserFeatures = await resolveUserFeatures(ctx)
|
|
2423
2471
|
const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
|
|
2424
2472
|
let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
|
|
2473
|
+
// Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
|
|
2474
|
+
// null candidateId and OPT OUT of row-level guards, leaving the command-level
|
|
2475
|
+
// optimistic-lock check as the sole guard — a documented contract, see
|
|
2476
|
+
// apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
|
|
2425
2477
|
if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
|
|
2426
2478
|
const guardResult = await runMutationGuards(updateAllGuards, {
|
|
2427
2479
|
tenantId: ctx.auth.tenantId,
|