@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.
@@ -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 "unknown";
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
- currentItems = cached;
129
- enrichedBy.push(enricher.id);
130
- continue;
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
- await writeEnricherCache(
153
- cache,
154
- cacheKey,
155
- result,
156
- getEnricherCacheTtl(enricher),
157
- getEnricherCacheTags(enricher, context)
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
- currentRecord = cached;
205
- enrichedBy.push(enricher.id);
206
- continue;
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
- await writeEnricherCache(
218
- cache,
219
- cacheKey,
220
- result,
221
- getEnricherCacheTtl(enricher),
222
- getEnricherCacheTags(enricher, context)
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7170.1.d95074d7ba";
1
+ const APP_VERSION = "0.7.1-develop.7171.1.9b31dbba45";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.7170.1.d95074d7ba';\nexport const appVersion = APP_VERSION;\n"],
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.7170.1.d95074d7ba",
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.7170.1.d95074d7ba",
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 'unknown'
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<T[]>(cache, cacheKey)
244
- if (cached) {
245
- currentItems = cached
246
- enrichedBy.push(enricher.id)
247
- continue
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
- await writeEnricherCache(
273
- cache,
274
- cacheKey,
275
- result,
276
- getEnricherCacheTtl(enricher),
277
- getEnricherCacheTags(enricher, context),
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<T>(cache, cacheKey)
345
- if (cached) {
346
- currentRecord = cached
347
- enrichedBy.push(enricher.id)
348
- continue
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
- await writeEnricherCache(
362
- cache,
363
- cacheKey,
364
- result,
365
- getEnricherCacheTtl(enricher),
366
- getEnricherCacheTags(enricher, context),
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
- /** Optional cache configuration for read-through enrichment results. */
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