@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/src/lib/di/container.ts
CHANGED
|
@@ -16,6 +16,82 @@ export type DiRegistrar = (container: AppContainer) => void
|
|
|
16
16
|
// Use globalThis to survive tsx/esbuild module duplication issue where the same
|
|
17
17
|
// file can be loaded as multiple module instances when mixing dynamic and static imports
|
|
18
18
|
const GLOBAL_KEY = '__openMercatoDiRegistrars__'
|
|
19
|
+
// Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
|
|
20
|
+
// services bootstrap() creates are inherently process-scoped (they hold
|
|
21
|
+
// state across requests). Caching them on globalThis after the first
|
|
22
|
+
// successful bootstrap call lets every subsequent request skip the
|
|
23
|
+
// `await bootstrap(container)` body and just re-register the cached
|
|
24
|
+
// instances. Same globalThis pattern as registerDiRegistrars so HMR
|
|
25
|
+
// keeps working.
|
|
26
|
+
const BOOTSTRAP_CACHE_KEY = '__openMercatoBootstrapCache__'
|
|
27
|
+
const ENCRYPTION_ENABLED_KEY = '__openMercatoEncryptionEnabledCache__'
|
|
28
|
+
|
|
29
|
+
const BOOTSTRAP_CACHE_KEYS = [
|
|
30
|
+
'cache',
|
|
31
|
+
'eventBus',
|
|
32
|
+
'kmsService',
|
|
33
|
+
'tenantEncryptionService',
|
|
34
|
+
'rateLimiterService',
|
|
35
|
+
'searchModuleConfigs',
|
|
36
|
+
'searchIndexer',
|
|
37
|
+
] as const
|
|
38
|
+
|
|
39
|
+
type BootstrapCacheEntry = Partial<Record<(typeof BOOTSTRAP_CACHE_KEYS)[number], unknown>>
|
|
40
|
+
|
|
41
|
+
// Phase 5 is opt-in. Some bootstrap services close over per-request state
|
|
42
|
+
// (e.g. tenantEncryptionService captures the first request's `em.fork`, the
|
|
43
|
+
// event-bus's resolver closes over the first container) so naively replaying
|
|
44
|
+
// them on later requests yields stale references — observed as a 500 from
|
|
45
|
+
// CRUD list endpoints in `next start`. Default OFF preserves develop's
|
|
46
|
+
// per-request bootstrap. Set `OM_BOOTSTRAP_CACHE=1` to opt in once each
|
|
47
|
+
// cached service is verified safe for cross-request reuse.
|
|
48
|
+
function isBootstrapCacheEnabled(): boolean {
|
|
49
|
+
const raw = process.env.OM_BOOTSTRAP_CACHE
|
|
50
|
+
if (raw === undefined) return false
|
|
51
|
+
const normalized = raw.trim().toLowerCase()
|
|
52
|
+
if (!normalized.length) return false
|
|
53
|
+
if (normalized === '0' || normalized === 'off' || normalized === 'false' || normalized === 'no') return false
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getBootstrapCache(): BootstrapCacheEntry | null {
|
|
58
|
+
if (!isBootstrapCacheEnabled()) return null
|
|
59
|
+
const existing = (globalThis as any)[BOOTSTRAP_CACHE_KEY]
|
|
60
|
+
return existing && typeof existing === 'object' ? (existing as BootstrapCacheEntry) : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setBootstrapCache(entry: BootstrapCacheEntry): void {
|
|
64
|
+
if (!isBootstrapCacheEnabled()) return
|
|
65
|
+
;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = entry
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function harvestBootstrapCache(container: AwilixContainer): BootstrapCacheEntry {
|
|
69
|
+
const entry: BootstrapCacheEntry = {}
|
|
70
|
+
for (const key of BOOTSTRAP_CACHE_KEYS) {
|
|
71
|
+
try {
|
|
72
|
+
const value: unknown = container.resolve(key as never)
|
|
73
|
+
if (value !== undefined && value !== null) entry[key] = value
|
|
74
|
+
} catch {
|
|
75
|
+
// not registered — skip
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entry
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type EncryptionEnabledProbe = { isEnabled?: () => boolean } | null | undefined
|
|
82
|
+
|
|
83
|
+
function getCachedEncryptionEnabled(service: EncryptionEnabledProbe): boolean | null {
|
|
84
|
+
if (!service || typeof service.isEnabled !== 'function') return false
|
|
85
|
+
const cached = (globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY]
|
|
86
|
+
if (typeof cached === 'boolean') return cached
|
|
87
|
+
try {
|
|
88
|
+
const result = !!service.isEnabled()
|
|
89
|
+
;(globalThis as Record<string, unknown>)[ENCRYPTION_ENABLED_KEY] = result
|
|
90
|
+
return result
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
19
95
|
|
|
20
96
|
function getGlobalRegistrars(): DiRegistrar[] | null {
|
|
21
97
|
return (globalThis as any)[GLOBAL_KEY] ?? null
|
|
@@ -31,6 +107,9 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
|
|
|
31
107
|
console.debug('[Bootstrap] DI registrars re-registered (this may occur during HMR)')
|
|
32
108
|
}
|
|
33
109
|
setGlobalRegistrars(registrars)
|
|
110
|
+
// Force re-bootstrap on HMR — module subscribers may have changed.
|
|
111
|
+
;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
|
|
112
|
+
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
34
113
|
}
|
|
35
114
|
|
|
36
115
|
export function getDiRegistrars(): DiRegistrar[] {
|
|
@@ -41,6 +120,12 @@ export function getDiRegistrars(): DiRegistrar[] {
|
|
|
41
120
|
return registrars
|
|
42
121
|
}
|
|
43
122
|
|
|
123
|
+
/** Test-only helper to drop the process-scoped bootstrap cache. */
|
|
124
|
+
export function resetBootstrapCache(): void {
|
|
125
|
+
(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
|
|
126
|
+
;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
44
129
|
function isAwilixResolver(value: unknown): value is Resolver<unknown> {
|
|
45
130
|
return Boolean(value && typeof value === 'object' && typeof (value as { resolve?: unknown }).resolve === 'function')
|
|
46
131
|
}
|
|
@@ -76,16 +161,30 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
76
161
|
try { reg?.(container) } catch {}
|
|
77
162
|
}
|
|
78
163
|
// Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
164
|
+
// Phase 5 — process-scoped once-guard. The first request runs the full
|
|
165
|
+
// bootstrap() body; later requests re-register the cached services
|
|
166
|
+
// directly on this request's container without re-importing or
|
|
167
|
+
// re-initializing anything. HMR clears the cache (see
|
|
168
|
+
// registerDiRegistrars). Skippable if a caller already wired eventBus.
|
|
169
|
+
const alreadyBootstrappedOnThisContainer = !!container.registrations?.eventBus
|
|
170
|
+
if (!alreadyBootstrappedOnThisContainer) {
|
|
171
|
+
const cached = getBootstrapCache()
|
|
172
|
+
if (cached) {
|
|
173
|
+
const replay: Record<string, any> = {}
|
|
174
|
+
for (const [key, value] of Object.entries(cached)) {
|
|
175
|
+
if (value !== undefined && value !== null) replay[key] = asValue(value)
|
|
86
176
|
}
|
|
177
|
+
if (Object.keys(replay).length > 0) container.register(replay)
|
|
178
|
+
} else {
|
|
179
|
+
try {
|
|
180
|
+
const { bootstrap } = await import('@open-mercato/core/bootstrap') as any
|
|
181
|
+
if (bootstrap && typeof bootstrap === 'function') {
|
|
182
|
+
await bootstrap(container)
|
|
183
|
+
setBootstrapCache(harvestBootstrapCache(container))
|
|
184
|
+
}
|
|
185
|
+
} catch { /* optional */ }
|
|
87
186
|
}
|
|
88
|
-
}
|
|
187
|
+
}
|
|
89
188
|
// App-level DI override (last chance)
|
|
90
189
|
// This import path resolves only in the app context, not in packages
|
|
91
190
|
try {
|
|
@@ -103,12 +202,15 @@ export async function createRequestContainer(): Promise<AppContainer> {
|
|
|
103
202
|
unregister: (key) => container.register({ [key]: asValue(undefined) }),
|
|
104
203
|
})
|
|
105
204
|
// Ensure tenant encryption subscriber is always registered on the fresh request-scoped EM
|
|
205
|
+
// Phase 5 — cache `tenantEncryptionService.isEnabled()` for the process
|
|
206
|
+
// lifetime. The result depends only on config that does not change at
|
|
207
|
+
// runtime, so reading it once skips a config lookup per request.
|
|
106
208
|
try {
|
|
107
209
|
const emForEnc = container.resolve('em') as any
|
|
108
210
|
const tenantEncryptionService = container.hasRegistration('tenantEncryptionService')
|
|
109
211
|
? (container.resolve('tenantEncryptionService') as any)
|
|
110
212
|
: null
|
|
111
|
-
if (emForEnc && tenantEncryptionService
|
|
213
|
+
if (emForEnc && tenantEncryptionService && getCachedEncryptionEnabled(tenantEncryptionService) === true) {
|
|
112
214
|
const { registerTenantEncryptionSubscriber } = await import('@open-mercato/shared/lib/encryption/subscriber')
|
|
113
215
|
registerTenantEncryptionSubscriber(emForEnc, tenantEncryptionService)
|
|
114
216
|
}
|