@open-mercato/shared 0.7.1-develop.7154.1.981330c924 → 0.7.1-develop.7171.1.9b31dbba45
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/AGENTS.md +1 -1
- package/dist/lib/bootstrap/dynamicLoader.js +62 -4
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/crud/enricher-registry.js +1 -1
- package/dist/lib/crud/enricher-registry.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +98 -29
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -1
- package/dist/lib/crud/factory.js.map +3 -3
- package/dist/lib/db/pg-errors.js +38 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/number.js +67 -1
- package/dist/lib/number.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/__tests__/number.test.ts +91 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +279 -0
- package/src/lib/bootstrap/dynamicLoader.ts +105 -5
- package/src/lib/crud/__tests__/crud-factory.test.ts +23 -0
- package/src/lib/crud/__tests__/enricher-registry.test.ts +16 -0
- package/src/lib/crud/__tests__/enricher-runner.read-through-cache.test.ts +291 -0
- package/src/lib/crud/__tests__/enricher-runner.test.ts +59 -0
- package/src/lib/crud/enricher-registry.ts +2 -1
- package/src/lib/crud/enricher-runner.ts +171 -30
- package/src/lib/crud/factory.ts +30 -1
- package/src/lib/crud/response-enricher.ts +26 -2
- package/src/lib/db/__tests__/pg-errors.test.ts +59 -1
- package/src/lib/db/pg-errors.ts +57 -0
- package/src/lib/number.ts +103 -0
|
@@ -3,6 +3,10 @@ import type { AppDiRegistrar } from '../di/container'
|
|
|
3
3
|
import { findAppRoot, type AppRoot } from './appResolver'
|
|
4
4
|
import { registerEntityIds } from '../encryption/entityIds'
|
|
5
5
|
import { createLogger } from '../logger'
|
|
6
|
+
import {
|
|
7
|
+
applyModuleOverridesFromEnabledModules,
|
|
8
|
+
type ModuleEntryWithOverrides,
|
|
9
|
+
} from '../../modules/overrides'
|
|
6
10
|
import {
|
|
7
11
|
ensureMikroOrmV7GeneratedCacheCompatibility,
|
|
8
12
|
recoverMikroOrmV7GeneratedCacheFromImportError,
|
|
@@ -589,6 +593,95 @@ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null
|
|
|
589
593
|
}
|
|
590
594
|
}
|
|
591
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Override domains whose applier is not registered by `registerBuiltInModuleOverrideAppliers()`
|
|
598
|
+
* but by importing a domain package for its side effect. `bootstrap-common.ts` does this with a
|
|
599
|
+
* static import right before it dispatches; the dynamic bootstrap path has no bundler to lean on,
|
|
600
|
+
* so it resolves the same modules here — lazily, and only when an app actually declares the
|
|
601
|
+
* domain, so `@open-mercato/shared` keeps its rule of never taking a runtime dependency on a
|
|
602
|
+
* domain package (soft-optional coupling, `packages/core/AGENTS.md` → Cross-Module Coupling).
|
|
603
|
+
*/
|
|
604
|
+
const OPTIONAL_OVERRIDE_APPLIER_MODULES: Record<string, string> = {
|
|
605
|
+
ai: '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-overrides',
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Import the side-effect module that registers the applier for every declared override domain
|
|
610
|
+
* that has no built-in one. A domain package the app does not install is not an error — there
|
|
611
|
+
* is nothing for that domain to apply to — so a failed resolution is logged and skipped, and the
|
|
612
|
+
* dispatcher's own "domain not yet wired" warning still fires behind it.
|
|
613
|
+
*/
|
|
614
|
+
async function ensureOptionalOverrideAppliers(enabledModules: ModuleEntryWithOverrides[]): Promise<void> {
|
|
615
|
+
for (const [domain, specifier] of Object.entries(OPTIONAL_OVERRIDE_APPLIER_MODULES)) {
|
|
616
|
+
const declared = enabledModules.some((entry) => {
|
|
617
|
+
const overrides = entry?.overrides as Record<string, unknown> | undefined
|
|
618
|
+
return Boolean(overrides && overrides[domain])
|
|
619
|
+
})
|
|
620
|
+
if (!declared) continue
|
|
621
|
+
try {
|
|
622
|
+
await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier)
|
|
623
|
+
} catch (error) {
|
|
624
|
+
logger.debug('Optional override applier module is not installed; the domain has nothing to apply to', {
|
|
625
|
+
domain,
|
|
626
|
+
specifier,
|
|
627
|
+
err: error,
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Dispatch `entry.overrides` declared in the app's `src/modules.ts` for the dynamic
|
|
635
|
+
* bootstrap path.
|
|
636
|
+
*
|
|
637
|
+
* The Next.js runtime imports `enabledModules` statically from its own `src/modules.ts` and
|
|
638
|
+
* calls `applyModuleOverridesFromEnabledModules` from `bootstrap-common.ts` before any registry
|
|
639
|
+
* first-loads. Worker, scheduler and CLI processes bootstrap through `bootstrapFromAppRoot`
|
|
640
|
+
* instead, which only ever compiled the generated `modules.cli.generated.ts` — so an app's
|
|
641
|
+
* `entry.overrides` (encryption maps, ACL features, CLI commands, workers, event subscribers,
|
|
642
|
+
* setup, …) silently never applied there. `seed-encryption` seeding the base encryption maps
|
|
643
|
+
* instead of the app's `overrides.encryption.maps` was the concrete symptom (#5582).
|
|
644
|
+
*
|
|
645
|
+
* An app layout with no `src/modules.ts` at all is logged and skipped — that is a real
|
|
646
|
+
* compatibility case, handled the same way an absent `src/di.ts` is. A file that is *present*
|
|
647
|
+
* but fails to compile or import is not: it throws, matching how this same function treats
|
|
648
|
+
* every other mandatory input and how the Next.js runtime treats this same file (a static
|
|
649
|
+
* import in `bootstrap-common.ts`). Degrading there would put `seed-encryption` back on the
|
|
650
|
+
* base encryption maps while still printing success — #5582's outcome, only quieter.
|
|
651
|
+
*/
|
|
652
|
+
async function loadAppModuleOverrides(appDir: string): Promise<void> {
|
|
653
|
+
const tsPath = path.join(appDir, 'src', 'modules.ts')
|
|
654
|
+
if (!fs.existsSync(tsPath)) {
|
|
655
|
+
logger.debug('App-level modules file not present, skipping entry.overrides dispatch', { filePath: tsPath })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
let enabledModules: unknown
|
|
660
|
+
try {
|
|
661
|
+
const appModulesModule = await compileAndImport(tsPath, {
|
|
662
|
+
appRoot: appDir,
|
|
663
|
+
outFile: path.join(appDir, '.mercato', 'generated', 'app-modules-overrides.compiled.mjs'),
|
|
664
|
+
})
|
|
665
|
+
enabledModules = appModulesModule.enabledModules
|
|
666
|
+
} catch (error) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`[internal] Failed to load the app-level modules file (${tsPath}); entry.overrides cannot be applied. ` +
|
|
669
|
+
'Refusing to bootstrap with a partial override set.',
|
|
670
|
+
{ cause: error },
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!Array.isArray(enabledModules)) {
|
|
675
|
+
throw new Error(
|
|
676
|
+
`[internal] The app-level modules file (${tsPath}) exports no enabledModules array; ` +
|
|
677
|
+
'entry.overrides cannot be applied. Refusing to bootstrap with a partial override set.',
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
await ensureOptionalOverrideAppliers(enabledModules as ModuleEntryWithOverrides[])
|
|
682
|
+
applyModuleOverridesFromEnabledModules(enabledModules as ModuleEntryWithOverrides[])
|
|
683
|
+
}
|
|
684
|
+
|
|
592
685
|
/**
|
|
593
686
|
* Dynamically load bootstrap data from a resolved app directory.
|
|
594
687
|
*
|
|
@@ -681,13 +774,20 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
681
774
|
export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
|
|
682
775
|
const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
|
|
683
776
|
const resolved = resolveAppRootOrThrow(appRoot)
|
|
684
|
-
//
|
|
777
|
+
// All three loads compile through esbuild, so they share one lifecycle scope: without it
|
|
685
778
|
// `loadBootstrapData` releases the esbuild helper process and `loadAppDiRegistrar`
|
|
686
779
|
// silently starts a second one that nothing ever stops.
|
|
687
|
-
const { data, appDiRegistrar } = await withEsbuildLifecycle(async () =>
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
780
|
+
const { data, appDiRegistrar } = await withEsbuildLifecycle(async () => {
|
|
781
|
+
// Dispatch the app's `entry.overrides` (src/modules.ts) BEFORE any registry
|
|
782
|
+
// first-loads — the `bootstrap()` call below runs `registerModules(data.modules)`,
|
|
783
|
+
// and `registerCliModules` in the mercato bin right after this function returns;
|
|
784
|
+
// both read the override side-registry this populates.
|
|
785
|
+
await loadAppModuleOverrides(resolved.appDir)
|
|
786
|
+
return {
|
|
787
|
+
data: await loadBootstrapData(resolved.appDir),
|
|
788
|
+
appDiRegistrar: await loadAppDiRegistrar(resolved.appDir),
|
|
789
|
+
}
|
|
790
|
+
})
|
|
691
791
|
const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
|
|
692
792
|
bootstrap()
|
|
693
793
|
// In CLI context, wait for async registrations (UI widgets, search configs, etc.)
|
|
@@ -922,6 +922,29 @@ describe('CRUD Factory', () => {
|
|
|
922
922
|
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
923
923
|
})
|
|
924
924
|
|
|
925
|
+
it('returns a correlated 409 without leaking the constraint name when a handler hits a foreign key violation', async () => {
|
|
926
|
+
setRecordCustomFields.mockImplementationOnce(async () => {
|
|
927
|
+
// Mirror MikroORM's wrapping: the pg error sits behind `previous`, and the
|
|
928
|
+
// wrapper only carries the message.
|
|
929
|
+
throw Object.assign(
|
|
930
|
+
new Error('update or delete on table "users" violates foreign key constraint "sidebar_variants_user_id_foreign" on table "sidebar_variants"'),
|
|
931
|
+
{ previous: { code: '23503', constraint: 'sidebar_variants_user_id_foreign' } },
|
|
932
|
+
)
|
|
933
|
+
})
|
|
934
|
+
const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Referenced', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
|
|
935
|
+
expect(res.status).toBe(409)
|
|
936
|
+
const body = await res.json()
|
|
937
|
+
expect(body.code).toBe('FOREIGN_KEY_VIOLATION')
|
|
938
|
+
// Internal schema names stay in the server log, never in the client body.
|
|
939
|
+
expect(body.constraint).toBeUndefined()
|
|
940
|
+
expect(JSON.stringify(body)).not.toContain('sidebar_variants_user_id_foreign')
|
|
941
|
+
// Same correlation contract as the generic 500 path.
|
|
942
|
+
expect(typeof body.requestId).toBe('string')
|
|
943
|
+
expect(res.headers.get('x-request-id')).toBe(body.requestId)
|
|
944
|
+
expect(Object.values(db)).toHaveLength(0)
|
|
945
|
+
expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
|
|
946
|
+
})
|
|
947
|
+
|
|
925
948
|
it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
|
|
926
949
|
mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
|
|
927
950
|
throw new Error('index write failed')
|
|
@@ -37,6 +37,22 @@ describe('enricher-registry', () => {
|
|
|
37
37
|
expect(result[0].enricher.id).toBe('a.tier')
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
it('includes wildcard enrichers in priority order for a concrete entity', () => {
|
|
41
|
+
registerResponseEnrichers([
|
|
42
|
+
{
|
|
43
|
+
moduleId: 'mod-a',
|
|
44
|
+
enrichers: [
|
|
45
|
+
makeEnricher({ id: 'a.exact', targetEntity: 'customers.person', priority: 10 }),
|
|
46
|
+
makeEnricher({ id: 'a.wildcard', targetEntity: '*', priority: 50 }),
|
|
47
|
+
makeEnricher({ id: 'a.other', targetEntity: 'sales.order', priority: 100 }),
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const result = getEnrichersForEntity('customers.person')
|
|
53
|
+
expect(result.map((entry) => entry.enricher.id)).toEqual(['a.wildcard', 'a.exact'])
|
|
54
|
+
})
|
|
55
|
+
|
|
40
56
|
it('returns enrichers regardless of queryEngine config', () => {
|
|
41
57
|
registerResponseEnrichers([
|
|
42
58
|
{
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { applyResponseEnrichers, applyResponseEnricherToRecord } from '../enricher-runner'
|
|
2
|
+
import { registerResponseEnrichers } from '../enricher-registry'
|
|
3
|
+
import type { EnricherContext, ResponseEnricher } from '../response-enricher'
|
|
4
|
+
|
|
5
|
+
type Record_ = Record<string, unknown>
|
|
6
|
+
|
|
7
|
+
function createCache() {
|
|
8
|
+
const store = new Map<string, unknown>()
|
|
9
|
+
const set = jest.fn(async (key: string, value: unknown) => {
|
|
10
|
+
store.set(key, value)
|
|
11
|
+
})
|
|
12
|
+
const get = jest.fn(async (key: string) => (store.has(key) ? store.get(key) : null))
|
|
13
|
+
return { store, cache: { get, set } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createContext(cache: { get: jest.Mock; set: jest.Mock } | null): EnricherContext {
|
|
17
|
+
return {
|
|
18
|
+
tenantId: 'tenant-1',
|
|
19
|
+
organizationId: 'org-1',
|
|
20
|
+
userFeatures: ['demo.view'],
|
|
21
|
+
container: cache ? { resolve: (name: string) => (name === 'cache' ? cache : null) } : undefined,
|
|
22
|
+
} as unknown as EnricherContext
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TARGET = 'demo:widget'
|
|
26
|
+
|
|
27
|
+
function defineEnricher(
|
|
28
|
+
enrichMany: (records: Record_[]) => Record_[],
|
|
29
|
+
overrides: Partial<ResponseEnricher> = {},
|
|
30
|
+
): ResponseEnricher {
|
|
31
|
+
return {
|
|
32
|
+
id: 'demo.enricher',
|
|
33
|
+
targetEntity: TARGET,
|
|
34
|
+
priority: 10,
|
|
35
|
+
cache: { strategy: 'read-through', ttl: 30_000, tags: ['demo:widgets'] },
|
|
36
|
+
async enrichOne(record: Record_) {
|
|
37
|
+
return enrichMany([record])[0]
|
|
38
|
+
},
|
|
39
|
+
async enrichMany(records: Record_[]) {
|
|
40
|
+
return enrichMany(records)
|
|
41
|
+
},
|
|
42
|
+
...overrides,
|
|
43
|
+
} as ResponseEnricher
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function register(enricher: ResponseEnricher) {
|
|
47
|
+
registerResponseEnrichers([{ moduleId: 'demo', enrichers: [enricher] }])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('enricher runner read-through cache', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
registerResponseEnrichers([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('serves the enrichment from cache on the second call without re-running the enricher', async () => {
|
|
56
|
+
const { cache } = createCache()
|
|
57
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
58
|
+
records.map((record) => ({ ...record, _demo: { stock: 7 } })),
|
|
59
|
+
)
|
|
60
|
+
register(defineEnricher(enrichMany))
|
|
61
|
+
|
|
62
|
+
const items = [{ id: 'a', name: 'first' }]
|
|
63
|
+
const first = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
64
|
+
expect(first.items[0]._demo).toEqual({ stock: 7 })
|
|
65
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
66
|
+
|
|
67
|
+
const second = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
68
|
+
expect(second.items[0]._demo).toEqual({ stock: 7 })
|
|
69
|
+
expect(second._meta.enrichedBy).toEqual(['demo.enricher'])
|
|
70
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps base record fields fresh on a cache hit instead of replaying the cached snapshot', async () => {
|
|
74
|
+
const { cache } = createCache()
|
|
75
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
76
|
+
records.map((record) => ({ ...record, _demo: { stock: 7 } })),
|
|
77
|
+
)
|
|
78
|
+
register(defineEnricher(enrichMany))
|
|
79
|
+
|
|
80
|
+
await applyResponseEnrichers([{ id: 'a', name: 'old name' }], TARGET, createContext(cache))
|
|
81
|
+
|
|
82
|
+
const second = await applyResponseEnrichers(
|
|
83
|
+
[{ id: 'a', name: 'new name' }],
|
|
84
|
+
TARGET,
|
|
85
|
+
createContext(cache),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
89
|
+
expect(second.items[0].name).toBe('new name')
|
|
90
|
+
expect(second.items[0]._demo).toEqual({ stock: 7 })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not cache an enricher that mutates a pre-existing field', async () => {
|
|
94
|
+
const { cache } = createCache()
|
|
95
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
96
|
+
records.map((record) => ({ ...record, name: 'rewritten', _demo: { stock: 1 } })),
|
|
97
|
+
)
|
|
98
|
+
register(defineEnricher(enrichMany))
|
|
99
|
+
|
|
100
|
+
const items = [{ id: 'a', name: 'original' }]
|
|
101
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
102
|
+
expect(cache.set).not.toHaveBeenCalled()
|
|
103
|
+
|
|
104
|
+
const second = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
105
|
+
expect(enrichMany).toHaveBeenCalledTimes(2)
|
|
106
|
+
expect(second.items[0].name).toBe('rewritten')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not cache a batch containing a record without a usable id', async () => {
|
|
110
|
+
const { cache } = createCache()
|
|
111
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
112
|
+
records.map((record) => ({ ...record, _demo: { stock: 3 } })),
|
|
113
|
+
)
|
|
114
|
+
register(defineEnricher(enrichMany))
|
|
115
|
+
|
|
116
|
+
await applyResponseEnrichers([{ name: 'no id' }], TARGET, createContext(cache))
|
|
117
|
+
|
|
118
|
+
expect(cache.set).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('re-runs the enricher when the cached envelope does not cover every record', async () => {
|
|
122
|
+
const { store, cache } = createCache()
|
|
123
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
124
|
+
records.map((record) => ({ ...record, _demo: { stock: 5 } })),
|
|
125
|
+
)
|
|
126
|
+
register(defineEnricher(enrichMany))
|
|
127
|
+
|
|
128
|
+
const items = [{ id: 'a' }, { id: 'b' }]
|
|
129
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
130
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
131
|
+
|
|
132
|
+
const [cacheKey] = Array.from(store.keys())
|
|
133
|
+
store.set(cacheKey, { version: 1, deltas: { a: { _demo: { stock: 5 } } } })
|
|
134
|
+
|
|
135
|
+
const second = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
136
|
+
expect(enrichMany).toHaveBeenCalledTimes(2)
|
|
137
|
+
expect(second.items.map((item) => item._demo)).toEqual([{ stock: 5 }, { stock: 5 }])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('ignores a cached payload written under a different envelope version', async () => {
|
|
141
|
+
const { store, cache } = createCache()
|
|
142
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
143
|
+
records.map((record) => ({ ...record, _demo: { stock: 9 } })),
|
|
144
|
+
)
|
|
145
|
+
register(defineEnricher(enrichMany))
|
|
146
|
+
|
|
147
|
+
const items = [{ id: 'a' }]
|
|
148
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
149
|
+
|
|
150
|
+
const [cacheKey] = Array.from(store.keys())
|
|
151
|
+
store.set(cacheKey, { version: 99, deltas: { a: { _demo: { stock: 0 } } } })
|
|
152
|
+
|
|
153
|
+
const second = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
154
|
+
expect(enrichMany).toHaveBeenCalledTimes(2)
|
|
155
|
+
expect(second.items[0]._demo).toEqual({ stock: 9 })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('caches the delta of an enricher that mutates the records in place', async () => {
|
|
159
|
+
// The contract does not forbid in-place mutation. Comparing a mutated
|
|
160
|
+
// record against itself would produce an empty delta, so the cache would
|
|
161
|
+
// then serve unenriched records for the rest of the TTL.
|
|
162
|
+
const { cache } = createCache()
|
|
163
|
+
const enrichMany = jest.fn((records: Record_[]) => {
|
|
164
|
+
for (const record of records) {
|
|
165
|
+
record._demo = { stock: 11 }
|
|
166
|
+
}
|
|
167
|
+
return records
|
|
168
|
+
})
|
|
169
|
+
register(defineEnricher(enrichMany))
|
|
170
|
+
|
|
171
|
+
const first = await applyResponseEnrichers(
|
|
172
|
+
[{ id: 'a', name: 'first' }],
|
|
173
|
+
TARGET,
|
|
174
|
+
createContext(cache),
|
|
175
|
+
)
|
|
176
|
+
expect(first.items[0]._demo).toEqual({ stock: 11 })
|
|
177
|
+
expect(cache.set).toHaveBeenCalledTimes(1)
|
|
178
|
+
|
|
179
|
+
const second = await applyResponseEnrichers(
|
|
180
|
+
[{ id: 'a', name: 'first' }],
|
|
181
|
+
TARGET,
|
|
182
|
+
createContext(cache),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
186
|
+
expect(second.items[0]._demo).toEqual({ stock: 11 })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('caches an empty delta when the enricher genuinely adds nothing', async () => {
|
|
190
|
+
const { cache } = createCache()
|
|
191
|
+
const enrichMany = jest.fn((records: Record_[]) => records)
|
|
192
|
+
register(defineEnricher(enrichMany))
|
|
193
|
+
|
|
194
|
+
const items = [{ id: 'a', name: 'unchanged' }]
|
|
195
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
196
|
+
expect(cache.set).toHaveBeenCalledTimes(1)
|
|
197
|
+
|
|
198
|
+
const second = await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
199
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
200
|
+
expect(second.items[0]).toEqual({ id: 'a', name: 'unchanged' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('does not let a cache hit mutate the caller\'s input records', async () => {
|
|
204
|
+
const { cache } = createCache()
|
|
205
|
+
register(
|
|
206
|
+
defineEnricher((records) => records.map((record) => ({ ...record, _demo: { stock: 8 } }))),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
await applyResponseEnrichers([{ id: 'a' }], TARGET, createContext(cache))
|
|
210
|
+
|
|
211
|
+
const input = [{ id: 'a' }]
|
|
212
|
+
const second = await applyResponseEnrichers(input, TARGET, createContext(cache))
|
|
213
|
+
|
|
214
|
+
expect(second.items[0]._demo).toEqual({ stock: 8 })
|
|
215
|
+
expect(input[0]).toEqual({ id: 'a' })
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('never touches the cache for an enricher that did not opt in', async () => {
|
|
219
|
+
const { cache } = createCache()
|
|
220
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
221
|
+
records.map((record) => ({ ...record, _demo: { stock: 2 } })),
|
|
222
|
+
)
|
|
223
|
+
register(defineEnricher(enrichMany, { cache: undefined }))
|
|
224
|
+
|
|
225
|
+
const items = [{ id: 'a' }]
|
|
226
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
227
|
+
await applyResponseEnrichers(items, TARGET, createContext(cache))
|
|
228
|
+
|
|
229
|
+
expect(cache.get).not.toHaveBeenCalled()
|
|
230
|
+
expect(cache.set).not.toHaveBeenCalled()
|
|
231
|
+
expect(enrichMany).toHaveBeenCalledTimes(2)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('writes the enrichment tags alongside the automatic tenant, organization and enricher tags', async () => {
|
|
235
|
+
const { cache } = createCache()
|
|
236
|
+
register(
|
|
237
|
+
defineEnricher((records) => records.map((record) => ({ ...record, _demo: { stock: 1 } }))),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
await applyResponseEnrichers([{ id: 'a' }], TARGET, createContext(cache))
|
|
241
|
+
|
|
242
|
+
expect(cache.set).toHaveBeenCalledTimes(1)
|
|
243
|
+
const [, , options] = cache.set.mock.calls[0]
|
|
244
|
+
expect(options.ttl).toBe(30_000)
|
|
245
|
+
expect(options.tags).toEqual(
|
|
246
|
+
expect.arrayContaining([
|
|
247
|
+
'tenant:tenant-1',
|
|
248
|
+
'organization:org-1',
|
|
249
|
+
'enricher:demo.enricher',
|
|
250
|
+
'demo:widgets',
|
|
251
|
+
]),
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('caches the single-record path and keeps its base fields fresh', async () => {
|
|
256
|
+
const { cache } = createCache()
|
|
257
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
258
|
+
records.map((record) => ({ ...record, _demo: { stock: 4 } })),
|
|
259
|
+
)
|
|
260
|
+
register(defineEnricher(enrichMany))
|
|
261
|
+
|
|
262
|
+
const first = await applyResponseEnricherToRecord(
|
|
263
|
+
{ id: 'a', name: 'old' },
|
|
264
|
+
TARGET,
|
|
265
|
+
createContext(cache),
|
|
266
|
+
)
|
|
267
|
+
expect(first.record._demo).toEqual({ stock: 4 })
|
|
268
|
+
|
|
269
|
+
const second = await applyResponseEnricherToRecord(
|
|
270
|
+
{ id: 'a', name: 'new' },
|
|
271
|
+
TARGET,
|
|
272
|
+
createContext(cache),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
276
|
+
expect(second.record.name).toBe('new')
|
|
277
|
+
expect(second.record._demo).toEqual({ stock: 4 })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('runs normally when no cache service is available in the container', async () => {
|
|
281
|
+
const enrichMany = jest.fn((records: Record_[]) =>
|
|
282
|
+
records.map((record) => ({ ...record, _demo: { stock: 6 } })),
|
|
283
|
+
)
|
|
284
|
+
register(defineEnricher(enrichMany))
|
|
285
|
+
|
|
286
|
+
const result = await applyResponseEnrichers([{ id: 'a' }], TARGET, createContext(null))
|
|
287
|
+
|
|
288
|
+
expect(result.items[0]._demo).toEqual({ stock: 6 })
|
|
289
|
+
expect(enrichMany).toHaveBeenCalledTimes(1)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EnricherContext, ResponseEnricher } from '../response-enricher'
|
|
2
|
+
import { registerResponseEnrichers } from '../enricher-registry'
|
|
3
|
+
import { applyResponseEnricherToRecord } from '../enricher-runner'
|
|
4
|
+
|
|
5
|
+
describe('enricher runner', () => {
|
|
6
|
+
it('partitions wildcard read-through cache entries by concrete entity', async () => {
|
|
7
|
+
const cacheEntries = new Map<string, unknown>()
|
|
8
|
+
const cache = {
|
|
9
|
+
get: jest.fn(async (key: string) => cacheEntries.get(key)),
|
|
10
|
+
set: jest.fn(async (key: string, value: unknown) => {
|
|
11
|
+
cacheEntries.set(key, value)
|
|
12
|
+
}),
|
|
13
|
+
}
|
|
14
|
+
const enrichOne = jest.fn(
|
|
15
|
+
async (record: Record<string, unknown>, context: EnricherContext) => ({
|
|
16
|
+
...record,
|
|
17
|
+
enrichedFrom: context.targetEntity,
|
|
18
|
+
}),
|
|
19
|
+
)
|
|
20
|
+
const enricher: ResponseEnricher<
|
|
21
|
+
Record<string, unknown>,
|
|
22
|
+
{ enrichedFrom: string | undefined }
|
|
23
|
+
> = {
|
|
24
|
+
id: 'test.wildcard-cache',
|
|
25
|
+
targetEntity: '*',
|
|
26
|
+
timeout: 10,
|
|
27
|
+
cache: { strategy: 'read-through', ttl: 60_000 },
|
|
28
|
+
enrichOne,
|
|
29
|
+
}
|
|
30
|
+
registerResponseEnrichers([{ moduleId: 'test', enrichers: [enricher] }])
|
|
31
|
+
|
|
32
|
+
const context: EnricherContext = {
|
|
33
|
+
organizationId: 'org-1',
|
|
34
|
+
tenantId: 'tenant-1',
|
|
35
|
+
userId: 'user-1',
|
|
36
|
+
em: {},
|
|
37
|
+
container: { resolve: () => cache },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const personResult = await applyResponseEnricherToRecord(
|
|
41
|
+
{ id: 'shared-id' },
|
|
42
|
+
'customers.person',
|
|
43
|
+
context,
|
|
44
|
+
)
|
|
45
|
+
const orderResult = await applyResponseEnricherToRecord(
|
|
46
|
+
{ id: 'shared-id' },
|
|
47
|
+
'sales.order',
|
|
48
|
+
context,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(personResult.record).toMatchObject({ enrichedFrom: 'customers.person' })
|
|
52
|
+
expect(orderResult.record).toMatchObject({ enrichedFrom: 'sales.order' })
|
|
53
|
+
expect(enrichOne).toHaveBeenCalledTimes(2)
|
|
54
|
+
expect(Array.from(cacheEntries.keys())).toEqual([
|
|
55
|
+
expect.stringContaining('entity:customers.person'),
|
|
56
|
+
expect.stringContaining('entity:sales.order'),
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -87,7 +87,8 @@ export function getEnrichersForEntity(
|
|
|
87
87
|
selector?: EnricherSurfaceSelector,
|
|
88
88
|
): EnricherRegistryEntry[] {
|
|
89
89
|
const entityEntries = getResponseEnrichers().filter(
|
|
90
|
-
(entry) =>
|
|
90
|
+
(entry) =>
|
|
91
|
+
entry.enricher.targetEntity === targetEntity || entry.enricher.targetEntity === '*',
|
|
91
92
|
)
|
|
92
93
|
|
|
93
94
|
if (!selector || selector.surface === 'api-response') {
|