@open-mercato/shared 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3894.1.352abf4240
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
|
@@ -337,24 +337,113 @@ function selectDefinitionForRecord(
|
|
|
337
337
|
return sortDefinitionSummaries(defs)[0] ?? null
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
|
|
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
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|