@open-mercato/shared 0.7.1-develop.7170.1.d95074d7ba → 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/dist/lib/crud/enricher-runner.js +90 -23
- package/dist/lib/crud/enricher-runner.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__/enricher-runner.read-through-cache.test.ts +291 -0
- package/src/lib/crud/enricher-runner.ts +159 -25
- package/src/lib/crud/response-enricher.ts +23 -1
|
@@ -62,11 +62,12 @@ function buildCacheKey(enricher, context, targetEntity, mode, recordIds) {
|
|
|
62
62
|
const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b));
|
|
63
63
|
return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`;
|
|
64
64
|
}
|
|
65
|
+
const UNKNOWN_RECORD_ID = "unknown";
|
|
65
66
|
function extractRecordId(record) {
|
|
66
67
|
const idValue = record.id;
|
|
67
68
|
if (typeof idValue === "string" && idValue.trim().length > 0) return idValue.trim();
|
|
68
69
|
if (typeof idValue === "number") return String(idValue);
|
|
69
|
-
return
|
|
70
|
+
return UNKNOWN_RECORD_ID;
|
|
70
71
|
}
|
|
71
72
|
function getEnricherCacheTtl(enricher) {
|
|
72
73
|
const ttl = enricher.cache?.ttl;
|
|
@@ -96,6 +97,11 @@ async function readEnricherCache(cache, key) {
|
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
99
|
}
|
|
100
|
+
function logSkippedCacheWrite(enricher) {
|
|
101
|
+
logger.debug("Skipped enricher cache write \u2014 enrichment is not purely additive or lacks usable record ids", {
|
|
102
|
+
enricherId: enricher.id
|
|
103
|
+
});
|
|
104
|
+
}
|
|
99
105
|
async function writeEnricherCache(cache, key, value, ttl, tags) {
|
|
100
106
|
if (!cache) return;
|
|
101
107
|
try {
|
|
@@ -103,6 +109,49 @@ async function writeEnricherCache(cache, key, value, ttl, tags) {
|
|
|
103
109
|
} catch {
|
|
104
110
|
}
|
|
105
111
|
}
|
|
112
|
+
const ENRICHER_CACHE_VERSION = 1;
|
|
113
|
+
function isEnricherCacheEnvelope(value) {
|
|
114
|
+
if (typeof value !== "object" || value === null) return false;
|
|
115
|
+
const candidate = value;
|
|
116
|
+
if (candidate.version !== ENRICHER_CACHE_VERSION) return false;
|
|
117
|
+
return typeof candidate.deltas === "object" && candidate.deltas !== null;
|
|
118
|
+
}
|
|
119
|
+
function computeAdditiveDelta(input, output) {
|
|
120
|
+
const delta = {};
|
|
121
|
+
for (const key of Object.keys(input)) {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(output, key)) return null;
|
|
123
|
+
if (output[key] !== input[key]) return null;
|
|
124
|
+
}
|
|
125
|
+
for (const key of Object.keys(output)) {
|
|
126
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) continue;
|
|
127
|
+
delta[key] = output[key];
|
|
128
|
+
}
|
|
129
|
+
return delta;
|
|
130
|
+
}
|
|
131
|
+
function buildCacheEnvelope(inputs, outputs) {
|
|
132
|
+
if (inputs.length !== outputs.length) return null;
|
|
133
|
+
const deltas = {};
|
|
134
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
135
|
+
const recordId = extractRecordId(inputs[index]);
|
|
136
|
+
if (recordId === UNKNOWN_RECORD_ID) return null;
|
|
137
|
+
if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null;
|
|
138
|
+
const delta = computeAdditiveDelta(inputs[index], outputs[index]);
|
|
139
|
+
if (!delta) return null;
|
|
140
|
+
deltas[recordId] = delta;
|
|
141
|
+
}
|
|
142
|
+
return { version: ENRICHER_CACHE_VERSION, deltas };
|
|
143
|
+
}
|
|
144
|
+
function applyCacheEnvelope(envelope, records) {
|
|
145
|
+
const merged = [];
|
|
146
|
+
for (const record of records) {
|
|
147
|
+
const recordId = extractRecordId(record);
|
|
148
|
+
if (recordId === UNKNOWN_RECORD_ID) return null;
|
|
149
|
+
const delta = envelope.deltas[recordId];
|
|
150
|
+
if (!delta || typeof delta !== "object") return null;
|
|
151
|
+
merged.push({ ...record, ...delta });
|
|
152
|
+
}
|
|
153
|
+
return merged;
|
|
154
|
+
}
|
|
106
155
|
async function applyResponseEnrichers(items, targetEntity, context, preFilteredEntries) {
|
|
107
156
|
const enricherContext = { ...context, targetEntity };
|
|
108
157
|
const activeEntries = preFilteredEntries ? filterByACLAndTenant(preFilteredEntries, context) : getActiveEnrichers(targetEntity, context);
|
|
@@ -122,12 +171,16 @@ async function applyResponseEnrichers(items, targetEntity, context, preFilteredE
|
|
|
122
171
|
const recordIds = currentItems.map((item) => extractRecordId(item));
|
|
123
172
|
const shouldUseCache = enricher.cache?.strategy === "read-through";
|
|
124
173
|
const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, targetEntity, "many", recordIds) : null;
|
|
174
|
+
const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems;
|
|
125
175
|
if (shouldUseCache && cacheKey) {
|
|
126
176
|
const cached = await readEnricherCache(cache, cacheKey);
|
|
127
|
-
if (cached) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
177
|
+
if (isEnricherCacheEnvelope(cached)) {
|
|
178
|
+
const merged = applyCacheEnvelope(cached, currentItems);
|
|
179
|
+
if (merged) {
|
|
180
|
+
currentItems = merged;
|
|
181
|
+
enrichedBy.push(enricher.id);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
131
184
|
}
|
|
132
185
|
}
|
|
133
186
|
if (enricher.enrichMany) {
|
|
@@ -149,13 +202,18 @@ async function applyResponseEnrichers(items, targetEntity, context, preFilteredE
|
|
|
149
202
|
logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs);
|
|
150
203
|
currentItems = result;
|
|
151
204
|
if (shouldUseCache && cacheKey) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
205
|
+
const envelope = buildCacheEnvelope(inputItems, result);
|
|
206
|
+
if (envelope) {
|
|
207
|
+
await writeEnricherCache(
|
|
208
|
+
cache,
|
|
209
|
+
cacheKey,
|
|
210
|
+
envelope,
|
|
211
|
+
getEnricherCacheTtl(enricher),
|
|
212
|
+
getEnricherCacheTags(enricher, context)
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
logSkippedCacheWrite(enricher);
|
|
216
|
+
}
|
|
159
217
|
}
|
|
160
218
|
enrichedBy.push(enricher.id);
|
|
161
219
|
} catch (err) {
|
|
@@ -198,12 +256,16 @@ async function applyResponseEnricherToRecord(record, targetEntity, context, preF
|
|
|
198
256
|
const recordId = extractRecordId(currentRecord);
|
|
199
257
|
const shouldUseCache = enricher.cache?.strategy === "read-through";
|
|
200
258
|
const cacheKey = shouldUseCache ? buildCacheKey(enricher, context, targetEntity, "one", [recordId]) : null;
|
|
259
|
+
const inputRecord = shouldUseCache ? { ...currentRecord } : currentRecord;
|
|
201
260
|
if (shouldUseCache && cacheKey) {
|
|
202
261
|
const cached = await readEnricherCache(cache, cacheKey);
|
|
203
|
-
if (cached) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
262
|
+
if (isEnricherCacheEnvelope(cached)) {
|
|
263
|
+
const merged = applyCacheEnvelope(cached, [currentRecord]);
|
|
264
|
+
if (merged) {
|
|
265
|
+
currentRecord = merged[0];
|
|
266
|
+
enrichedBy.push(enricher.id);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
207
269
|
}
|
|
208
270
|
}
|
|
209
271
|
const result = await Promise.race([
|
|
@@ -214,13 +276,18 @@ async function applyResponseEnricherToRecord(record, targetEntity, context, preF
|
|
|
214
276
|
logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs);
|
|
215
277
|
currentRecord = result;
|
|
216
278
|
if (shouldUseCache && cacheKey) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
279
|
+
const envelope = buildCacheEnvelope([inputRecord], [result]);
|
|
280
|
+
if (envelope) {
|
|
281
|
+
await writeEnricherCache(
|
|
282
|
+
cache,
|
|
283
|
+
cacheKey,
|
|
284
|
+
envelope,
|
|
285
|
+
getEnricherCacheTtl(enricher),
|
|
286
|
+
getEnricherCacheTags(enricher, context)
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
logSkippedCacheWrite(enricher);
|
|
290
|
+
}
|
|
224
291
|
}
|
|
225
292
|
enrichedBy.push(enricher.id);
|
|
226
293
|
} catch (err) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/crud/enricher-runner.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Response Enricher Runner\n *\n * Executes response enrichers against API response payloads.\n * Handles timeout, fallback, ACL feature gating, and error isolation.\n */\n\nimport type {\n EnricherContext,\n EnricherRegistryEntry,\n EnrichmentResult,\n ResponseEnricher,\n SingleEnrichmentResult,\n} from './response-enricher'\nimport { getEnrichersForEntity } from './enricher-registry'\nimport { logEnricherTiming } from '../umes/enricher-timing'\nimport { createLogger } from '../logger'\nimport { authorizeFeatures } from '../../security/featurePolicy'\n\nconst logger = createLogger('shared').child({ component: 'umes' })\n\nconst DEFAULT_TIMEOUT = 2000\nconst SLOW_WARN_MS = 100\nconst SLOW_ERROR_MS = 500\nconst DEFAULT_CACHE_TTL_MS = 60_000\n\nfunction timeoutPromise(ms: number): Promise<never> {\n return new Promise((_, reject) =>\n setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms),\n )\n}\n\nfunction hasRequiredFeatures(\n enricher: ResponseEnricher,\n userFeatures: string[] | undefined,\n): boolean {\n if (!enricher.features || enricher.features.length === 0) return true\n if (!userFeatures) return false\n return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })\n}\n\nfunction filterByACLAndTenant(\n entries: EnricherRegistryEntry[],\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n return entries.filter((entry) => {\n const enricher = entry.enricher\n if (!hasRequiredFeatures(enricher, context.userFeatures)) return false\n if (enricher.disabledTenantIds?.includes(context.tenantId)) return false\n return true\n })\n}\n\nfunction getActiveEnrichers(\n targetEntity: string,\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n const entries = getEnrichersForEntity(targetEntity)\n return filterByACLAndTenant(entries, context)\n}\n\n/**\n * Plan describing whether (and how) a CRUD list cache may embed enricher output.\n */\nexport type ListCacheEnricherPlan = {\n /**\n * Stable signature of the active, cache-embeddable enrichers in registry\n * (priority) order. Included in the CRUD list cache key so a cached enriched\n * payload is only ever served back to a request whose entitlements select the\n * exact same enricher set. Empty string when nothing is embeddable \u2014 keeping\n * the cache key identical to the pre-enricher shape for unaffected routes.\n */\n signature: string\n /**\n * True only when there is at least one active enricher for the context AND\n * every active enricher opted into `cacheableOnListHit`. When true, the\n * enriched list payload may be stored in the cache and served on a hit without\n * re-running enrichers. When false, enrichers MUST re-run on every request so\n * the response reflects live data (cross-module reads, wall-clock values, etc.)\n * and no live enrichment is embedded in the shared cache entry.\n */\n skipEnrichersOnCacheHit: boolean\n}\n\n/**\n * Resolve, for the given context, whether the CRUD list cache may embed enricher\n * output and the cache-key signature to partition by when it can.\n *\n * The enriched payload is only embeddable (and the cache hit allowed to skip\n * enrichment) when every active enricher is `cacheableOnListHit` \u2014 i.e. its\n * output is a pure function of the cached record and invalidated together with\n * it. If any active enricher reads data the list cache does not invalidate on,\n * the route falls back to caching the pre-enrichment payload and re-running\n * enrichers on every request.\n */\nexport function resolveListCacheEnricherPlan(\n targetEntity: string,\n context: EnricherContext,\n): ListCacheEnricherPlan {\n const active = getActiveEnrichers(targetEntity, context)\n if (active.length === 0) return { signature: '', skipEnrichersOnCacheHit: false }\n const allCacheable = active.every((entry) => entry.enricher.cacheableOnListHit === true)\n if (!allCacheable) return { signature: '', skipEnrichersOnCacheHit: false }\n return {\n signature: active.map((entry) => entry.enricher.id).join(','),\n skipEnrichersOnCacheHit: true,\n }\n}\n\ntype CacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>\n}\n\nfunction resolveCache(context: EnricherContext): CacheLike | null {\n const container = context.container as { resolve?: (name: string) => unknown } | undefined\n if (!container?.resolve) return null\n try {\n const cache = container.resolve('cache') as CacheLike\n if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {\n return cache\n }\n } catch {\n // ignore cache resolution failures\n }\n try {\n const cacheService = container.resolve('cacheService') as CacheLike\n if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {\n return cacheService\n }\n } catch {\n // ignore cache service resolution failures\n }\n return null\n}\n\nfunction buildCacheKey(\n enricher: ResponseEnricher,\n context: EnricherContext,\n targetEntity: string,\n mode: 'one' | 'many',\n recordIds: string[],\n): string {\n const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b))\n return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`\n}\n\nfunction extractRecordId(record: Record<string, unknown>): string {\n const idValue = record.id\n if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()\n if (typeof idValue === 'number') return String(idValue)\n return 'unknown'\n}\n\nfunction getEnricherCacheTtl(enricher: ResponseEnricher): number {\n const ttl = enricher.cache?.ttl\n if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {\n return ttl\n }\n return DEFAULT_CACHE_TTL_MS\n}\n\nfunction getEnricherCacheTags(enricher: ResponseEnricher, context: EnricherContext): string[] {\n const tags = new Set<string>([\n `tenant:${context.tenantId}`,\n `organization:${context.organizationId}`,\n `enricher:${enricher.id}`,\n ])\n for (const tag of enricher.cache?.tags ?? []) {\n if (!tag || tag.trim().length === 0) continue\n tags.add(tag)\n }\n return Array.from(tags)\n}\n\nasync function readEnricherCache<T>(\n cache: CacheLike | null,\n key: string,\n): Promise<T | null> {\n if (!cache) return null\n try {\n const value = await cache.get(key)\n return value == null ? null : (value as T)\n } catch {\n return null\n }\n}\n\nasync function writeEnricherCache(\n cache: CacheLike | null,\n key: string,\n value: unknown,\n ttl: number,\n tags: string[],\n): Promise<void> {\n if (!cache) return\n try {\n await cache.set(key, value, { ttl, tags })\n } catch {\n // ignore cache write failures\n }\n}\n\n/**\n * Apply response enrichers to a list of records.\n *\n * Runs AFTER CrudHooks.afterList, BEFORE HTTP response serialization.\n * Each enricher runs independently \u2014 a failed non-critical enricher is skipped.\n */\nexport async function applyResponseEnrichers<T extends Record<string, unknown>>(\n items: T[],\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<EnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { items, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentItems = items\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n let result: T[]\n const recordIds = currentItems.map((item) => extractRecordId(item))\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)\n : null\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<T[]>(cache, cacheKey)\n if (cached) {\n currentItems = cached\n enrichedBy.push(enricher.id)\n continue\n }\n }\n\n if (enricher.enrichMany) {\n result = await Promise.race([\n enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,\n timeoutPromise(timeout),\n ])\n } else {\n throw new Error(\n `Enricher ${enricher.id} must implement enrichMany() for list endpoints`,\n )\n }\n\n const elapsedMs = Date.now() - startTime\n if (elapsedMs > SLOW_ERROR_MS) {\n logger.error('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_ERROR_MS })\n } else if (elapsedMs > SLOW_WARN_MS) {\n logger.warn('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS })\n }\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentItems = result\n if (shouldUseCache && cacheKey) {\n await writeEnricherCache(\n cache,\n cacheKey,\n result,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentItems = currentItems.map((item) => ({\n ...item,\n ...enricher.fallback,\n })) as T[]\n }\n }\n }\n\n return {\n items: currentItems,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n\n/**\n * Apply response enrichers to a single record.\n *\n * Used for detail endpoints (GET /:id), POST, and PUT responses.\n */\nexport async function applyResponseEnricherToRecord<T extends Record<string, unknown>>(\n record: T,\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<SingleEnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { record, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentRecord = record\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n const recordId = extractRecordId(currentRecord)\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])\n : null\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<T>(cache, cacheKey)\n if (cached) {\n currentRecord = cached\n enrichedBy.push(enricher.id)\n continue\n }\n }\n const result = await Promise.race([\n enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,\n timeoutPromise(timeout),\n ])\n\n const elapsedMs = Date.now() - startTime\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentRecord = result\n if (shouldUseCache && cacheKey) {\n await writeEnricherCache(\n cache,\n cacheKey,\n result,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentRecord = { ...currentRecord, ...enricher.fallback } as T\n }\n }\n }\n\n return {\n record: currentRecord,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["/**\n * Response Enricher Runner\n *\n * Executes response enrichers against API response payloads.\n * Handles timeout, fallback, ACL feature gating, and error isolation.\n */\n\nimport type {\n EnricherContext,\n EnricherRegistryEntry,\n EnrichmentResult,\n ResponseEnricher,\n SingleEnrichmentResult,\n} from './response-enricher'\nimport { getEnrichersForEntity } from './enricher-registry'\nimport { logEnricherTiming } from '../umes/enricher-timing'\nimport { createLogger } from '../logger'\nimport { authorizeFeatures } from '../../security/featurePolicy'\n\nconst logger = createLogger('shared').child({ component: 'umes' })\n\nconst DEFAULT_TIMEOUT = 2000\nconst SLOW_WARN_MS = 100\nconst SLOW_ERROR_MS = 500\nconst DEFAULT_CACHE_TTL_MS = 60_000\n\nfunction timeoutPromise(ms: number): Promise<never> {\n return new Promise((_, reject) =>\n setTimeout(() => reject(new Error(`Enricher timed out after ${ms}ms`)), ms),\n )\n}\n\nfunction hasRequiredFeatures(\n enricher: ResponseEnricher,\n userFeatures: string[] | undefined,\n): boolean {\n if (!enricher.features || enricher.features.length === 0) return true\n if (!userFeatures) return false\n return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })\n}\n\nfunction filterByACLAndTenant(\n entries: EnricherRegistryEntry[],\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n return entries.filter((entry) => {\n const enricher = entry.enricher\n if (!hasRequiredFeatures(enricher, context.userFeatures)) return false\n if (enricher.disabledTenantIds?.includes(context.tenantId)) return false\n return true\n })\n}\n\nfunction getActiveEnrichers(\n targetEntity: string,\n context: EnricherContext,\n): EnricherRegistryEntry[] {\n const entries = getEnrichersForEntity(targetEntity)\n return filterByACLAndTenant(entries, context)\n}\n\n/**\n * Plan describing whether (and how) a CRUD list cache may embed enricher output.\n */\nexport type ListCacheEnricherPlan = {\n /**\n * Stable signature of the active, cache-embeddable enrichers in registry\n * (priority) order. Included in the CRUD list cache key so a cached enriched\n * payload is only ever served back to a request whose entitlements select the\n * exact same enricher set. Empty string when nothing is embeddable \u2014 keeping\n * the cache key identical to the pre-enricher shape for unaffected routes.\n */\n signature: string\n /**\n * True only when there is at least one active enricher for the context AND\n * every active enricher opted into `cacheableOnListHit`. When true, the\n * enriched list payload may be stored in the cache and served on a hit without\n * re-running enrichers. When false, enrichers MUST re-run on every request so\n * the response reflects live data (cross-module reads, wall-clock values, etc.)\n * and no live enrichment is embedded in the shared cache entry.\n */\n skipEnrichersOnCacheHit: boolean\n}\n\n/**\n * Resolve, for the given context, whether the CRUD list cache may embed enricher\n * output and the cache-key signature to partition by when it can.\n *\n * The enriched payload is only embeddable (and the cache hit allowed to skip\n * enrichment) when every active enricher is `cacheableOnListHit` \u2014 i.e. its\n * output is a pure function of the cached record and invalidated together with\n * it. If any active enricher reads data the list cache does not invalidate on,\n * the route falls back to caching the pre-enrichment payload and re-running\n * enrichers on every request.\n */\nexport function resolveListCacheEnricherPlan(\n targetEntity: string,\n context: EnricherContext,\n): ListCacheEnricherPlan {\n const active = getActiveEnrichers(targetEntity, context)\n if (active.length === 0) return { signature: '', skipEnrichersOnCacheHit: false }\n const allCacheable = active.every((entry) => entry.enricher.cacheableOnListHit === true)\n if (!allCacheable) return { signature: '', skipEnrichersOnCacheHit: false }\n return {\n signature: active.map((entry) => entry.enricher.id).join(','),\n skipEnrichersOnCacheHit: true,\n }\n}\n\ntype CacheLike = {\n get: (key: string) => Promise<unknown>\n set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>\n}\n\nfunction resolveCache(context: EnricherContext): CacheLike | null {\n const container = context.container as { resolve?: (name: string) => unknown } | undefined\n if (!container?.resolve) return null\n try {\n const cache = container.resolve('cache') as CacheLike\n if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') {\n return cache\n }\n } catch {\n // ignore cache resolution failures\n }\n try {\n const cacheService = container.resolve('cacheService') as CacheLike\n if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {\n return cacheService\n }\n } catch {\n // ignore cache service resolution failures\n }\n return null\n}\n\nfunction buildCacheKey(\n enricher: ResponseEnricher,\n context: EnricherContext,\n targetEntity: string,\n mode: 'one' | 'many',\n recordIds: string[],\n): string {\n const sortedIds = [...recordIds].sort((a, b) => a.localeCompare(b))\n return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`\n}\n\nconst UNKNOWN_RECORD_ID = 'unknown'\n\nfunction extractRecordId(record: Record<string, unknown>): string {\n const idValue = record.id\n if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()\n if (typeof idValue === 'number') return String(idValue)\n return UNKNOWN_RECORD_ID\n}\n\nfunction getEnricherCacheTtl(enricher: ResponseEnricher): number {\n const ttl = enricher.cache?.ttl\n if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0) {\n return ttl\n }\n return DEFAULT_CACHE_TTL_MS\n}\n\nfunction getEnricherCacheTags(enricher: ResponseEnricher, context: EnricherContext): string[] {\n const tags = new Set<string>([\n `tenant:${context.tenantId}`,\n `organization:${context.organizationId}`,\n `enricher:${enricher.id}`,\n ])\n for (const tag of enricher.cache?.tags ?? []) {\n if (!tag || tag.trim().length === 0) continue\n tags.add(tag)\n }\n return Array.from(tags)\n}\n\nasync function readEnricherCache<T>(\n cache: CacheLike | null,\n key: string,\n): Promise<T | null> {\n if (!cache) return null\n try {\n const value = await cache.get(key)\n return value == null ? null : (value as T)\n } catch {\n return null\n }\n}\n\n/**\n * The cache write was skipped because no safe envelope could be built. Logged\n * rather than swallowed: from outside the runner a silently-skipped write is\n * indistinguishable from a broken cache, and \"this enricher is not purely\n * additive\" is the answer an author needs to see.\n */\nfunction logSkippedCacheWrite(enricher: ResponseEnricher): void {\n logger.debug('Skipped enricher cache write \u2014 enrichment is not purely additive or lacks usable record ids', {\n enricherId: enricher.id,\n })\n}\n\nasync function writeEnricherCache(\n cache: CacheLike | null,\n key: string,\n value: unknown,\n ttl: number,\n tags: string[],\n): Promise<void> {\n if (!cache) return\n try {\n await cache.set(key, value, { ttl, tags })\n } catch {\n // ignore cache write failures\n }\n}\n\n/**\n * Cached read-through payload: the fields each enricher ADDED, keyed by record id.\n *\n * Caching whole records would replace the freshly-read record with the snapshot\n * taken at write time, so an edit to a base field (a product's name, an order's\n * status) would not surface until the entry expired, and a cached array would\n * also carry \u2014 and therefore overwrite \u2014 whatever the previous enricher in the\n * chain contributed. The additive delta is a pure function of the enricher, the\n * tenant/organization scope and the record ids, which is exactly what the cache\n * key already encodes, so it is the only part of the result that is safe to reuse.\n */\ntype EnricherCacheEnvelope = {\n version: 1\n deltas: Record<string, Record<string, unknown>>\n}\n\nconst ENRICHER_CACHE_VERSION = 1\n\nfunction isEnricherCacheEnvelope(value: unknown): value is EnricherCacheEnvelope {\n if (typeof value !== 'object' || value === null) return false\n const candidate = value as { version?: unknown; deltas?: unknown }\n if (candidate.version !== ENRICHER_CACHE_VERSION) return false\n return typeof candidate.deltas === 'object' && candidate.deltas !== null\n}\n\n/**\n * The keys an enricher added to a record, or `null` when the enrichment was not\n * purely additive \u2014 it changed or dropped a key that was already there. A\n * non-additive enricher is never cached: replaying only its added keys onto a\n * later record would silently lose the change it made to the existing ones.\n *\n * Comparison is by identity at the top level only, so an enricher that mutates a\n * nested object in place is indistinguishable from one that left the record\n * alone: its nested change is absent from the delta and therefore lost on a\n * later hit. That fails safe \u2014 the served record is under-enriched, never\n * stale-wrong \u2014 and a deep clone of every record on every enriched response is\n * not worth paying for the case.\n */\nfunction computeAdditiveDelta(\n input: Record<string, unknown>,\n output: Record<string, unknown>,\n): Record<string, unknown> | null {\n const delta: Record<string, unknown> = {}\n for (const key of Object.keys(input)) {\n if (!Object.prototype.hasOwnProperty.call(output, key)) return null\n if (output[key] !== input[key]) return null\n }\n for (const key of Object.keys(output)) {\n if (Object.prototype.hasOwnProperty.call(input, key)) continue\n delta[key] = output[key]\n }\n return delta\n}\n\n/**\n * Build the cacheable envelope for a batch, or `null` when it cannot be built\n * safely \u2014 an unusable record id, a duplicate id (the deltas would collide), or\n * a non-additive enrichment. Every failure mode skips the cache write and leaves\n * the enricher running on every request, which is the pre-cache behavior.\n */\nfunction buildCacheEnvelope<T extends Record<string, unknown>>(\n inputs: T[],\n outputs: T[],\n): EnricherCacheEnvelope | null {\n if (inputs.length !== outputs.length) return null\n const deltas: Record<string, Record<string, unknown>> = {}\n for (let index = 0; index < inputs.length; index += 1) {\n const recordId = extractRecordId(inputs[index])\n if (recordId === UNKNOWN_RECORD_ID) return null\n if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null\n const delta = computeAdditiveDelta(inputs[index], outputs[index])\n if (!delta) return null\n deltas[recordId] = delta\n }\n return { version: ENRICHER_CACHE_VERSION, deltas }\n}\n\n/**\n * Merge a cached envelope onto freshly-read records. Returns `null` \u2014 a miss \u2014\n * when the envelope does not cover every record, so a partially-cached batch\n * re-runs the enricher rather than returning some records unenriched.\n */\nfunction applyCacheEnvelope<T extends Record<string, unknown>>(\n envelope: EnricherCacheEnvelope,\n records: T[],\n): T[] | null {\n const merged: T[] = []\n for (const record of records) {\n const recordId = extractRecordId(record)\n if (recordId === UNKNOWN_RECORD_ID) return null\n const delta = envelope.deltas[recordId]\n if (!delta || typeof delta !== 'object') return null\n merged.push({ ...record, ...delta } as T)\n }\n return merged\n}\n\n/**\n * Apply response enrichers to a list of records.\n *\n * Runs AFTER CrudHooks.afterList, BEFORE HTTP response serialization.\n * Each enricher runs independently \u2014 a failed non-critical enricher is skipped.\n */\nexport async function applyResponseEnrichers<T extends Record<string, unknown>>(\n items: T[],\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<EnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { items, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentItems = items\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n let result: T[]\n const recordIds = currentItems.map((item) => extractRecordId(item))\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)\n : null\n // Snapshot BEFORE enrichment: the contract does not forbid an enricher\n // from mutating the records it was handed, and comparing a mutated record\n // against itself would yield an empty delta \u2014 caching \"this enricher adds\n // nothing\" and serving unenriched records for the rest of the TTL.\n const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, currentItems)\n if (merged) {\n currentItems = merged\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n\n if (enricher.enrichMany) {\n result = await Promise.race([\n enricher.enrichMany(currentItems, enricherContext) as Promise<T[]>,\n timeoutPromise(timeout),\n ])\n } else {\n throw new Error(\n `Enricher ${enricher.id} must implement enrichMany() for list endpoints`,\n )\n }\n\n const elapsedMs = Date.now() - startTime\n if (elapsedMs > SLOW_ERROR_MS) {\n logger.error('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_ERROR_MS })\n } else if (elapsedMs > SLOW_WARN_MS) {\n logger.warn('Enricher exceeded slow threshold', { enricherId: enricher.id, elapsedMs, thresholdMs: SLOW_WARN_MS })\n }\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentItems = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope(inputItems, result)\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentItems = currentItems.map((item) => ({\n ...item,\n ...enricher.fallback,\n })) as T[]\n }\n }\n }\n\n return {\n items: currentItems,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n\n/**\n * Apply response enrichers to a single record.\n *\n * Used for detail endpoints (GET /:id), POST, and PUT responses.\n */\nexport async function applyResponseEnricherToRecord<T extends Record<string, unknown>>(\n record: T,\n targetEntity: string,\n context: EnricherContext,\n preFilteredEntries?: EnricherRegistryEntry[],\n): Promise<SingleEnrichmentResult<T>> {\n const enricherContext: EnricherContext = { ...context, targetEntity }\n const activeEntries = preFilteredEntries\n ? filterByACLAndTenant(preFilteredEntries, context)\n : getActiveEnrichers(targetEntity, context)\n\n if (activeEntries.length === 0) {\n return { record, _meta: { enrichedBy: [] } }\n }\n\n const enrichedBy: string[] = []\n const enricherErrors: string[] = []\n let currentRecord = record\n const cache = resolveCache(context)\n\n for (const entry of activeEntries) {\n const enricher = entry.enricher\n const timeout = enricher.timeout ?? DEFAULT_TIMEOUT\n const startTime = Date.now()\n\n try {\n const recordId = extractRecordId(currentRecord)\n const shouldUseCache = enricher.cache?.strategy === 'read-through'\n const cacheKey = shouldUseCache\n ? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])\n : null\n // Snapshot before enrichment \u2014 see the list path for why.\n const inputRecord = shouldUseCache ? ({ ...currentRecord } as T) : currentRecord\n if (shouldUseCache && cacheKey) {\n const cached = await readEnricherCache<unknown>(cache, cacheKey)\n if (isEnricherCacheEnvelope(cached)) {\n const merged = applyCacheEnvelope(cached, [currentRecord])\n if (merged) {\n currentRecord = merged[0]\n enrichedBy.push(enricher.id)\n continue\n }\n }\n }\n const result = await Promise.race([\n enricher.enrichOne(currentRecord, enricherContext) as Promise<T>,\n timeoutPromise(timeout),\n ])\n\n const elapsedMs = Date.now() - startTime\n logEnricherTiming(enricher.id, entry.moduleId, targetEntity, elapsedMs)\n\n currentRecord = result\n if (shouldUseCache && cacheKey) {\n const envelope = buildCacheEnvelope([inputRecord], [result])\n if (envelope) {\n await writeEnricherCache(\n cache,\n cacheKey,\n envelope,\n getEnricherCacheTtl(enricher),\n getEnricherCacheTags(enricher, context),\n )\n } else {\n logSkippedCacheWrite(enricher)\n }\n }\n enrichedBy.push(enricher.id)\n } catch (err) {\n if (enricher.critical) {\n throw err\n }\n\n logger.warn('Enricher failed', { enricherId: enricher.id, err })\n enricherErrors.push(enricher.id)\n\n if (enricher.fallback) {\n currentRecord = { ...currentRecord, ...enricher.fallback } as T\n }\n }\n }\n\n return {\n record: currentRecord,\n _meta: {\n enrichedBy,\n ...(enricherErrors.length > 0 ? { enricherErrors } : {}),\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7171.1.9b31dbba45';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7171.1.9b31dbba45",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mikro-orm/core": "^7.1.8",
|
|
114
114
|
"@mikro-orm/decorators": "^7.1.8",
|
|
115
115
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
116
|
-
"@open-mercato/cache": "0.7.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.7.1-develop.7171.1.9b31dbba45",
|
|
117
117
|
"@types/html-to-text": "^9.0.4",
|
|
118
118
|
"@types/sanitize-html": "^2.16.1",
|
|
119
119
|
"dotenv": "^17.4.2",
|
|
@@ -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
|
+
})
|
|
@@ -145,11 +145,13 @@ function buildCacheKey(
|
|
|
145
145
|
return `umes:enricher:${enricher.id}:entity:${targetEntity}:tenant:${context.tenantId}:org:${context.organizationId}:mode:${mode}:ids:${JSON.stringify(sortedIds)}`
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const UNKNOWN_RECORD_ID = 'unknown'
|
|
149
|
+
|
|
148
150
|
function extractRecordId(record: Record<string, unknown>): string {
|
|
149
151
|
const idValue = record.id
|
|
150
152
|
if (typeof idValue === 'string' && idValue.trim().length > 0) return idValue.trim()
|
|
151
153
|
if (typeof idValue === 'number') return String(idValue)
|
|
152
|
-
return
|
|
154
|
+
return UNKNOWN_RECORD_ID
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
function getEnricherCacheTtl(enricher: ResponseEnricher): number {
|
|
@@ -186,6 +188,18 @@ async function readEnricherCache<T>(
|
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The cache write was skipped because no safe envelope could be built. Logged
|
|
193
|
+
* rather than swallowed: from outside the runner a silently-skipped write is
|
|
194
|
+
* indistinguishable from a broken cache, and "this enricher is not purely
|
|
195
|
+
* additive" is the answer an author needs to see.
|
|
196
|
+
*/
|
|
197
|
+
function logSkippedCacheWrite(enricher: ResponseEnricher): void {
|
|
198
|
+
logger.debug('Skipped enricher cache write — enrichment is not purely additive or lacks usable record ids', {
|
|
199
|
+
enricherId: enricher.id,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
189
203
|
async function writeEnricherCache(
|
|
190
204
|
cache: CacheLike | null,
|
|
191
205
|
key: string,
|
|
@@ -201,6 +215,103 @@ async function writeEnricherCache(
|
|
|
201
215
|
}
|
|
202
216
|
}
|
|
203
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Cached read-through payload: the fields each enricher ADDED, keyed by record id.
|
|
220
|
+
*
|
|
221
|
+
* Caching whole records would replace the freshly-read record with the snapshot
|
|
222
|
+
* taken at write time, so an edit to a base field (a product's name, an order's
|
|
223
|
+
* status) would not surface until the entry expired, and a cached array would
|
|
224
|
+
* also carry — and therefore overwrite — whatever the previous enricher in the
|
|
225
|
+
* chain contributed. The additive delta is a pure function of the enricher, the
|
|
226
|
+
* tenant/organization scope and the record ids, which is exactly what the cache
|
|
227
|
+
* key already encodes, so it is the only part of the result that is safe to reuse.
|
|
228
|
+
*/
|
|
229
|
+
type EnricherCacheEnvelope = {
|
|
230
|
+
version: 1
|
|
231
|
+
deltas: Record<string, Record<string, unknown>>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ENRICHER_CACHE_VERSION = 1
|
|
235
|
+
|
|
236
|
+
function isEnricherCacheEnvelope(value: unknown): value is EnricherCacheEnvelope {
|
|
237
|
+
if (typeof value !== 'object' || value === null) return false
|
|
238
|
+
const candidate = value as { version?: unknown; deltas?: unknown }
|
|
239
|
+
if (candidate.version !== ENRICHER_CACHE_VERSION) return false
|
|
240
|
+
return typeof candidate.deltas === 'object' && candidate.deltas !== null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The keys an enricher added to a record, or `null` when the enrichment was not
|
|
245
|
+
* purely additive — it changed or dropped a key that was already there. A
|
|
246
|
+
* non-additive enricher is never cached: replaying only its added keys onto a
|
|
247
|
+
* later record would silently lose the change it made to the existing ones.
|
|
248
|
+
*
|
|
249
|
+
* Comparison is by identity at the top level only, so an enricher that mutates a
|
|
250
|
+
* nested object in place is indistinguishable from one that left the record
|
|
251
|
+
* alone: its nested change is absent from the delta and therefore lost on a
|
|
252
|
+
* later hit. That fails safe — the served record is under-enriched, never
|
|
253
|
+
* stale-wrong — and a deep clone of every record on every enriched response is
|
|
254
|
+
* not worth paying for the case.
|
|
255
|
+
*/
|
|
256
|
+
function computeAdditiveDelta(
|
|
257
|
+
input: Record<string, unknown>,
|
|
258
|
+
output: Record<string, unknown>,
|
|
259
|
+
): Record<string, unknown> | null {
|
|
260
|
+
const delta: Record<string, unknown> = {}
|
|
261
|
+
for (const key of Object.keys(input)) {
|
|
262
|
+
if (!Object.prototype.hasOwnProperty.call(output, key)) return null
|
|
263
|
+
if (output[key] !== input[key]) return null
|
|
264
|
+
}
|
|
265
|
+
for (const key of Object.keys(output)) {
|
|
266
|
+
if (Object.prototype.hasOwnProperty.call(input, key)) continue
|
|
267
|
+
delta[key] = output[key]
|
|
268
|
+
}
|
|
269
|
+
return delta
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build the cacheable envelope for a batch, or `null` when it cannot be built
|
|
274
|
+
* safely — an unusable record id, a duplicate id (the deltas would collide), or
|
|
275
|
+
* a non-additive enrichment. Every failure mode skips the cache write and leaves
|
|
276
|
+
* the enricher running on every request, which is the pre-cache behavior.
|
|
277
|
+
*/
|
|
278
|
+
function buildCacheEnvelope<T extends Record<string, unknown>>(
|
|
279
|
+
inputs: T[],
|
|
280
|
+
outputs: T[],
|
|
281
|
+
): EnricherCacheEnvelope | null {
|
|
282
|
+
if (inputs.length !== outputs.length) return null
|
|
283
|
+
const deltas: Record<string, Record<string, unknown>> = {}
|
|
284
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
285
|
+
const recordId = extractRecordId(inputs[index])
|
|
286
|
+
if (recordId === UNKNOWN_RECORD_ID) return null
|
|
287
|
+
if (Object.prototype.hasOwnProperty.call(deltas, recordId)) return null
|
|
288
|
+
const delta = computeAdditiveDelta(inputs[index], outputs[index])
|
|
289
|
+
if (!delta) return null
|
|
290
|
+
deltas[recordId] = delta
|
|
291
|
+
}
|
|
292
|
+
return { version: ENRICHER_CACHE_VERSION, deltas }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Merge a cached envelope onto freshly-read records. Returns `null` — a miss —
|
|
297
|
+
* when the envelope does not cover every record, so a partially-cached batch
|
|
298
|
+
* re-runs the enricher rather than returning some records unenriched.
|
|
299
|
+
*/
|
|
300
|
+
function applyCacheEnvelope<T extends Record<string, unknown>>(
|
|
301
|
+
envelope: EnricherCacheEnvelope,
|
|
302
|
+
records: T[],
|
|
303
|
+
): T[] | null {
|
|
304
|
+
const merged: T[] = []
|
|
305
|
+
for (const record of records) {
|
|
306
|
+
const recordId = extractRecordId(record)
|
|
307
|
+
if (recordId === UNKNOWN_RECORD_ID) return null
|
|
308
|
+
const delta = envelope.deltas[recordId]
|
|
309
|
+
if (!delta || typeof delta !== 'object') return null
|
|
310
|
+
merged.push({ ...record, ...delta } as T)
|
|
311
|
+
}
|
|
312
|
+
return merged
|
|
313
|
+
}
|
|
314
|
+
|
|
204
315
|
/**
|
|
205
316
|
* Apply response enrichers to a list of records.
|
|
206
317
|
*
|
|
@@ -239,12 +350,20 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
|
|
|
239
350
|
const cacheKey = shouldUseCache
|
|
240
351
|
? buildCacheKey(enricher, context, targetEntity, 'many', recordIds)
|
|
241
352
|
: null
|
|
353
|
+
// Snapshot BEFORE enrichment: the contract does not forbid an enricher
|
|
354
|
+
// from mutating the records it was handed, and comparing a mutated record
|
|
355
|
+
// against itself would yield an empty delta — caching "this enricher adds
|
|
356
|
+
// nothing" and serving unenriched records for the rest of the TTL.
|
|
357
|
+
const inputItems = shouldUseCache ? currentItems.map((item) => ({ ...item })) : currentItems
|
|
242
358
|
if (shouldUseCache && cacheKey) {
|
|
243
|
-
const cached = await readEnricherCache<
|
|
244
|
-
if (cached) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
359
|
+
const cached = await readEnricherCache<unknown>(cache, cacheKey)
|
|
360
|
+
if (isEnricherCacheEnvelope(cached)) {
|
|
361
|
+
const merged = applyCacheEnvelope(cached, currentItems)
|
|
362
|
+
if (merged) {
|
|
363
|
+
currentItems = merged
|
|
364
|
+
enrichedBy.push(enricher.id)
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
248
367
|
}
|
|
249
368
|
}
|
|
250
369
|
|
|
@@ -269,13 +388,18 @@ export async function applyResponseEnrichers<T extends Record<string, unknown>>(
|
|
|
269
388
|
|
|
270
389
|
currentItems = result
|
|
271
390
|
if (shouldUseCache && cacheKey) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
391
|
+
const envelope = buildCacheEnvelope(inputItems, result)
|
|
392
|
+
if (envelope) {
|
|
393
|
+
await writeEnricherCache(
|
|
394
|
+
cache,
|
|
395
|
+
cacheKey,
|
|
396
|
+
envelope,
|
|
397
|
+
getEnricherCacheTtl(enricher),
|
|
398
|
+
getEnricherCacheTags(enricher, context),
|
|
399
|
+
)
|
|
400
|
+
} else {
|
|
401
|
+
logSkippedCacheWrite(enricher)
|
|
402
|
+
}
|
|
279
403
|
}
|
|
280
404
|
enrichedBy.push(enricher.id)
|
|
281
405
|
} catch (err) {
|
|
@@ -340,12 +464,17 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
|
|
|
340
464
|
const cacheKey = shouldUseCache
|
|
341
465
|
? buildCacheKey(enricher, context, targetEntity, 'one', [recordId])
|
|
342
466
|
: null
|
|
467
|
+
// Snapshot before enrichment — see the list path for why.
|
|
468
|
+
const inputRecord = shouldUseCache ? ({ ...currentRecord } as T) : currentRecord
|
|
343
469
|
if (shouldUseCache && cacheKey) {
|
|
344
|
-
const cached = await readEnricherCache<
|
|
345
|
-
if (cached) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
470
|
+
const cached = await readEnricherCache<unknown>(cache, cacheKey)
|
|
471
|
+
if (isEnricherCacheEnvelope(cached)) {
|
|
472
|
+
const merged = applyCacheEnvelope(cached, [currentRecord])
|
|
473
|
+
if (merged) {
|
|
474
|
+
currentRecord = merged[0]
|
|
475
|
+
enrichedBy.push(enricher.id)
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
349
478
|
}
|
|
350
479
|
}
|
|
351
480
|
const result = await Promise.race([
|
|
@@ -358,13 +487,18 @@ export async function applyResponseEnricherToRecord<T extends Record<string, unk
|
|
|
358
487
|
|
|
359
488
|
currentRecord = result
|
|
360
489
|
if (shouldUseCache && cacheKey) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
490
|
+
const envelope = buildCacheEnvelope([inputRecord], [result])
|
|
491
|
+
if (envelope) {
|
|
492
|
+
await writeEnricherCache(
|
|
493
|
+
cache,
|
|
494
|
+
cacheKey,
|
|
495
|
+
envelope,
|
|
496
|
+
getEnricherCacheTtl(enricher),
|
|
497
|
+
getEnricherCacheTags(enricher, context),
|
|
498
|
+
)
|
|
499
|
+
} else {
|
|
500
|
+
logSkippedCacheWrite(enricher)
|
|
501
|
+
}
|
|
368
502
|
}
|
|
369
503
|
enrichedBy.push(enricher.id)
|
|
370
504
|
} catch (err) {
|
|
@@ -93,11 +93,33 @@ export interface ResponseEnricher<TRecord = any, TEnriched = any> {
|
|
|
93
93
|
/** Tenant IDs where this enricher should be disabled. */
|
|
94
94
|
disabledTenantIds?: string[]
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Optional cache configuration for read-through enrichment results.
|
|
98
|
+
*
|
|
99
|
+
* The runner caches the **additive delta** — the keys this enricher adds to a
|
|
100
|
+
* record — not the record itself, so a cache hit still serves freshly-read
|
|
101
|
+
* base fields and cannot overwrite what an earlier enricher in the chain
|
|
102
|
+
* contributed. That makes the cache usable only by an enricher whose output is
|
|
103
|
+
* purely additive: one that changes or drops a key the record already carried
|
|
104
|
+
* is never cached and simply re-runs on every request. Declaring `cache` on
|
|
105
|
+
* such an enricher is silently a no-op rather than an error, so an enricher
|
|
106
|
+
* that appears never to cache is usually mutating an existing key.
|
|
107
|
+
*/
|
|
97
108
|
cache?: {
|
|
98
109
|
strategy: 'read-through'
|
|
99
110
|
ttl: number
|
|
100
111
|
tags?: string[]
|
|
112
|
+
/**
|
|
113
|
+
* NOT IMPLEMENTED — nothing in the runner reads this field, so declaring it
|
|
114
|
+
* has no effect and an enricher relying on it will serve stale enrichment
|
|
115
|
+
* until the TTL expires. It is kept rather than removed so any existing
|
|
116
|
+
* declaration keeps compiling.
|
|
117
|
+
*
|
|
118
|
+
* Wire invalidation with an event subscriber that calls `deleteByTags` on
|
|
119
|
+
* the tags above; see
|
|
120
|
+
* `packages/core/src/modules/wms/subscribers/invalidate-enricher-cache-*.ts`
|
|
121
|
+
* for the reference implementation.
|
|
122
|
+
*/
|
|
101
123
|
invalidateOn?: string[]
|
|
102
124
|
}
|
|
103
125
|
|