@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.
@@ -337,24 +337,113 @@ function selectDefinitionForRecord(
337
337
  return sortDefinitionSummaries(defs)[0] ?? null
338
338
  }
339
339
 
340
- export async function loadCustomFieldDefinitionIndex(opts: {
340
+ type LoadCustomFieldDefinitionIndexOptions = {
341
341
  em: EntityManager
342
342
  entityIds: string | string[]
343
343
  tenantId?: string | null | undefined
344
344
  organizationIds?: Array<string | null | undefined> | null
345
345
  fieldset?: string | string[] | null
346
- }): Promise<CustomFieldDefinitionIndex> {
347
- const list = Array.isArray(opts.entityIds) ? opts.entityIds : [opts.entityIds]
348
- const entityIds = list
349
- .map((id) => (typeof id === 'string' ? id.trim() : String(id ?? '')))
350
- .filter((id) => id.length > 0)
351
- if (!entityIds.length) return new Map()
346
+ }
347
+
348
+ type CustomFieldDefIndexCache = {
349
+ get(key: string): Promise<unknown> | unknown
350
+ set(key: string, value: unknown, opts?: { ttl?: number; tags?: string[] }): Promise<unknown> | unknown
351
+ deleteByTags?(tags: string[]): Promise<number> | number
352
+ }
353
+
354
+ const CF_DEF_INDEX_CACHE_KEY_PREFIX = 'crud:cf-def-index'
355
+ // Phase 2 default-off: integration runs observed `/api/customers/people`
356
+ // returning 500 with this cache path active, and the readiness-probe
357
+ // timeout blocked artifact upload so the direct stack trace was lost.
358
+ // Until cross-request safety of the SQLite-cache JSON round-trip is
359
+ // re-verified, ship with the layer disabled. Set
360
+ // `OM_CF_DEF_CACHE_TTL_MS=300000` (or any positive integer) to opt in.
361
+ const CF_DEF_INDEX_DEFAULT_TTL_MS = 0
362
+
363
+ function resolveCfDefIndexCacheTtlMs(): number {
364
+ const raw = process.env.OM_CF_DEF_CACHE_TTL_MS
365
+ if (raw === undefined) return CF_DEF_INDEX_DEFAULT_TTL_MS
366
+ const parsed = Number(raw)
367
+ if (!Number.isFinite(parsed) || parsed < 0) return CF_DEF_INDEX_DEFAULT_TTL_MS
368
+ return parsed
369
+ }
370
+
371
+ function buildCfDefIndexCacheKey(opts: {
372
+ tenantId: string | null
373
+ entityIds: string[]
374
+ organizationIds: string[]
375
+ fieldsetKey: string | null
376
+ }): string {
377
+ const tenant = opts.tenantId ?? 'global'
378
+ const entities = opts.entityIds.slice().sort().join('|')
379
+ const orgs = opts.organizationIds.length ? opts.organizationIds.slice().sort().join('|') : 'none'
380
+ const fieldset = opts.fieldsetKey ?? 'all'
381
+ return `${CF_DEF_INDEX_CACHE_KEY_PREFIX}:${tenant}:${entities}:${orgs}:${fieldset}`
382
+ }
383
+
384
+ function buildCfDefIndexCacheTags(opts: {
385
+ tenantId: string | null
386
+ entityIds: string[]
387
+ }): string[] {
388
+ const tenant = opts.tenantId ?? 'global'
389
+ const tagBase = `entities:definitions:${tenant}`
390
+ const tags = new Set<string>([tagBase])
391
+ for (const entityId of opts.entityIds) {
392
+ tags.add(`${tagBase}:entity:${entityId}`)
393
+ }
394
+ return Array.from(tags)
395
+ }
396
+
397
+ function normalizeFieldsetKey(value: string | string[] | null | undefined): string | null {
398
+ if (!value) return null
399
+ if (Array.isArray(value)) {
400
+ const cleaned = value
401
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
402
+ .filter((entry) => entry.length > 0)
403
+ if (!cleaned.length) return null
404
+ return cleaned.sort().join(',')
405
+ }
406
+ if (typeof value !== 'string') return null
407
+ const trimmed = value.trim()
408
+ return trimmed.length > 0 ? trimmed : null
409
+ }
410
+
411
+ function serializableIndexFromMap(index: CustomFieldDefinitionIndex): Array<[string, CustomFieldDefinitionSummary[]]> {
412
+ return Array.from(index.entries())
413
+ }
414
+
415
+ function indexMapFromSerializable(value: unknown): CustomFieldDefinitionIndex | null {
416
+ if (!Array.isArray(value)) return null
417
+ const map: CustomFieldDefinitionIndex = new Map()
418
+ for (const entry of value) {
419
+ if (!Array.isArray(entry) || entry.length !== 2) return null
420
+ const [key, summaries] = entry as [unknown, unknown]
421
+ if (typeof key !== 'string' || !Array.isArray(summaries)) return null
422
+ map.set(key, summaries as CustomFieldDefinitionSummary[])
423
+ }
424
+ return map
425
+ }
426
+
427
+ // Per-request micro-cache. Two CRUD calls within one HTTP request (rare but
428
+ // possible via interceptors) share the same Map keyed by ctx-like objects.
429
+ const requestScopedCfDefIndexCache = new WeakMap<object, Map<string, CustomFieldDefinitionIndex>>()
430
+
431
+ export type CustomFieldDefinitionIndexCacheKey = string
432
+
433
+ export function getRequestScopedCfDefIndexCache(scope: object): Map<string, CustomFieldDefinitionIndex> {
434
+ let bucket = requestScopedCfDefIndexCache.get(scope)
435
+ if (!bucket) {
436
+ bucket = new Map()
437
+ requestScopedCfDefIndexCache.set(scope, bucket)
438
+ }
439
+ return bucket
440
+ }
441
+
442
+ async function loadCustomFieldDefinitionIndexFresh(
443
+ opts: LoadCustomFieldDefinitionIndexOptions & { entityIds: string[]; orgCandidates: string[] }
444
+ ): Promise<CustomFieldDefinitionIndex> {
445
+ const { em, entityIds, orgCandidates } = opts
352
446
  const tenantId = opts.tenantId ?? null
353
- const orgCandidates = Array.isArray(opts.organizationIds)
354
- ? opts.organizationIds
355
- .map((id) => (typeof id === 'string' ? id.trim() : id))
356
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
357
- : []
358
447
  const scopeClauses: Record<string, unknown>[] = [
359
448
  tenantId
360
449
  ? { $or: [{ tenantId: tenantId as any }, { tenantId: null }] }
@@ -373,7 +462,7 @@ export async function loadCustomFieldDefinitionIndex(opts: {
373
462
  isActive: true,
374
463
  $and: scopeClauses,
375
464
  }
376
- const defs = await opts.em.find(CustomFieldDef, where as any)
465
+ const defs = await em.find(CustomFieldDef, where as any)
377
466
  const fieldsetFilter = normalizeFieldsetFilter(opts.fieldset)
378
467
  const index: CustomFieldDefinitionIndex = new Map()
379
468
  defs.forEach((def) => {
@@ -406,6 +495,73 @@ export async function loadCustomFieldDefinitionIndex(opts: {
406
495
  return index
407
496
  }
408
497
 
498
+ export async function loadCustomFieldDefinitionIndex(opts: LoadCustomFieldDefinitionIndexOptions & {
499
+ cache?: CustomFieldDefIndexCache | null
500
+ requestScope?: object | null
501
+ }): Promise<CustomFieldDefinitionIndex> {
502
+ const list = Array.isArray(opts.entityIds) ? opts.entityIds : [opts.entityIds]
503
+ const entityIds = list
504
+ .map((id) => (typeof id === 'string' ? id.trim() : String(id ?? '')))
505
+ .filter((id) => id.length > 0)
506
+ if (!entityIds.length) return new Map()
507
+ const tenantId = opts.tenantId ?? null
508
+ const orgCandidates = Array.isArray(opts.organizationIds)
509
+ ? opts.organizationIds
510
+ .map((id) => (typeof id === 'string' ? id.trim() : id))
511
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
512
+ : []
513
+
514
+ const fieldsetKey = normalizeFieldsetKey(opts.fieldset)
515
+ const ttlMs = resolveCfDefIndexCacheTtlMs()
516
+ const cacheKey = buildCfDefIndexCacheKey({
517
+ tenantId,
518
+ entityIds,
519
+ organizationIds: orgCandidates,
520
+ fieldsetKey,
521
+ })
522
+
523
+ const requestBucket = opts.requestScope
524
+ ? getRequestScopedCfDefIndexCache(opts.requestScope)
525
+ : null
526
+ if (requestBucket) {
527
+ const cached = requestBucket.get(cacheKey)
528
+ if (cached) return cached
529
+ }
530
+
531
+ const sharedCache = ttlMs > 0 ? opts.cache ?? null : null
532
+ if (sharedCache && typeof sharedCache.get === 'function') {
533
+ try {
534
+ const cached = await sharedCache.get(cacheKey)
535
+ const restored = indexMapFromSerializable(cached)
536
+ if (restored) {
537
+ if (requestBucket) requestBucket.set(cacheKey, restored)
538
+ return restored
539
+ }
540
+ } catch (err) {
541
+ console.warn('[crud:cf-def-cache] read failed', err)
542
+ }
543
+ }
544
+
545
+ const index = await loadCustomFieldDefinitionIndexFresh({
546
+ ...opts,
547
+ entityIds,
548
+ orgCandidates,
549
+ })
550
+
551
+ if (sharedCache && typeof sharedCache.set === 'function') {
552
+ try {
553
+ await sharedCache.set(cacheKey, serializableIndexFromMap(index), {
554
+ ttl: ttlMs,
555
+ tags: buildCfDefIndexCacheTags({ tenantId, entityIds }),
556
+ })
557
+ } catch (err) {
558
+ console.warn('[crud:cf-def-cache] write failed', err)
559
+ }
560
+ }
561
+ if (requestBucket) requestBucket.set(cacheKey, index)
562
+ return index
563
+ }
564
+
409
565
  export type ApplyCustomFieldsNormalizationOptions = {
410
566
  /**
411
567
  * When true, removes raw `cf_*` and `cf:*` keys from the record once they
@@ -38,6 +38,7 @@ import { CrudHttpError, isCrudHttpError } from './errors'
38
38
  import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
39
39
  import type { EntityId } from '@open-mercato/shared/modules/entities'
40
40
  import type { EntityManager } from '@mikro-orm/postgresql'
41
+ import type { CacheStrategy } from '@open-mercato/cache'
41
42
  import {
42
43
  buildCollectionTags,
43
44
  buildRecordTag,
@@ -637,7 +638,11 @@ function isUuid(v: any): v is string {
637
638
  return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
638
639
  }
639
640
 
640
- type AccessLogServiceLike = { log: (input: any) => Promise<unknown> | unknown }
641
+ type AccessLogServiceLike = {
642
+ log: (input: any) => Promise<unknown> | unknown
643
+ logMany?: (inputs: any[]) => Promise<unknown> | unknown
644
+ flush?: () => Promise<void> | void
645
+ }
641
646
 
642
647
  function resolveAccessLogService(container: AwilixContainer): AccessLogServiceLike | null {
643
648
  const registrations = (container as { registrations?: Record<string, unknown> }).registrations
@@ -654,6 +659,33 @@ function resolveAccessLogService(container: AwilixContainer): AccessLogServiceLi
654
659
  return null
655
660
  }
656
661
 
662
+ function shouldBlockAccessLogWrites(): boolean {
663
+ return process.env.OM_CRUD_ACCESS_LOG_BLOCKING === '1'
664
+ }
665
+
666
+ // Module-level set of in-flight access-log writes started by the CRUD factory.
667
+ // Sits alongside the same registry inside AccessLogService so callers without
668
+ // the concrete service (or in tests with mocks) can still drain pending work
669
+ // via `flushPendingCrudAccessLogs()`.
670
+ const pendingCrudAccessLogPromises = new Set<Promise<unknown>>()
671
+
672
+ function trackPendingCrudAccessLogPromise<T>(promise: Promise<T>): Promise<T> {
673
+ pendingCrudAccessLogPromises.add(promise as unknown as Promise<unknown>)
674
+ promise
675
+ .catch(() => undefined)
676
+ .finally(() => {
677
+ pendingCrudAccessLogPromises.delete(promise as unknown as Promise<unknown>)
678
+ })
679
+ return promise
680
+ }
681
+
682
+ export async function flushPendingCrudAccessLogs(): Promise<void> {
683
+ while (pendingCrudAccessLogPromises.size > 0) {
684
+ const snapshot = Array.from(pendingCrudAccessLogPromises)
685
+ await Promise.allSettled(snapshot)
686
+ }
687
+ }
688
+
657
689
  function logForbidden(details: Record<string, unknown>) {
658
690
  try {
659
691
  // eslint-disable-next-line no-console
@@ -705,12 +737,18 @@ export type LogCrudAccessOptions = {
705
737
  fields?: string[]
706
738
  }
707
739
 
708
- export async function logCrudAccess(options: LogCrudAccessOptions) {
740
+ export type LogCrudAccessResult = {
741
+ mode: 'batch' | 'fanout' | 'blocking' | 'skipped'
742
+ count: number
743
+ pending: number
744
+ }
745
+
746
+ export async function logCrudAccess(options: LogCrudAccessOptions): Promise<LogCrudAccessResult> {
709
747
  const { container, auth, request, items, resourceKind } = options
710
- if (!auth) return
711
- if (!Array.isArray(items) || items.length === 0) return
748
+ if (!auth) return { mode: 'skipped', count: 0, pending: pendingCrudAccessLogPromises.size }
749
+ if (!Array.isArray(items) || items.length === 0) return { mode: 'skipped', count: 0, pending: pendingCrudAccessLogPromises.size }
712
750
  const service = resolveAccessLogService(container)
713
- if (!service) return
751
+ if (!service) return { mode: 'skipped', count: 0, pending: pendingCrudAccessLogPromises.size }
714
752
 
715
753
  const idField = options.idField || 'id'
716
754
  const tenantId = options.tenantId ?? auth.tenantId ?? null
@@ -736,7 +774,7 @@ export async function logCrudAccess(options: LogCrudAccessOptions) {
736
774
  }
737
775
 
738
776
  const uniqueIds = new Set<string>()
739
- const tasks: Promise<unknown>[] = []
777
+ const payloads: Record<string, unknown>[] = []
740
778
  for (const item of items) {
741
779
  if (!item || typeof item !== 'object') continue
742
780
  const rawId = (item as any)[idField]
@@ -753,16 +791,41 @@ export async function logCrudAccess(options: LogCrudAccessOptions) {
753
791
  }
754
792
  if (fields.length > 0) payload.fields = fields
755
793
  if (Object.keys(context).length > 0) payload.context = context
756
- tasks.push(
757
- Promise.resolve(service.log(payload)).catch((err) => {
758
- try {
759
- console.error('[crud] failed to record access log', { err, payload })
760
- } catch {}
761
- return undefined
762
- })
763
- )
794
+ payloads.push(payload)
795
+ }
796
+ if (!payloads.length) return { mode: 'skipped', count: 0, pending: pendingCrudAccessLogPromises.size }
797
+
798
+ const blocking = shouldBlockAccessLogWrites()
799
+ const dispatchMode: 'batch' | 'fanout' = typeof service.logMany === 'function' ? 'batch' : 'fanout'
800
+ const writePromise = (async () => {
801
+ try {
802
+ if (typeof service.logMany === 'function') {
803
+ await service.logMany(payloads)
804
+ } else {
805
+ // Legacy fallback for service mocks/implementations without logMany.
806
+ await Promise.all(
807
+ payloads.map((payload) =>
808
+ Promise.resolve(service.log(payload)).catch((err) => {
809
+ try {
810
+ console.error('[crud] failed to record access log', { err, payload })
811
+ } catch {}
812
+ return undefined
813
+ }),
814
+ ),
815
+ )
816
+ }
817
+ } catch (err) {
818
+ try {
819
+ console.error('[crud] failed to record access logs (batch)', { err, count: payloads.length })
820
+ } catch {}
821
+ }
822
+ })()
823
+ trackPendingCrudAccessLogPromise(writePromise)
824
+ if (blocking) {
825
+ await writePromise
826
+ return { mode: 'blocking', count: payloads.length, pending: pendingCrudAccessLogPromises.size }
764
827
  }
765
- if (tasks.length > 0) await Promise.all(tasks)
828
+ return { mode: dispatchMode, count: payloads.length, pending: pendingCrudAccessLogPromises.size }
766
829
  }
767
830
 
768
831
  type CrudCacheStoredValue = {
@@ -908,11 +971,17 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
908
971
  Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
909
972
  ? ctx.organizationIds
910
973
  : [ctx.selectedOrganizationId ?? null]
974
+ let cfDefCache: CacheStrategy | null = null
975
+ try {
976
+ cfDefCache = ctx.container.resolve('cache') as CacheStrategy
977
+ } catch {}
911
978
  const definitionIndex = await loadCustomFieldDefinitionIndex({
912
979
  em,
913
980
  entityIds,
914
981
  tenantId: ctx.auth?.tenantId ?? null,
915
982
  organizationIds,
983
+ cache: cfDefCache ?? null,
984
+ requestScope: ctx,
916
985
  })
917
986
  cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
918
987
  const decoratedItems = items.map((raw) => {
@@ -954,6 +1023,36 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
954
1023
  }
955
1024
  }
956
1025
 
1026
+ // Phase 3 — per-request userFeatures memo. CrudCtx is a plain object that
1027
+ // lives only for the duration of one HTTP request, so a WeakMap keyed on
1028
+ // ctx is the right scope: interceptor + enricher + any future call site
1029
+ // share one Promise<string[] | undefined> per request. The cache lifetime
1030
+ // is bounded by the request and cannot desync from mid-request grant
1031
+ // changes because RBAC grants never change mid-request.
1032
+ const userFeaturesPromiseCache = new WeakMap<object, Promise<string[] | undefined>>()
1033
+
1034
+ function resolveUserFeaturesOnce(ctx: CrudCtx): Promise<string[] | undefined> {
1035
+ if (!ctx.auth) return Promise.resolve(undefined)
1036
+ const cached = userFeaturesPromiseCache.get(ctx)
1037
+ if (cached) return cached
1038
+ const promise = (async () => {
1039
+ try {
1040
+ const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
1041
+ if (rbac?.getGrantedFeatures) {
1042
+ return await rbac.getGrantedFeatures(ctx.auth!.sub, {
1043
+ tenantId: ctx.auth!.tenantId,
1044
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth!.orgId,
1045
+ })
1046
+ }
1047
+ } catch {
1048
+ // rbacService not available — enrichers without feature requirements still run
1049
+ }
1050
+ return undefined
1051
+ })()
1052
+ userFeaturesPromiseCache.set(ctx, promise)
1053
+ return promise
1054
+ }
1055
+
957
1056
  /**
958
1057
  * Build enricher context from CRUD context and resolve user features for ACL gating.
959
1058
  * Returns null if enrichers are not configured or auth is missing.
@@ -962,18 +1061,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
962
1061
  if (!opts.enrichers?.entityId) return null
963
1062
  if (!ctx.auth) return null
964
1063
 
965
- let userFeatures: string[] | undefined
966
- try {
967
- const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
968
- if (rbac?.getGrantedFeatures) {
969
- userFeatures = await rbac.getGrantedFeatures(ctx.auth.sub, {
970
- tenantId: ctx.auth.tenantId,
971
- organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
972
- })
973
- }
974
- } catch {
975
- // rbacService not available — enrichers without feature requirements still run
976
- }
1064
+ const userFeatures = await resolveUserFeaturesOnce(ctx)
977
1065
 
978
1066
  return {
979
1067
  organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? '',
@@ -986,19 +1074,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
986
1074
  }
987
1075
 
988
1076
  async function resolveUserFeatures(ctx: CrudCtx): Promise<string[] | undefined> {
989
- if (!ctx.auth) return undefined
990
- try {
991
- const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined
992
- if (rbac?.getGrantedFeatures) {
993
- return await rbac.getGrantedFeatures(ctx.auth.sub, {
994
- tenantId: ctx.auth.tenantId,
995
- organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
996
- })
997
- }
998
- } catch {
999
- // rbacService is optional in some contexts
1000
- }
1001
- return undefined
1077
+ return resolveUserFeaturesOnce(ctx)
1002
1078
  }
1003
1079
 
1004
1080
  const interceptorContextCache = new WeakMap<object, ReturnType<typeof buildInterceptorContextInner>>()
@@ -1514,7 +1590,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1514
1590
  profiler.mark('translation_overlays_complete', { itemCount: transformedItems.length })
1515
1591
  }
1516
1592
 
1517
- await logCrudAccess({
1593
+ const accessLogResult = await logCrudAccess({
1518
1594
  container: ctx.container,
1519
1595
  auth: ctx.auth,
1520
1596
  request,
@@ -1525,7 +1601,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1525
1601
  tenantId: ctx.auth.tenantId ?? null,
1526
1602
  query: validated,
1527
1603
  })
1528
- profiler.mark('access_logged')
1604
+ profiler.mark('access_logged', accessLogResult)
1529
1605
 
1530
1606
  if (exportRequested && requestedExport) {
1531
1607
  const total = typeof res.total === 'number' ? res.total : rawItems.length
@@ -1746,7 +1822,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1746
1822
  profiler.mark('fallback_translation_overlays_complete', { itemCount: Array.isArray(list) ? list.length : 0 })
1747
1823
  }
1748
1824
 
1749
- await logCrudAccess({
1825
+ const accessLogResult = await logCrudAccess({
1750
1826
  container: ctx.container,
1751
1827
  auth: ctx.auth,
1752
1828
  request,
@@ -1757,7 +1833,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1757
1833
  tenantId: ctx.auth.tenantId ?? null,
1758
1834
  query: validated,
1759
1835
  })
1760
- profiler.mark('access_logged')
1836
+ profiler.mark('access_logged', accessLogResult)
1761
1837
  if (exportRequested && requestedExport) {
1762
1838
  const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
1763
1839
  const prepared = exportFullRequested
@@ -0,0 +1,182 @@
1
+ // Unit tests for the Phase 5 process-scoped bootstrap once-guard.
2
+ //
3
+ // The once-guard caches `cache`, `eventBus`, `kmsService`, etc. on
4
+ // `globalThis` after the first `createRequestContainer()` call so that
5
+ // subsequent calls in the same process re-register those services
6
+ // directly without re-importing `@open-mercato/core/bootstrap`.
7
+ // `registerDiRegistrars` clears the cache so HMR sees fresh subscribers.
8
+ // `OM_BOOTSTRAP_CACHE` gates the whole behavior; default OFF.
9
+
10
+ import { asValue } from 'awilix'
11
+
12
+ // Mock the deep ORM/engine imports BEFORE importing container.ts so we can
13
+ // exercise the bootstrap once-guard without pulling in MikroORM decorators.
14
+ jest.mock(
15
+ '@open-mercato/shared/lib/db/mikro',
16
+ () => {
17
+ const baseEm: any = {
18
+ fork: () => baseEm,
19
+ getRepository: () => ({ find: async () => [], findOne: async () => null }),
20
+ }
21
+ return {
22
+ __esModule: true,
23
+ getOrm: async () => ({ em: baseEm }),
24
+ getOrmEntities: () => [],
25
+ registerOrmEntities: () => {},
26
+ }
27
+ },
28
+ { virtual: false },
29
+ )
30
+
31
+ jest.mock(
32
+ '@mikro-orm/core',
33
+ () => ({
34
+ __esModule: true,
35
+ RequestContext: { getEntityManager: () => null },
36
+ }),
37
+ { virtual: true },
38
+ )
39
+
40
+ jest.mock(
41
+ '@open-mercato/shared/lib/query/engine',
42
+ () => ({ __esModule: true, BasicQueryEngine: class {} }),
43
+ { virtual: false },
44
+ )
45
+
46
+ jest.mock(
47
+ '@open-mercato/shared/lib/data/engine',
48
+ () => ({ __esModule: true, DefaultDataEngine: class {} }),
49
+ { virtual: false },
50
+ )
51
+
52
+ jest.mock(
53
+ '@open-mercato/shared/lib/commands',
54
+ () => ({
55
+ __esModule: true,
56
+ commandRegistry: {},
57
+ CommandBus: class { constructor() {} },
58
+ }),
59
+ { virtual: false },
60
+ )
61
+
62
+ jest.mock(
63
+ '@open-mercato/shared/modules/overrides',
64
+ () => ({
65
+ __esModule: true,
66
+ applyDiOverridesToContainer: () => {},
67
+ }),
68
+ { virtual: false },
69
+ )
70
+
71
+ const {
72
+ registerDiRegistrars,
73
+ resetBootstrapCache,
74
+ } = require('@open-mercato/shared/lib/di/container')
75
+
76
+ const bootstrapMock = jest.fn(async (container: any) => {
77
+ container.register({
78
+ cache: asValue({ __value: 'cache-value' }),
79
+ eventBus: asValue({ __value: 'event-bus-value' }),
80
+ tenantEncryptionService: asValue({ isEnabled: () => true, __value: 'enc' }),
81
+ })
82
+ })
83
+
84
+ jest.mock(
85
+ '@open-mercato/core/bootstrap',
86
+ () => ({
87
+ __esModule: true,
88
+ bootstrap: (...args: unknown[]) => bootstrapMock(...args as [any]),
89
+ }),
90
+ { virtual: true },
91
+ )
92
+
93
+ const subscriberRegistered = jest.fn()
94
+ jest.mock(
95
+ '@open-mercato/shared/lib/encryption/subscriber',
96
+ () => ({
97
+ __esModule: true,
98
+ registerTenantEncryptionSubscriber: (...args: unknown[]) => subscriberRegistered(...args),
99
+ }),
100
+ { virtual: true },
101
+ )
102
+
103
+ const ORIGINAL_FLAG = process.env.OM_BOOTSTRAP_CACHE
104
+
105
+ describe('bootstrap once-guard cache', () => {
106
+ beforeEach(() => {
107
+ resetBootstrapCache()
108
+ bootstrapMock.mockClear()
109
+ subscriberRegistered.mockClear()
110
+ registerDiRegistrars([])
111
+ })
112
+
113
+ afterAll(() => {
114
+ if (ORIGINAL_FLAG === undefined) delete process.env.OM_BOOTSTRAP_CACHE
115
+ else process.env.OM_BOOTSTRAP_CACHE = ORIGINAL_FLAG
116
+ })
117
+
118
+ it('resetBootstrapCache clears the process-scoped bootstrap cache and encryption flag', () => {
119
+ const g = globalThis as Record<string, unknown>
120
+ g.__openMercatoBootstrapCache__ = { cache: { tag: 'memo' } }
121
+ g.__openMercatoEncryptionEnabledCache__ = true
122
+ resetBootstrapCache()
123
+ expect(g.__openMercatoBootstrapCache__).toBeNull()
124
+ expect(g.__openMercatoEncryptionEnabledCache__).toBeUndefined()
125
+ })
126
+
127
+ it('runs bootstrap() on the first createRequestContainer() and replays cached services on the second when OM_BOOTSTRAP_CACHE=1', async () => {
128
+ process.env.OM_BOOTSTRAP_CACHE = '1'
129
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
130
+ const first = await createRequestContainer()
131
+ expect(bootstrapMock).toHaveBeenCalledTimes(1)
132
+ expect(first.resolve('cache')).toMatchObject({ __value: 'cache-value' })
133
+
134
+ const second = await createRequestContainer()
135
+ // Bootstrap MUST NOT run again — the cached entries are replayed.
136
+ expect(bootstrapMock).toHaveBeenCalledTimes(1)
137
+ expect(second.resolve('cache')).toMatchObject({ __value: 'cache-value' })
138
+ expect(second.resolve('eventBus')).toMatchObject({ __value: 'event-bus-value' })
139
+ })
140
+
141
+ it('runs bootstrap() on every createRequestContainer() when OM_BOOTSTRAP_CACHE is unset (default)', async () => {
142
+ delete process.env.OM_BOOTSTRAP_CACHE
143
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
144
+ await createRequestContainer()
145
+ await createRequestContainer()
146
+ expect(bootstrapMock).toHaveBeenCalledTimes(2)
147
+ })
148
+
149
+ it('registerDiRegistrars clears the cache so HMR re-runs bootstrap on the next request', async () => {
150
+ process.env.OM_BOOTSTRAP_CACHE = '1'
151
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
152
+ await createRequestContainer()
153
+ expect(bootstrapMock).toHaveBeenCalledTimes(1)
154
+ registerDiRegistrars([])
155
+ await createRequestContainer()
156
+ expect(bootstrapMock).toHaveBeenCalledTimes(2)
157
+ })
158
+
159
+ it('memoizes tenantEncryptionService.isEnabled() across requests', async () => {
160
+ process.env.OM_BOOTSTRAP_CACHE = '1'
161
+ let isEnabledCalls = 0
162
+ bootstrapMock.mockImplementationOnce(async (container: any) => {
163
+ container.register({
164
+ cache: asValue({ __value: 'cache-value' }),
165
+ eventBus: asValue({ __value: 'event-bus-value' }),
166
+ tenantEncryptionService: asValue({
167
+ isEnabled: () => {
168
+ isEnabledCalls += 1
169
+ return true
170
+ },
171
+ }),
172
+ })
173
+ })
174
+ const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
175
+ await createRequestContainer()
176
+ await createRequestContainer()
177
+ await createRequestContainer()
178
+ // Called once during the first bootstrap, then cached on globalThis.
179
+ expect(isEnabledCalls).toBe(1)
180
+ expect(subscriberRegistered).toHaveBeenCalledTimes(3)
181
+ })
182
+ })