@open-mercato/shared 0.6.4-develop.4282.1.4d95e85930 → 0.6.4-develop.4305.1.efaf0ebab1

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.
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.4-develop.4282.1.4d95e85930";
1
+ const APP_VERSION = "0.6.4-develop.4305.1.efaf0ebab1";
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.6.4-develop.4282.1.4d95e85930'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.4-develop.4305.1.efaf0ebab1'\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.6.4-develop.4282.1.4d95e85930",
3
+ "version": "0.6.4-develop.4305.1.efaf0ebab1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -89,10 +89,10 @@
89
89
  }
90
90
  },
91
91
  "dependencies": {
92
- "@mikro-orm/core": "^7.1.1",
93
- "@mikro-orm/decorators": "^7.1.1",
94
- "@mikro-orm/postgresql": "^7.1.1",
95
- "@open-mercato/cache": "0.6.4-develop.4282.1.4d95e85930",
92
+ "@mikro-orm/core": "^7.1.3",
93
+ "@mikro-orm/decorators": "^7.1.3",
94
+ "@mikro-orm/postgresql": "^7.1.3",
95
+ "@open-mercato/cache": "0.6.4-develop.4305.1.efaf0ebab1",
96
96
  "dotenv": "^17.4.2",
97
97
  "rate-limiter-flexible": "^11.1.0",
98
98
  "re2js": "2.8.3",
@@ -12,6 +12,14 @@ export type CommandRuntimeContext = {
12
12
  organizationIds: string[] | null
13
13
  request?: Request
14
14
  syncOrigin?: string | null
15
+ /**
16
+ * Marks a trusted server-side invocation (CLI seeding, tenant setup) that runs
17
+ * without an authenticated end-user actor. Commands that gate writes behind a
18
+ * privileged actor (e.g. super-admin-only platform tables) may treat this as
19
+ * an explicit system grant. HTTP request paths MUST NOT set this — they always
20
+ * carry a real `auth` actor, so a present-but-unprivileged actor stays denied.
21
+ */
22
+ systemActor?: boolean
15
23
  /**
16
24
  * When set, command handlers that support it MUST run their writes within this
17
25
  * existing transactional EntityManager (reusing its row locks) instead of
@@ -0,0 +1,284 @@
1
+ // Regression coverage for #2222: a CRUD list cache hit may skip re-running
2
+ // response enrichers ONLY when every active enricher opts into
3
+ // `cacheableOnListHit` (its output is a pure function of the cached record). For
4
+ // such enrichers the stored payload embeds enricher output and the cache key is
5
+ // partitioned by the active-enricher signature, so a hit serves the cached
6
+ // enrichment directly — eliminating the ~15ms per-hit cost — while staying
7
+ // ACL-gated. Enrichers that read live cross-module / time-dependent data (the
8
+ // default, fail-closed) MUST re-run on every hit, and the cache stores their
9
+ // pre-enrichment base payload — see the fail-closed test at the bottom.
10
+
11
+ jest.mock('@open-mercato/cache', () => ({
12
+ runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
13
+ }), { virtual: true })
14
+
15
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
16
+ import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
17
+ import { registerResponseEnrichers } from '@open-mercato/shared/lib/crud/enricher-registry'
18
+ import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'
19
+ import { z } from 'zod'
20
+
21
+ const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
22
+ const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
23
+
24
+ let mockUserFeatures: string[] | undefined
25
+
26
+ const em = {}
27
+
28
+ const queryEngine = {
29
+ query: jest.fn(async () => ({
30
+ items: [{ id: 'id-1', title: 'A', organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
31
+ total: 1,
32
+ })),
33
+ }
34
+
35
+ // Simple Map-backed CRUD cache supporting the surface the factory touches.
36
+ const store = new Map<string, unknown>()
37
+ const cache = {
38
+ get: jest.fn(async (key: string) => (store.has(key) ? store.get(key) : null)),
39
+ set: jest.fn(async (key: string, value: unknown) => { store.set(key, value) }),
40
+ delete: jest.fn(async (key: string) => { store.delete(key) }),
41
+ deleteByTags: jest.fn(async () => 0),
42
+ }
43
+
44
+ const accessLogService = { log: jest.fn(async () => {}) }
45
+
46
+ const rbacService = {
47
+ getGrantedFeatures: jest.fn(async () => mockUserFeatures),
48
+ }
49
+
50
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
51
+ createRequestContainer: async () => ({
52
+ resolve: (name: string) => ({
53
+ em,
54
+ queryEngine,
55
+ cache,
56
+ accessLogService,
57
+ rbacService,
58
+ } as any)[name],
59
+ }),
60
+ }))
61
+
62
+ jest.mock('@open-mercato/shared/lib/auth/server', () => {
63
+ const auth = {
64
+ sub: 'u1',
65
+ orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
66
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
67
+ roles: ['admin'],
68
+ }
69
+ return {
70
+ getAuthFromCookies: async () => auth,
71
+ getAuthFromRequest: async () => auth,
72
+ }
73
+ })
74
+
75
+ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
76
+ resolveOrganizationScopeForRequest: jest.fn(async () => ({
77
+ selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
78
+ filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
79
+ allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
80
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
81
+ })),
82
+ }))
83
+
84
+ jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
85
+ setRecordCustomFields: jest.fn(async () => {}),
86
+ }))
87
+
88
+ class Todo {}
89
+
90
+ const enrichManyCalls = jest.fn()
91
+
92
+ // Record-pure enricher: its output depends only on the cached record, so it
93
+ // opts into `cacheableOnListHit` and is safe to embed in the list cache.
94
+ const gatedEnricher: ResponseEnricher<any> = {
95
+ id: 'example.todo-flag',
96
+ targetEntity: 'example.todo',
97
+ features: ['example.view'],
98
+ priority: 10,
99
+ timeout: 2000,
100
+ critical: false,
101
+ cacheableOnListHit: true,
102
+ fallback: { _example: { flagged: false } },
103
+ async enrichOne(record, context) {
104
+ return (await this.enrichMany!([record], context))[0]
105
+ },
106
+ async enrichMany(records) {
107
+ enrichManyCalls()
108
+ return records.map((record) => ({ ...record, _example: { flagged: true } }))
109
+ },
110
+ }
111
+
112
+ // Live enricher: its output mirrors mutable external state (a stand-in for a
113
+ // cross-module read like the catalog product image in TC-SALES-023). It does NOT
114
+ // set `cacheableOnListHit`, so it must re-run on every cache hit.
115
+ const liveEnricherCalls = jest.fn()
116
+ let liveExternalValue = 'v1'
117
+
118
+ const liveEnricher: ResponseEnricher<any> = {
119
+ id: 'example.todo-live',
120
+ targetEntity: 'example.todo',
121
+ features: [],
122
+ priority: 5,
123
+ timeout: 2000,
124
+ critical: false,
125
+ async enrichOne(record, context) {
126
+ return (await this.enrichMany!([record], context))[0]
127
+ },
128
+ async enrichMany(records) {
129
+ liveEnricherCalls()
130
+ return records.map((record) => ({ ...record, _live: { value: liveExternalValue } }))
131
+ },
132
+ }
133
+
134
+ const querySchema = z.object({
135
+ page: z.coerce.number().default(1),
136
+ pageSize: z.coerce.number().default(50),
137
+ sortField: z.string().default('id'),
138
+ sortDir: z.enum(['asc', 'desc']).default('asc'),
139
+ })
140
+
141
+ const route = makeCrudRoute({
142
+ metadata: { GET: { requireAuth: true } },
143
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
144
+ indexer: { entityType: 'example.todo' },
145
+ list: {
146
+ schema: querySchema,
147
+ entityId: 'example.todo',
148
+ fields: ['id', 'title'],
149
+ sortFieldMap: { id: 'id', title: 'title' },
150
+ buildFilters: () => ({} as any),
151
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
152
+ },
153
+ enrichers: { entityId: 'example.todo' },
154
+ })
155
+
156
+ const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
157
+
158
+ describe('CRUD Factory — response enrichers + list cache (#2222)', () => {
159
+ const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
160
+
161
+ beforeAll(() => {
162
+ process.env.ENABLE_CRUD_API_CACHE = 'true'
163
+ })
164
+
165
+ afterAll(() => {
166
+ if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
167
+ else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
168
+ })
169
+
170
+ beforeEach(() => {
171
+ jest.clearAllMocks()
172
+ store.clear()
173
+ mockUserFeatures = ['example.view']
174
+ registerApiInterceptors([])
175
+ registerResponseEnrichers([{ moduleId: 'example', enrichers: [gatedEnricher] }])
176
+ })
177
+
178
+ it('runs enrichers on the cache miss and caches the enriched payload', async () => {
179
+ const res = await route.GET(new Request(url))
180
+ expect(res.status).toBe(200)
181
+ const body = await res.json()
182
+ expect(body.items[0]._example).toEqual({ flagged: true })
183
+ expect(enrichManyCalls).toHaveBeenCalledTimes(1)
184
+ expect(res.headers.get('x-om-cache')).toBe('miss')
185
+
186
+ // The stored payload embeds the enrichment.
187
+ const stored = Array.from(store.values())[0] as any
188
+ expect(stored.payload.items[0]._example).toEqual({ flagged: true })
189
+ })
190
+
191
+ it('serves enriched fields from cache WITHOUT re-running enrichers on a hit', async () => {
192
+ const first = await route.GET(new Request(url))
193
+ expect(first.headers.get('x-om-cache')).toBe('miss')
194
+ expect(enrichManyCalls).toHaveBeenCalledTimes(1)
195
+
196
+ const second = await route.GET(new Request(url))
197
+ const body = await second.json()
198
+ expect(second.headers.get('x-om-cache')).toBe('hit')
199
+ // Enriched output still present...
200
+ expect(body.items[0]._example).toEqual({ flagged: true })
201
+ // ...but the enricher did NOT run a second time.
202
+ expect(enrichManyCalls).toHaveBeenCalledTimes(1)
203
+ })
204
+
205
+ it('partitions the cache by active-enricher signature so feature cohorts cannot leak ACL-gated fields', async () => {
206
+ // Cohort A holds the gating feature → enricher active, enriched payload cached.
207
+ mockUserFeatures = ['example.view']
208
+ const aRes = await route.GET(new Request(url))
209
+ const aBody = await aRes.json()
210
+ expect(aBody.items[0]._example).toEqual({ flagged: true })
211
+ const keysAfterA = new Set(store.keys())
212
+ expect(keysAfterA.size).toBe(1)
213
+
214
+ // Cohort B lacks the gating feature → enricher inactive (different signature →
215
+ // different cache key) → must NOT receive cohort A's enriched fields.
216
+ mockUserFeatures = []
217
+ const bRes = await route.GET(new Request(url))
218
+ const bBody = await bRes.json()
219
+ expect(bBody.items[0]._example).toBeUndefined()
220
+ expect(bRes.headers.get('x-om-cache')).toBe('miss')
221
+ // A distinct entry was written for cohort B rather than reusing cohort A's.
222
+ expect(store.size).toBe(2)
223
+
224
+ // Cohort A still gets a fast, correct hit from its own entry.
225
+ mockUserFeatures = ['example.view']
226
+ const aAgain = await route.GET(new Request(url))
227
+ const aAgainBody = await aAgain.json()
228
+ expect(aAgain.headers.get('x-om-cache')).toBe('hit')
229
+ expect(aAgainBody.items[0]._example).toEqual({ flagged: true })
230
+ })
231
+ })
232
+
233
+ describe('CRUD Factory — fail-closed enrichers re-run on cache hits (#2222)', () => {
234
+ const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
235
+
236
+ beforeAll(() => {
237
+ process.env.ENABLE_CRUD_API_CACHE = 'true'
238
+ })
239
+
240
+ afterAll(() => {
241
+ if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
242
+ else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
243
+ })
244
+
245
+ beforeEach(() => {
246
+ jest.clearAllMocks()
247
+ store.clear()
248
+ liveExternalValue = 'v1'
249
+ mockUserFeatures = ['example.view']
250
+ registerApiInterceptors([])
251
+ registerResponseEnrichers([{ moduleId: 'example', enrichers: [liveEnricher] }])
252
+ })
253
+
254
+ it('re-runs a non-cacheable enricher on a cache hit and reflects updated external data', async () => {
255
+ const first = await route.GET(new Request(url))
256
+ const firstBody = await first.json()
257
+ expect(first.headers.get('x-om-cache')).toBe('miss')
258
+ expect(firstBody.items[0]._live).toEqual({ value: 'v1' })
259
+ expect(liveEnricherCalls).toHaveBeenCalledTimes(1)
260
+
261
+ // External data the list cache does not invalidate on changes (e.g. a
262
+ // catalog product image update for a sales line, as in TC-SALES-023).
263
+ liveExternalValue = 'v2'
264
+
265
+ const second = await route.GET(new Request(url))
266
+ const secondBody = await second.json()
267
+ expect(second.headers.get('x-om-cache')).toBe('hit')
268
+ // The enricher re-ran on the hit, so the response reflects the new value...
269
+ expect(secondBody.items[0]._live).toEqual({ value: 'v2' })
270
+ expect(liveEnricherCalls).toHaveBeenCalledTimes(2)
271
+ })
272
+
273
+ it('caches the pre-enrichment base payload (no live enrichment embedded, no signature partition)', async () => {
274
+ await route.GET(new Request(url))
275
+
276
+ // Exactly one entry was written...
277
+ expect(store.size).toBe(1)
278
+ const [key, stored] = Array.from(store.entries())[0] as [string, any]
279
+ // ...holding the base record without the live enrichment...
280
+ expect(stored.payload.items[0]._live).toBeUndefined()
281
+ // ...under a key that is NOT partitioned by an enricher signature.
282
+ expect(key).not.toContain('enrichers:')
283
+ })
284
+ })
@@ -65,6 +65,54 @@ function getActiveEnrichers(
65
65
  return filterByACLAndTenant(entries, context)
66
66
  }
67
67
 
68
+ /**
69
+ * Plan describing whether (and how) a CRUD list cache may embed enricher output.
70
+ */
71
+ export type ListCacheEnricherPlan = {
72
+ /**
73
+ * Stable signature of the active, cache-embeddable enrichers in registry
74
+ * (priority) order. Included in the CRUD list cache key so a cached enriched
75
+ * payload is only ever served back to a request whose entitlements select the
76
+ * exact same enricher set. Empty string when nothing is embeddable — keeping
77
+ * the cache key identical to the pre-enricher shape for unaffected routes.
78
+ */
79
+ signature: string
80
+ /**
81
+ * True only when there is at least one active enricher for the context AND
82
+ * every active enricher opted into `cacheableOnListHit`. When true, the
83
+ * enriched list payload may be stored in the cache and served on a hit without
84
+ * re-running enrichers. When false, enrichers MUST re-run on every request so
85
+ * the response reflects live data (cross-module reads, wall-clock values, etc.)
86
+ * and no live enrichment is embedded in the shared cache entry.
87
+ */
88
+ skipEnrichersOnCacheHit: boolean
89
+ }
90
+
91
+ /**
92
+ * Resolve, for the given context, whether the CRUD list cache may embed enricher
93
+ * output and the cache-key signature to partition by when it can.
94
+ *
95
+ * The enriched payload is only embeddable (and the cache hit allowed to skip
96
+ * enrichment) when every active enricher is `cacheableOnListHit` — i.e. its
97
+ * output is a pure function of the cached record and invalidated together with
98
+ * it. If any active enricher reads data the list cache does not invalidate on,
99
+ * the route falls back to caching the pre-enrichment payload and re-running
100
+ * enrichers on every request.
101
+ */
102
+ export function resolveListCacheEnricherPlan(
103
+ targetEntity: string,
104
+ context: EnricherContext,
105
+ ): ListCacheEnricherPlan {
106
+ const active = getActiveEnrichers(targetEntity, context)
107
+ if (active.length === 0) return { signature: '', skipEnrichersOnCacheHit: false }
108
+ const allCacheable = active.every((entry) => entry.enricher.cacheableOnListHit === true)
109
+ if (!allCacheable) return { signature: '', skipEnrichersOnCacheHit: false }
110
+ return {
111
+ signature: active.map((entry) => entry.enricher.id).join(','),
112
+ skipEnrichersOnCacheHit: true,
113
+ }
114
+ }
115
+
68
116
  type CacheLike = {
69
117
  get: (key: string) => Promise<unknown>
70
118
  set: (key: string, value: unknown, options?: { ttl?: number; tags?: string[] }) => Promise<unknown>
@@ -62,7 +62,7 @@ import {
62
62
  import { deriveCrudSegmentTag } from './cache-stats'
63
63
  import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
64
64
  import { getTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
65
- import { applyResponseEnrichers, applyResponseEnricherToRecord } from './enricher-runner'
65
+ import { applyResponseEnrichers, applyResponseEnricherToRecord, resolveListCacheEnricherPlan, type ListCacheEnricherPlan } from './enricher-runner'
66
66
  import type { EnricherContext } from './response-enricher'
67
67
  import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor'
68
68
  import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner'
@@ -879,13 +879,18 @@ function serializeSearchParams(params: URLSearchParams): string {
879
879
  return JSON.stringify(normalized)
880
880
  }
881
881
 
882
- function buildCrudCacheKey(resource: string, request: Request, ctx: CrudCtx): string {
882
+ function buildCrudCacheKey(
883
+ resource: string,
884
+ request: Request,
885
+ ctx: CrudCtx,
886
+ enricherSignature = '',
887
+ ): string {
883
888
  const url = new URL(request.url)
884
889
  const scopeIds = collectScopeOrganizationIds(ctx)
885
890
  const scopeSegment = scopeIds.length
886
891
  ? scopeIds.map((id) => normalizeTagSegment(id)).sort((a, b) => a.localeCompare(b)).join(',')
887
892
  : 'none'
888
- return [
893
+ const segments = [
889
894
  'crud',
890
895
  normalizeTagSegment(resource),
891
896
  'GET',
@@ -894,7 +899,18 @@ function buildCrudCacheKey(resource: string, request: Request, ctx: CrudCtx): st
894
899
  `selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
895
900
  `scope:${scopeSegment}`,
896
901
  `query:${serializeSearchParams(url.searchParams)}`,
897
- ].join('|')
902
+ ]
903
+ // The cached list payload already embeds enricher output (enrichment runs before
904
+ // the cache store), so the cache key MUST partition by the set of enrichers a
905
+ // request's entitlements actually select. Two callers in the same tenant/org
906
+ // scope but with different active enrichers (e.g. one holding the enricher's
907
+ // gating feature and one not) get distinct entries, which lets the cache-hit
908
+ // path skip re-running enrichers without leaking ACL-gated fields across
909
+ // feature cohorts. Routes without enrichers pass '' and keep their key shape.
910
+ if (enricherSignature) {
911
+ segments.push(`enrichers:${normalizeTagSegment(enricherSignature)}`)
912
+ }
913
+ return segments.join('|')
898
914
  }
899
915
 
900
916
  function extractRecordIds(items: any[], idField: string): string[] {
@@ -1106,6 +1122,21 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1106
1122
  return resolveUserFeaturesOnce(ctx)
1107
1123
  }
1108
1124
 
1125
+ const NO_ENRICHER_CACHE_PLAN: ListCacheEnricherPlan = { signature: '', skipEnrichersOnCacheHit: false }
1126
+
1127
+ /**
1128
+ * Resolve whether this request's CRUD list cache may embed enricher output and
1129
+ * the cache-key signature to partition by. Returns the no-op plan when no
1130
+ * enrichers are configured or active — keeping the cache key identical to the
1131
+ * pre-enricher shape and forcing enrichers (if any) to run on every request.
1132
+ */
1133
+ async function resolveListCachePlan(ctx: CrudCtx): Promise<ListCacheEnricherPlan> {
1134
+ if (!opts.enrichers?.entityId) return NO_ENRICHER_CACHE_PLAN
1135
+ const enricherCtx = await buildEnricherContext(ctx)
1136
+ if (!enricherCtx) return NO_ENRICHER_CACHE_PLAN
1137
+ return resolveListCacheEnricherPlan(opts.enrichers.entityId, enricherCtx)
1138
+ }
1139
+
1109
1140
  const interceptorContextCache = new WeakMap<object, ReturnType<typeof buildInterceptorContextInner>>()
1110
1141
 
1111
1142
  async function buildInterceptorContextInner(ctx: CrudCtx) {
@@ -1357,7 +1388,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1357
1388
  ? process.hrtime.bigint()
1358
1389
  : null
1359
1390
  const cache = cacheEnabled ? resolveCrudCache(ctx.container) : null
1360
- const cacheKey = cacheEnabled ? buildCrudCacheKey(resourceKind, request, ctx) : null
1391
+ const enricherCachePlan = cacheEnabled ? await resolveListCachePlan(ctx) : NO_ENRICHER_CACHE_PLAN
1392
+ const cacheKey = cacheEnabled ? buildCrudCacheKey(resourceKind, request, ctx, enricherCachePlan.signature) : null
1361
1393
  let cacheStatus: 'hit' | 'miss' = 'miss'
1362
1394
  let cachedValue: CrudCacheStoredValue | null = null
1363
1395
 
@@ -1414,6 +1446,25 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1414
1446
  }
1415
1447
  }
1416
1448
 
1449
+ // Enrich the (miss-path) payload and store it in the CRUD cache, honoring
1450
+ // the request's enricher cache plan:
1451
+ // - skipEnrichersOnCacheHit: every active enricher is record-pure and safe
1452
+ // to embed, so enrich first and cache the enriched payload (a later hit
1453
+ // serves it without re-running enrichers — the #2222 optimization).
1454
+ // - otherwise: cache the PRE-enrichment payload, then enrich only the
1455
+ // response. A later hit re-runs enrichers against fresh data, and no live
1456
+ // enrichment is ever embedded in the shared cache entry (avoids stale
1457
+ // cross-module output and cross-cohort ACL leaks).
1458
+ const enrichAndStorePayload = async (payload: any) => {
1459
+ if (enricherCachePlan.skipEnrichersOnCacheHit) {
1460
+ await enrichListPayload(payload, ctx, profiler)
1461
+ await maybeStoreCrudCache(payload)
1462
+ return
1463
+ }
1464
+ await maybeStoreCrudCache(payload)
1465
+ await enrichListPayload(payload, ctx, profiler)
1466
+ }
1467
+
1417
1468
  const logCacheOutcome = (event: 'hit' | 'miss', itemCount: number) => {
1418
1469
  if (!cacheTimerStart) return
1419
1470
  const elapsedMs = Number(process.hrtime.bigint() - cacheTimerStart) / 1_000_000
@@ -1498,7 +1549,20 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1498
1549
  return json(cacheAfterInterceptors.body, { status: cacheAfterInterceptors.statusCode, headers: cacheAfterInterceptors.headers })
1499
1550
  }
1500
1551
  Object.assign(payload, cacheAfterInterceptors.body)
1501
- await enrichListPayload(payload, ctx, profiler)
1552
+ if (enricherCachePlan.skipEnrichersOnCacheHit) {
1553
+ // Every active enricher is record-pure: the cached payload already
1554
+ // embeds their output and the cache key is partitioned by the active
1555
+ // enricher signature, so the cached enrichment matches this caller's
1556
+ // entitlements exactly. Skipping it removes the per-hit enricher cost
1557
+ // (the ~15ms regression reported in #2222) while staying ACL-gated.
1558
+ profiler.mark('enrichers_skipped_cache_hit', { enricherSignature: enricherCachePlan.signature || null })
1559
+ } else {
1560
+ // Live-mode enrichers (or none): the cached payload is the
1561
+ // pre-enrichment base, so re-run enrichers against current data. This
1562
+ // keeps cross-module / time-dependent enrichment (catalog images,
1563
+ // pipeline state) fresh on cache hits.
1564
+ await enrichListPayload(payload, ctx, profiler)
1565
+ }
1502
1566
  logCacheOutcome('hit', items.length)
1503
1567
  const response = respondWithPayload(payload)
1504
1568
  finishProfile({ result: 'cache_hit', cacheStatus })
@@ -1728,8 +1792,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1728
1792
  return json(afterInterceptors.body, { status: afterInterceptors.statusCode, headers: afterInterceptors.headers })
1729
1793
  }
1730
1794
  Object.assign(payload, afterInterceptors.body)
1731
- await enrichListPayload(payload, ctx, profiler)
1732
- await maybeStoreCrudCache(payload)
1795
+ await enrichAndStorePayload(payload)
1733
1796
  profiler.mark('cache_store_attempt', { cacheEnabled })
1734
1797
  logCacheOutcome(cacheStatus, payload.items.length)
1735
1798
  const response = respondWithPayload(payload)
@@ -1914,8 +1977,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1914
1977
  return json(fallbackAfterInterceptors.body, { status: fallbackAfterInterceptors.statusCode, headers: fallbackAfterInterceptors.headers })
1915
1978
  }
1916
1979
  Object.assign(payload, fallbackAfterInterceptors.body)
1917
- await enrichListPayload(payload, ctx, profiler)
1918
- await maybeStoreCrudCache(payload)
1980
+ await enrichAndStorePayload(payload)
1919
1981
  profiler.mark('cache_store_attempt', { cacheEnabled })
1920
1982
  logCacheOutcome(cacheStatus, payload.items.length)
1921
1983
  const response = respondWithPayload(payload)
@@ -71,6 +71,23 @@ export interface ResponseEnricher<TRecord = any, TEnriched = any> {
71
71
  /** If true, enricher errors propagate as HTTP errors. Default: false */
72
72
  critical?: boolean
73
73
 
74
+ /**
75
+ * When true, this enricher's output for a record is a pure function of that
76
+ * record's own cached state and is invalidated together with it, so the
77
+ * enriched value is safe to embed in the CRUD list cache and serve back on a
78
+ * cache hit WITHOUT re-running the enricher (the #2222 cache-hit optimization).
79
+ *
80
+ * Leave this false (the default) for any enricher whose output depends on data
81
+ * the list cache does not invalidate on: cross-module / cross-entity reads
82
+ * (e.g. a product image fetched for a sales line), wall-clock-relative values
83
+ * (e.g. "days in stage"), or aggregates over other tables. Such enrichers MUST
84
+ * re-run on every request so the response reflects current data — embedding
85
+ * their output in the shared cache entry would serve stale values.
86
+ *
87
+ * Default: false (fail-closed — always re-run on a list cache hit).
88
+ */
89
+ cacheableOnListHit?: boolean
90
+
74
91
  /** Tenant IDs where this enricher should be disabled. */
75
92
  disabledTenantIds?: string[]
76
93