@open-mercato/shared 0.6.3-develop.3857.1.da89d7530c → 0.6.3-develop.3881.1.0b590ac4eb
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/custom-fields.js +113 -6
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js +91 -42
- package/dist/lib/crud/factory.js.map +3 -3
- package/dist/lib/di/container.js +77 -9
- 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__/custom-fields.cache.test.ts +142 -0
- package/src/lib/crud/__tests__/log-crud-access.test.ts +158 -0
- package/src/lib/crud/__tests__/user-features-memo.test.ts +106 -0
- package/src/lib/crud/custom-fields.ts +169 -13
- package/src/lib/crud/factory.ts +120 -44
- package/src/lib/di/__tests__/bootstrap-cache.test.ts +182 -0
- package/src/lib/di/container.ts +111 -9
package/dist/lib/di/container.js
CHANGED
|
@@ -6,6 +6,57 @@ import { DefaultDataEngine } from "@open-mercato/shared/lib/data/engine";
|
|
|
6
6
|
import { commandRegistry, CommandBus } from "@open-mercato/shared/lib/commands";
|
|
7
7
|
import { applyDiOverridesToContainer } from "@open-mercato/shared/modules/overrides";
|
|
8
8
|
const GLOBAL_KEY = "__openMercatoDiRegistrars__";
|
|
9
|
+
const BOOTSTRAP_CACHE_KEY = "__openMercatoBootstrapCache__";
|
|
10
|
+
const ENCRYPTION_ENABLED_KEY = "__openMercatoEncryptionEnabledCache__";
|
|
11
|
+
const BOOTSTRAP_CACHE_KEYS = [
|
|
12
|
+
"cache",
|
|
13
|
+
"eventBus",
|
|
14
|
+
"kmsService",
|
|
15
|
+
"tenantEncryptionService",
|
|
16
|
+
"rateLimiterService",
|
|
17
|
+
"searchModuleConfigs",
|
|
18
|
+
"searchIndexer"
|
|
19
|
+
];
|
|
20
|
+
function isBootstrapCacheEnabled() {
|
|
21
|
+
const raw = process.env.OM_BOOTSTRAP_CACHE;
|
|
22
|
+
if (raw === void 0) return false;
|
|
23
|
+
const normalized = raw.trim().toLowerCase();
|
|
24
|
+
if (!normalized.length) return false;
|
|
25
|
+
if (normalized === "0" || normalized === "off" || normalized === "false" || normalized === "no") return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function getBootstrapCache() {
|
|
29
|
+
if (!isBootstrapCacheEnabled()) return null;
|
|
30
|
+
const existing = globalThis[BOOTSTRAP_CACHE_KEY];
|
|
31
|
+
return existing && typeof existing === "object" ? existing : null;
|
|
32
|
+
}
|
|
33
|
+
function setBootstrapCache(entry) {
|
|
34
|
+
if (!isBootstrapCacheEnabled()) return;
|
|
35
|
+
globalThis[BOOTSTRAP_CACHE_KEY] = entry;
|
|
36
|
+
}
|
|
37
|
+
function harvestBootstrapCache(container) {
|
|
38
|
+
const entry = {};
|
|
39
|
+
for (const key of BOOTSTRAP_CACHE_KEYS) {
|
|
40
|
+
try {
|
|
41
|
+
const value = container.resolve(key);
|
|
42
|
+
if (value !== void 0 && value !== null) entry[key] = value;
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return entry;
|
|
47
|
+
}
|
|
48
|
+
function getCachedEncryptionEnabled(service) {
|
|
49
|
+
if (!service || typeof service.isEnabled !== "function") return false;
|
|
50
|
+
const cached = globalThis[ENCRYPTION_ENABLED_KEY];
|
|
51
|
+
if (typeof cached === "boolean") return cached;
|
|
52
|
+
try {
|
|
53
|
+
const result = !!service.isEnabled();
|
|
54
|
+
globalThis[ENCRYPTION_ENABLED_KEY] = result;
|
|
55
|
+
return result;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
9
60
|
function getGlobalRegistrars() {
|
|
10
61
|
return globalThis[GLOBAL_KEY] ?? null;
|
|
11
62
|
}
|
|
@@ -18,6 +69,8 @@ function registerDiRegistrars(registrars) {
|
|
|
18
69
|
console.debug("[Bootstrap] DI registrars re-registered (this may occur during HMR)");
|
|
19
70
|
}
|
|
20
71
|
setGlobalRegistrars(registrars);
|
|
72
|
+
globalThis[BOOTSTRAP_CACHE_KEY] = null;
|
|
73
|
+
globalThis[ENCRYPTION_ENABLED_KEY] = void 0;
|
|
21
74
|
}
|
|
22
75
|
function getDiRegistrars() {
|
|
23
76
|
const registrars = getGlobalRegistrars();
|
|
@@ -26,6 +79,10 @@ function getDiRegistrars() {
|
|
|
26
79
|
}
|
|
27
80
|
return registrars;
|
|
28
81
|
}
|
|
82
|
+
function resetBootstrapCache() {
|
|
83
|
+
globalThis[BOOTSTRAP_CACHE_KEY] = null;
|
|
84
|
+
globalThis[ENCRYPTION_ENABLED_KEY] = void 0;
|
|
85
|
+
}
|
|
29
86
|
function isAwilixResolver(value) {
|
|
30
87
|
return Boolean(value && typeof value === "object" && typeof value.resolve === "function");
|
|
31
88
|
}
|
|
@@ -62,15 +119,25 @@ async function createRequestContainer() {
|
|
|
62
119
|
} catch {
|
|
63
120
|
}
|
|
64
121
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus;
|
|
123
|
+
if (!alreadyBootstrappedOnThisContainer) {
|
|
124
|
+
const cached = getBootstrapCache();
|
|
125
|
+
if (cached) {
|
|
126
|
+
const replay = {};
|
|
127
|
+
for (const [key, value] of Object.entries(cached)) {
|
|
128
|
+
if (value !== void 0 && value !== null) replay[key] = asValue(value);
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(replay).length > 0) container.register(replay);
|
|
131
|
+
} else {
|
|
132
|
+
try {
|
|
133
|
+
const { bootstrap } = await import("@open-mercato/core/bootstrap");
|
|
134
|
+
if (bootstrap && typeof bootstrap === "function") {
|
|
135
|
+
await bootstrap(container);
|
|
136
|
+
setBootstrapCache(harvestBootstrapCache(container));
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
71
139
|
}
|
|
72
140
|
}
|
|
73
|
-
} catch {
|
|
74
141
|
}
|
|
75
142
|
try {
|
|
76
143
|
const appDi = await import("@/di");
|
|
@@ -90,7 +157,7 @@ async function createRequestContainer() {
|
|
|
90
157
|
try {
|
|
91
158
|
const emForEnc = container.resolve("em");
|
|
92
159
|
const tenantEncryptionService = container.hasRegistration("tenantEncryptionService") ? container.resolve("tenantEncryptionService") : null;
|
|
93
|
-
if (emForEnc && tenantEncryptionService
|
|
160
|
+
if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {
|
|
94
161
|
const { registerTenantEncryptionSubscriber } = await import("@open-mercato/shared/lib/encryption/subscriber");
|
|
95
162
|
registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService);
|
|
96
163
|
}
|
|
@@ -105,6 +172,7 @@ try {
|
|
|
105
172
|
export {
|
|
106
173
|
createRequestContainer,
|
|
107
174
|
getDiRegistrars,
|
|
108
|
-
registerDiRegistrars
|
|
175
|
+
registerDiRegistrars,
|
|
176
|
+
resetBootstrapCache
|
|
109
177
|
};
|
|
110
178
|
//# sourceMappingURL=container.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/di/container.ts"],
|
|
4
|
-
"sourcesContent": ["import { 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'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => 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__'\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 console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')\n }\n setGlobalRegistrars(registrars)\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 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 })\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
|
|
5
|
-
"mappings": "AAAA,SAAS,iBAAiB,SAA0B,qBAAoC;AACxF,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AAEvB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,mCAAmC;AAU5C,MAAM,aAAa;
|
|
4
|
+
"sourcesContent": ["import { 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'\n\ntype DynamicCradle = Record<string, any>\n\nexport type AppContainer = AwilixContainer<DynamicCradle>\nexport type DiRegistrar = (container: AppContainer) => 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__'\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 console.debug('[Bootstrap] 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\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 })\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 (last chance)\n // This import path resolves only in the app context, not in packages\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 {}\n }\n } catch {}\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": []
|
|
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.3-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.3-develop.3881.1.0b590ac4eb'\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.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.1",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.1",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.1",
|
|
95
|
-
"@open-mercato/cache": "0.6.3-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.3-develop.3881.1.0b590ac4eb",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
2
|
+
|
|
3
|
+
function buildFakeEm(rows: any[]) {
|
|
4
|
+
const find = jest.fn(async () => rows.slice())
|
|
5
|
+
const em = {
|
|
6
|
+
find,
|
|
7
|
+
}
|
|
8
|
+
return { em, find }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildMemoryCache() {
|
|
12
|
+
const store = new Map<string, { value: unknown; tags: string[]; expiresAt: number | null }>()
|
|
13
|
+
return {
|
|
14
|
+
store,
|
|
15
|
+
get: jest.fn(async (key: string) => {
|
|
16
|
+
const hit = store.get(key)
|
|
17
|
+
if (!hit) return null
|
|
18
|
+
if (hit.expiresAt !== null && hit.expiresAt < Date.now()) {
|
|
19
|
+
store.delete(key)
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
return hit.value
|
|
23
|
+
}),
|
|
24
|
+
set: jest.fn(async (key: string, value: unknown, opts?: { ttl?: number; tags?: string[] }) => {
|
|
25
|
+
const expiresAt = opts?.ttl && opts.ttl > 0 ? Date.now() + opts.ttl : null
|
|
26
|
+
store.set(key, { value, tags: opts?.tags ?? [], expiresAt })
|
|
27
|
+
}),
|
|
28
|
+
deleteByTags: jest.fn(async (tags: string[]) => {
|
|
29
|
+
let removed = 0
|
|
30
|
+
for (const [key, entry] of store.entries()) {
|
|
31
|
+
if (entry.tags.some((tag) => tags.includes(tag))) {
|
|
32
|
+
store.delete(key)
|
|
33
|
+
removed += 1
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return removed
|
|
37
|
+
}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tenantId = 'tenant-1'
|
|
42
|
+
const entityId = 'example.todo'
|
|
43
|
+
|
|
44
|
+
function fakeDef(key: string, idSuffix: string) {
|
|
45
|
+
return {
|
|
46
|
+
id: `00000000-0000-4000-8000-${idSuffix}`,
|
|
47
|
+
key,
|
|
48
|
+
entityId,
|
|
49
|
+
kind: 'text',
|
|
50
|
+
organizationId: null,
|
|
51
|
+
tenantId,
|
|
52
|
+
updatedAt: new Date('2026-05-24T10:00:00.000Z'),
|
|
53
|
+
configJson: { label: key },
|
|
54
|
+
isActive: true,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('loadCustomFieldDefinitionIndex caching', () => {
|
|
59
|
+
const originalTtl = process.env.OM_CF_DEF_CACHE_TTL_MS
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
if (originalTtl === undefined) delete process.env.OM_CF_DEF_CACHE_TTL_MS
|
|
63
|
+
else process.env.OM_CF_DEF_CACHE_TTL_MS = originalTtl
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('hits the underlying em.find once per (tenant, entities) within the TTL window', async () => {
|
|
67
|
+
process.env.OM_CF_DEF_CACHE_TTL_MS = '300000'
|
|
68
|
+
const { em, find } = buildFakeEm([fakeDef('priority', '000000000001')])
|
|
69
|
+
const cache = buildMemoryCache()
|
|
70
|
+
|
|
71
|
+
await loadCustomFieldDefinitionIndex({
|
|
72
|
+
em: em as any,
|
|
73
|
+
entityIds: entityId,
|
|
74
|
+
tenantId,
|
|
75
|
+
cache,
|
|
76
|
+
})
|
|
77
|
+
expect(find).toHaveBeenCalledTimes(1)
|
|
78
|
+
|
|
79
|
+
await loadCustomFieldDefinitionIndex({
|
|
80
|
+
em: em as any,
|
|
81
|
+
entityIds: entityId,
|
|
82
|
+
tenantId,
|
|
83
|
+
cache,
|
|
84
|
+
})
|
|
85
|
+
expect(find).toHaveBeenCalledTimes(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('falls through to em.find when OM_CF_DEF_CACHE_TTL_MS=0', async () => {
|
|
89
|
+
process.env.OM_CF_DEF_CACHE_TTL_MS = '0'
|
|
90
|
+
const { em, find } = buildFakeEm([fakeDef('priority', '000000000002')])
|
|
91
|
+
const cache = buildMemoryCache()
|
|
92
|
+
|
|
93
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: entityId, tenantId, cache })
|
|
94
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: entityId, tenantId, cache })
|
|
95
|
+
expect(find).toHaveBeenCalledTimes(2)
|
|
96
|
+
expect(cache.set).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('writes tags that the existing entities:definitions invalidation hits', async () => {
|
|
100
|
+
process.env.OM_CF_DEF_CACHE_TTL_MS = '300000'
|
|
101
|
+
const { em } = buildFakeEm([fakeDef('priority', '000000000003')])
|
|
102
|
+
const cache = buildMemoryCache()
|
|
103
|
+
|
|
104
|
+
await loadCustomFieldDefinitionIndex({
|
|
105
|
+
em: em as any,
|
|
106
|
+
entityIds: entityId,
|
|
107
|
+
tenantId,
|
|
108
|
+
cache,
|
|
109
|
+
})
|
|
110
|
+
expect(cache.set).toHaveBeenCalledTimes(1)
|
|
111
|
+
const writeOpts = cache.set.mock.calls[0]?.[2] as { tags?: string[] } | undefined
|
|
112
|
+
expect(writeOpts?.tags).toEqual(
|
|
113
|
+
expect.arrayContaining([
|
|
114
|
+
`entities:definitions:${tenantId}`,
|
|
115
|
+
`entities:definitions:${tenantId}:entity:${entityId}`,
|
|
116
|
+
]),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await cache.deleteByTags([`entities:definitions:${tenantId}:entity:${entityId}`])
|
|
120
|
+
expect(cache.store.size).toBe(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('keys distinguish different entity-id sets', async () => {
|
|
124
|
+
process.env.OM_CF_DEF_CACHE_TTL_MS = '300000'
|
|
125
|
+
const { em, find } = buildFakeEm([fakeDef('priority', '000000000004')])
|
|
126
|
+
const cache = buildMemoryCache()
|
|
127
|
+
|
|
128
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: entityId, tenantId, cache })
|
|
129
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: 'other:entity', tenantId, cache })
|
|
130
|
+
expect(find).toHaveBeenCalledTimes(2)
|
|
131
|
+
expect(cache.store.size).toBe(2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('per-request micro-cache short-circuits inside one request scope', async () => {
|
|
135
|
+
process.env.OM_CF_DEF_CACHE_TTL_MS = '0' // disable shared cache so we only see micro-cache effect
|
|
136
|
+
const { em, find } = buildFakeEm([fakeDef('priority', '000000000005')])
|
|
137
|
+
const ctx = {}
|
|
138
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: entityId, tenantId, requestScope: ctx })
|
|
139
|
+
await loadCustomFieldDefinitionIndex({ em: em as any, entityIds: entityId, tenantId, requestScope: ctx })
|
|
140
|
+
expect(find).toHaveBeenCalledTimes(1)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { flushPendingCrudAccessLogs, logCrudAccess } from '@open-mercato/shared/lib/crud/factory'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
|
|
4
|
+
function makeContainer(service: { log: jest.Mock; logMany?: jest.Mock }) {
|
|
5
|
+
const registrations = { accessLogService: service }
|
|
6
|
+
return {
|
|
7
|
+
registrations,
|
|
8
|
+
resolve: (key: string) => (key === 'accessLogService' ? service : undefined),
|
|
9
|
+
} as any
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const auth: AuthContext = {
|
|
13
|
+
sub: '11111111-1111-4111-8111-111111111111',
|
|
14
|
+
email: 'u@example.com',
|
|
15
|
+
tenantId: '22222222-2222-4222-8222-222222222222',
|
|
16
|
+
orgId: '33333333-3333-4333-8333-333333333333',
|
|
17
|
+
} as any
|
|
18
|
+
|
|
19
|
+
function makeItems(count: number) {
|
|
20
|
+
return Array.from({ length: count }, (_, idx) => ({
|
|
21
|
+
id: `00000000-0000-4000-8000-${String(idx).padStart(12, '0')}`,
|
|
22
|
+
title: `Row ${idx}`,
|
|
23
|
+
}))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('logCrudAccess', () => {
|
|
27
|
+
const originalBlocking = process.env.OM_CRUD_ACCESS_LOG_BLOCKING
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await flushPendingCrudAccessLogs()
|
|
31
|
+
if (originalBlocking === undefined) delete process.env.OM_CRUD_ACCESS_LOG_BLOCKING
|
|
32
|
+
else process.env.OM_CRUD_ACCESS_LOG_BLOCKING = originalBlocking
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('prefers logMany() when the service exposes it', async () => {
|
|
36
|
+
process.env.OM_CRUD_ACCESS_LOG_BLOCKING = '1'
|
|
37
|
+
const service = {
|
|
38
|
+
log: jest.fn(async () => {}),
|
|
39
|
+
logMany: jest.fn(async (_payloads: unknown[]) => {}),
|
|
40
|
+
}
|
|
41
|
+
const items = makeItems(50)
|
|
42
|
+
const result = await logCrudAccess({
|
|
43
|
+
container: makeContainer(service),
|
|
44
|
+
auth,
|
|
45
|
+
items,
|
|
46
|
+
idField: 'id',
|
|
47
|
+
resourceKind: 'example.todo',
|
|
48
|
+
})
|
|
49
|
+
expect(service.logMany).toHaveBeenCalledTimes(1)
|
|
50
|
+
const batch = service.logMany.mock.calls[0]?.[0] as Array<Record<string, unknown>>
|
|
51
|
+
expect(Array.isArray(batch)).toBe(true)
|
|
52
|
+
expect(batch).toHaveLength(50)
|
|
53
|
+
expect(service.log).not.toHaveBeenCalled()
|
|
54
|
+
expect(result.mode).toBe('blocking')
|
|
55
|
+
expect(result.count).toBe(50)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('falls back to per-row log() when logMany is missing', async () => {
|
|
59
|
+
process.env.OM_CRUD_ACCESS_LOG_BLOCKING = '1'
|
|
60
|
+
const service = { log: jest.fn(async () => {}) }
|
|
61
|
+
const items = makeItems(3)
|
|
62
|
+
const result = await logCrudAccess({
|
|
63
|
+
container: makeContainer(service),
|
|
64
|
+
auth,
|
|
65
|
+
items,
|
|
66
|
+
idField: 'id',
|
|
67
|
+
resourceKind: 'example.todo',
|
|
68
|
+
})
|
|
69
|
+
expect(service.log).toHaveBeenCalledTimes(3)
|
|
70
|
+
// dispatchMode reflects the underlying service shape even when the outer
|
|
71
|
+
// call blocks — the profiler payload needs to distinguish "batched into
|
|
72
|
+
// one INSERT" from "fanned out N INSERTs in blocking mode".
|
|
73
|
+
expect(result.mode).toBe('blocking')
|
|
74
|
+
expect(result.count).toBe(3)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does not await writes when OM_CRUD_ACCESS_LOG_BLOCKING is unset', async () => {
|
|
78
|
+
delete process.env.OM_CRUD_ACCESS_LOG_BLOCKING
|
|
79
|
+
let resolveLogMany: () => void = () => {}
|
|
80
|
+
const pendingLogMany = new Promise<void>((resolve) => {
|
|
81
|
+
resolveLogMany = resolve
|
|
82
|
+
})
|
|
83
|
+
const service = {
|
|
84
|
+
log: jest.fn(async () => {}),
|
|
85
|
+
logMany: jest.fn(async (_payloads: unknown[]) => {
|
|
86
|
+
await pendingLogMany
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
const items = makeItems(5)
|
|
90
|
+
const start = process.hrtime.bigint()
|
|
91
|
+
const result = await logCrudAccess({
|
|
92
|
+
container: makeContainer(service),
|
|
93
|
+
auth,
|
|
94
|
+
items,
|
|
95
|
+
idField: 'id',
|
|
96
|
+
resourceKind: 'example.todo',
|
|
97
|
+
})
|
|
98
|
+
const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000
|
|
99
|
+
// The outer logCrudAccess call should return immediately, even though
|
|
100
|
+
// logMany has not resolved yet. Allow a generous 50 ms ceiling for CI noise.
|
|
101
|
+
expect(elapsedMs).toBeLessThan(50)
|
|
102
|
+
expect(service.logMany).toHaveBeenCalledTimes(1)
|
|
103
|
+
expect(result.mode).toBe('batch')
|
|
104
|
+
expect(result.count).toBe(5)
|
|
105
|
+
expect(result.pending).toBeGreaterThan(0)
|
|
106
|
+
resolveLogMany()
|
|
107
|
+
await flushPendingCrudAccessLogs()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('flushPendingCrudAccessLogs drains in-flight writes', async () => {
|
|
111
|
+
delete process.env.OM_CRUD_ACCESS_LOG_BLOCKING
|
|
112
|
+
let completed = 0
|
|
113
|
+
const service = {
|
|
114
|
+
log: jest.fn(async () => {}),
|
|
115
|
+
logMany: jest.fn(async () => {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
117
|
+
completed += 1
|
|
118
|
+
}),
|
|
119
|
+
}
|
|
120
|
+
await logCrudAccess({
|
|
121
|
+
container: makeContainer(service),
|
|
122
|
+
auth,
|
|
123
|
+
items: makeItems(2),
|
|
124
|
+
idField: 'id',
|
|
125
|
+
resourceKind: 'example.todo',
|
|
126
|
+
})
|
|
127
|
+
expect(completed).toBe(0)
|
|
128
|
+
await flushPendingCrudAccessLogs()
|
|
129
|
+
expect(completed).toBe(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('skips items without a normalized id and dedupes duplicate ids', async () => {
|
|
133
|
+
process.env.OM_CRUD_ACCESS_LOG_BLOCKING = '1'
|
|
134
|
+
const service = {
|
|
135
|
+
log: jest.fn(async () => {}),
|
|
136
|
+
logMany: jest.fn(async (_payloads: unknown[]) => {}),
|
|
137
|
+
}
|
|
138
|
+
const items = [
|
|
139
|
+
{ id: '00000000-0000-4000-8000-000000000001' },
|
|
140
|
+
{ id: '00000000-0000-4000-8000-000000000001' }, // dup
|
|
141
|
+
{ id: '' }, // empty
|
|
142
|
+
{ id: '00000000-0000-4000-8000-000000000002' },
|
|
143
|
+
]
|
|
144
|
+
await logCrudAccess({
|
|
145
|
+
container: makeContainer(service),
|
|
146
|
+
auth,
|
|
147
|
+
items,
|
|
148
|
+
idField: 'id',
|
|
149
|
+
resourceKind: 'example.todo',
|
|
150
|
+
})
|
|
151
|
+
const batch = service.logMany.mock.calls[0]?.[0] as Array<Record<string, unknown>>
|
|
152
|
+
expect(batch).toHaveLength(2)
|
|
153
|
+
expect(batch.map((p) => p.resourceId)).toEqual([
|
|
154
|
+
'00000000-0000-4000-8000-000000000001',
|
|
155
|
+
'00000000-0000-4000-8000-000000000002',
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Light-weight contract test: the per-request RBAC memo MUST coalesce
|
|
2
|
+
// concurrent + sequential getGrantedFeatures calls for the same ctx into
|
|
3
|
+
// a single rbacService call. This is what saves the 5-15 ms / request the
|
|
4
|
+
// spec attributes to Phase 3.
|
|
5
|
+
|
|
6
|
+
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
7
|
+
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
|
|
10
|
+
const getGrantedFeatures = jest.fn(async () => ['example.view'])
|
|
11
|
+
const rbacService = { getGrantedFeatures }
|
|
12
|
+
const accessLogService = { log: jest.fn(async () => {}), logMany: jest.fn(async () => {}) }
|
|
13
|
+
|
|
14
|
+
const defaultOrgId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
15
|
+
const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
16
|
+
|
|
17
|
+
const em = {
|
|
18
|
+
getRepository: () => ({
|
|
19
|
+
find: async () => [{ id: 'id-1', organizationId: defaultOrgId, tenantId: defaultTenantId }],
|
|
20
|
+
findOne: async () => null,
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const queryEngine = {
|
|
25
|
+
query: jest.fn(async () => ({
|
|
26
|
+
items: [{ id: 'id-1', organization_id: defaultOrgId, tenant_id: defaultTenantId }],
|
|
27
|
+
total: 1,
|
|
28
|
+
})),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
32
|
+
createRequestContainer: async () => ({
|
|
33
|
+
resolve: (key: string) => {
|
|
34
|
+
if (key === 'em') return em
|
|
35
|
+
if (key === 'queryEngine') return queryEngine
|
|
36
|
+
if (key === 'rbacService') return rbacService
|
|
37
|
+
if (key === 'accessLogService') return accessLogService
|
|
38
|
+
throw new Error(`unexpected DI key: ${key}`)
|
|
39
|
+
},
|
|
40
|
+
registrations: {
|
|
41
|
+
em: true,
|
|
42
|
+
queryEngine: true,
|
|
43
|
+
rbacService: true,
|
|
44
|
+
accessLogService: true,
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
50
|
+
__esModule: true,
|
|
51
|
+
getAuthFromCookies: async () => ({
|
|
52
|
+
sub: '00000000-0000-4000-8000-000000000001',
|
|
53
|
+
tenantId: defaultTenantId,
|
|
54
|
+
orgId: defaultOrgId,
|
|
55
|
+
}),
|
|
56
|
+
getAuthFromRequest: async () => ({
|
|
57
|
+
sub: '00000000-0000-4000-8000-000000000001',
|
|
58
|
+
tenantId: defaultTenantId,
|
|
59
|
+
orgId: defaultOrgId,
|
|
60
|
+
}),
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
|
|
64
|
+
resolveOrganizationScopeForRequest: async () => ({
|
|
65
|
+
selectedId: defaultOrgId,
|
|
66
|
+
filterIds: [defaultOrgId],
|
|
67
|
+
allowedIds: [defaultOrgId],
|
|
68
|
+
tenantId: defaultTenantId,
|
|
69
|
+
}),
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
class Todo {}
|
|
73
|
+
|
|
74
|
+
describe('per-request userFeatures memo', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
getGrantedFeatures.mockClear()
|
|
77
|
+
accessLogService.log.mockClear()
|
|
78
|
+
accessLogService.logMany.mockClear()
|
|
79
|
+
queryEngine.query.mockClear()
|
|
80
|
+
registerApiInterceptors([])
|
|
81
|
+
process.env.OM_CRUD_ACCESS_LOG_BLOCKING = '1'
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('calls rbacService.getGrantedFeatures at most once per GET when enrichers are configured', async () => {
|
|
85
|
+
const route = makeCrudRoute({
|
|
86
|
+
metadata: { GET: { requireAuth: true } },
|
|
87
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
88
|
+
indexer: { entityType: 'example.todo' },
|
|
89
|
+
list: {
|
|
90
|
+
schema: z.object({
|
|
91
|
+
page: z.coerce.number().default(1),
|
|
92
|
+
pageSize: z.coerce.number().default(50),
|
|
93
|
+
sortField: z.string().default('id'),
|
|
94
|
+
sortDir: z.enum(['asc', 'desc']).default('asc'),
|
|
95
|
+
}),
|
|
96
|
+
entityId: 'example.todo',
|
|
97
|
+
fields: ['id'],
|
|
98
|
+
buildFilters: () => ({} as any),
|
|
99
|
+
},
|
|
100
|
+
enrichers: { entityId: 'example.todo' },
|
|
101
|
+
})
|
|
102
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
103
|
+
expect(res.status).toBe(200)
|
|
104
|
+
expect(getGrantedFeatures).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
})
|