@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.
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +12 -1
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +33 -10
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +5 -5
- package/src/lib/commands/types.ts +8 -0
- package/src/lib/crud/__tests__/crud-factory.enricher-cache.test.ts +284 -0
- package/src/lib/crud/enricher-runner.ts +48 -0
- package/src/lib/crud/factory.ts +72 -10
- package/src/lib/crud/response-enricher.ts +17 -0
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.6.4-develop.
|
|
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.
|
|
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.
|
|
93
|
-
"@mikro-orm/decorators": "^7.1.
|
|
94
|
-
"@mikro-orm/postgresql": "^7.1.
|
|
95
|
-
"@open-mercato/cache": "0.6.4-develop.
|
|
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>
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|