@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/di/container.ts +17 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createGenericOptimisticLockReader,
|
|
3
|
+
createOptimisticLockGuardService,
|
|
4
|
+
parseOptimisticLockEnv,
|
|
5
|
+
type OptimisticLockCurrentReader,
|
|
6
|
+
} from '../optimistic-lock'
|
|
7
|
+
import {
|
|
8
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
9
|
+
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
10
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
11
|
+
} from '../optimistic-lock-headers'
|
|
12
|
+
import type { CrudMutationGuardValidateInput } from '../mutation-guard'
|
|
13
|
+
|
|
14
|
+
describe('parseOptimisticLockEnv', () => {
|
|
15
|
+
it('returns mode=all when unset (default ON)', () => {
|
|
16
|
+
expect(parseOptimisticLockEnv(undefined)).toEqual({ mode: 'all' })
|
|
17
|
+
expect(parseOptimisticLockEnv(null)).toEqual({ mode: 'all' })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns mode=all for empty / whitespace strings (default ON)', () => {
|
|
21
|
+
expect(parseOptimisticLockEnv('')).toEqual({ mode: 'all' })
|
|
22
|
+
expect(parseOptimisticLockEnv(' ')).toEqual({ mode: 'all' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns mode=off for the explicit "off" token (case-insensitive)', () => {
|
|
26
|
+
expect(parseOptimisticLockEnv('off')).toEqual({ mode: 'off' })
|
|
27
|
+
expect(parseOptimisticLockEnv('OFF')).toEqual({ mode: 'off' })
|
|
28
|
+
expect(parseOptimisticLockEnv(' off ')).toEqual({ mode: 'off' })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.each(['false', '0', 'no', 'disabled', 'none'])(
|
|
32
|
+
'treats "%s" as an off-token (mirrors parseBooleanToken)',
|
|
33
|
+
(token) => {
|
|
34
|
+
expect(parseOptimisticLockEnv(token)).toEqual({ mode: 'off' })
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
it('disables the guard when any off-token appears alongside other entries (input is invalid; off wins)', () => {
|
|
39
|
+
expect(parseOptimisticLockEnv('off,customers.company')).toEqual({ mode: 'off' })
|
|
40
|
+
expect(parseOptimisticLockEnv('customers.company,false')).toEqual({ mode: 'off' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns mode=all for the "all" keyword (case-insensitive, trimmed)', () => {
|
|
44
|
+
expect(parseOptimisticLockEnv('all')).toEqual({ mode: 'all' })
|
|
45
|
+
expect(parseOptimisticLockEnv('ALL')).toEqual({ mode: 'all' })
|
|
46
|
+
expect(parseOptimisticLockEnv(' All ')).toEqual({ mode: 'all' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('builds an allow-list set from a comma-separated list', () => {
|
|
50
|
+
const config = parseOptimisticLockEnv('customers.company,sales.order')
|
|
51
|
+
expect(config.mode).toBe('allowlist')
|
|
52
|
+
if (config.mode !== 'allowlist') throw new Error('expected allowlist')
|
|
53
|
+
expect(Array.from(config.entities).sort()).toEqual(['customers.company', 'sales.order'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('trims, lowercases, and deduplicates allow-list entries', () => {
|
|
57
|
+
const config = parseOptimisticLockEnv(' Customers.Company , customers.company ,SALES.ORDER , ')
|
|
58
|
+
expect(config.mode).toBe('allowlist')
|
|
59
|
+
if (config.mode !== 'allowlist') throw new Error('expected allowlist')
|
|
60
|
+
expect(Array.from(config.entities).sort()).toEqual(['customers.company', 'sales.order'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('promotes the result to mode=all when "all" appears alongside other entities', () => {
|
|
64
|
+
expect(parseOptimisticLockEnv('customers.company,all,sales.order')).toEqual({ mode: 'all' })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
type MockEm = Record<string, unknown>
|
|
69
|
+
|
|
70
|
+
function makeService(opts: {
|
|
71
|
+
envValue?: string | null
|
|
72
|
+
readers?: Record<string, OptimisticLockCurrentReader>
|
|
73
|
+
em?: MockEm
|
|
74
|
+
}) {
|
|
75
|
+
return createOptimisticLockGuardService({
|
|
76
|
+
getEm: () => (opts.em ?? {}) as never,
|
|
77
|
+
readers: opts.readers ?? {},
|
|
78
|
+
envValue: opts.envValue ?? null,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeInput(overrides: Partial<CrudMutationGuardValidateInput> = {}): CrudMutationGuardValidateInput {
|
|
83
|
+
const headers = overrides.requestHeaders ?? new Headers()
|
|
84
|
+
return {
|
|
85
|
+
tenantId: 'tenant-1',
|
|
86
|
+
organizationId: 'org-1',
|
|
87
|
+
userId: 'user-1',
|
|
88
|
+
resourceKind: 'customers.company',
|
|
89
|
+
resourceId: 'company-1',
|
|
90
|
+
operation: 'update',
|
|
91
|
+
requestMethod: 'PUT',
|
|
92
|
+
requestHeaders: headers,
|
|
93
|
+
mutationPayload: null,
|
|
94
|
+
...overrides,
|
|
95
|
+
requestHeaders: headers,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('createOptimisticLockGuardService — short-circuits', () => {
|
|
100
|
+
it('passes when mode is off (explicit off-token)', async () => {
|
|
101
|
+
const service = makeService({ envValue: 'off' })
|
|
102
|
+
const result = await service.validateMutation(makeInput())
|
|
103
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// NEG-02 opt-out (OM_OPTIMISTIC_LOCK=off): this was originally a test.fixme in
|
|
107
|
+
// TC-LOCK-OSS-046.spec.ts because the shared integration app boots default-ON
|
|
108
|
+
// and a second app with the env flag flipped cannot be booted. The behavior is
|
|
109
|
+
// a pure function of the parser + guard, so it is proven here as a unit test:
|
|
110
|
+
// with the lock disabled, a STALE header (a token that mismatches the current
|
|
111
|
+
// updated_at, which would normally 409) instead passes through ok — exactly the
|
|
112
|
+
// 200/no-enforcement contract the integration case asserted.
|
|
113
|
+
it.each(['off', 'false', '0', 'no', 'disabled', 'none'])(
|
|
114
|
+
'NEG-02: with OM_OPTIMISTIC_LOCK="%s" a stale header is NOT enforced (would-be 409 passes)',
|
|
115
|
+
async (offToken) => {
|
|
116
|
+
const stale = '2026-05-25T08:00:00.000Z'
|
|
117
|
+
const current = '2026-05-25T09:00:00.000Z'
|
|
118
|
+
const headers = new Headers()
|
|
119
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, stale)
|
|
120
|
+
const service = makeService({
|
|
121
|
+
envValue: offToken,
|
|
122
|
+
readers: { 'customers.company': async () => current },
|
|
123
|
+
})
|
|
124
|
+
// Sanity: the same stale-vs-current pair DOES 409 when the guard is ON,
|
|
125
|
+
// so this assertion isolates the opt-out, not a degenerate no-op input.
|
|
126
|
+
const onService = makeService({
|
|
127
|
+
envValue: 'all',
|
|
128
|
+
readers: { 'customers.company': async () => current },
|
|
129
|
+
})
|
|
130
|
+
const onResult = await onService.validateMutation(makeInput({ requestHeaders: new Headers(headers) }))
|
|
131
|
+
expect(onResult.ok).toBe(false)
|
|
132
|
+
|
|
133
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
134
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
135
|
+
expect(service.getConfig()).toEqual({ mode: 'off' })
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
it('passes when env is unset (default mode=all) but no reader is registered (strict-additive opt-in)', async () => {
|
|
140
|
+
const service = makeService({ envValue: undefined, readers: {} })
|
|
141
|
+
const result = await service.validateMutation(makeInput())
|
|
142
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('passes when env is unset (default mode=all) but the client did not send the extension header', async () => {
|
|
146
|
+
const service = makeService({
|
|
147
|
+
envValue: undefined,
|
|
148
|
+
readers: { 'customers.company': async () => '2026-05-25T08:00:00.000Z' },
|
|
149
|
+
})
|
|
150
|
+
const result = await service.validateMutation(makeInput())
|
|
151
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('passes when operation is "create" (we never lock on create)', async () => {
|
|
155
|
+
const service = makeService({ envValue: 'all', readers: {} })
|
|
156
|
+
const result = await service.validateMutation(makeInput({ operation: 'create' }))
|
|
157
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('passes when entity is not on the allow-list', async () => {
|
|
161
|
+
const service = makeService({ envValue: 'sales.order' })
|
|
162
|
+
const result = await service.validateMutation(makeInput({ resourceKind: 'customers.company' }))
|
|
163
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('passes when no reader is registered for the entity (env is on but module did not opt in)', async () => {
|
|
167
|
+
const service = makeService({ envValue: 'all', readers: {} })
|
|
168
|
+
const result = await service.validateMutation(makeInput())
|
|
169
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('passes when the client did not send the extension header', async () => {
|
|
173
|
+
const service = makeService({
|
|
174
|
+
envValue: 'all',
|
|
175
|
+
readers: { 'customers.company': async () => new Date().toISOString() },
|
|
176
|
+
})
|
|
177
|
+
const result = await service.validateMutation(makeInput())
|
|
178
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('passes when the client header is malformed (non-ISO)', async () => {
|
|
182
|
+
const headers = new Headers()
|
|
183
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, 'not-a-date')
|
|
184
|
+
const service = makeService({
|
|
185
|
+
envValue: 'all',
|
|
186
|
+
readers: { 'customers.company': async () => new Date().toISOString() },
|
|
187
|
+
})
|
|
188
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
189
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('passes when the record no longer exists (lets the CRUD route 404 fire)', async () => {
|
|
193
|
+
const headers = new Headers()
|
|
194
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-25T08:00:00.000Z')
|
|
195
|
+
const service = makeService({
|
|
196
|
+
envValue: 'all',
|
|
197
|
+
readers: { 'customers.company': async () => null },
|
|
198
|
+
})
|
|
199
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
200
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('createOptimisticLockGuardService — match / mismatch', () => {
|
|
205
|
+
it('passes when expected matches current exactly', async () => {
|
|
206
|
+
const iso = '2026-05-25T08:00:00.000Z'
|
|
207
|
+
const headers = new Headers()
|
|
208
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, iso)
|
|
209
|
+
const service = makeService({
|
|
210
|
+
envValue: 'all',
|
|
211
|
+
readers: { 'customers.company': async () => iso },
|
|
212
|
+
})
|
|
213
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
214
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('detects mismatches end-to-end with env UNSET (proves default-ON behavior)', async () => {
|
|
218
|
+
const expected = '2026-05-25T08:00:00.000Z'
|
|
219
|
+
const current = '2026-05-25T08:00:05.000Z'
|
|
220
|
+
const headers = new Headers()
|
|
221
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
222
|
+
// No envValue / readers passed via constructor — but we DO pass an
|
|
223
|
+
// inline readers map so the service has a reader to consult. The
|
|
224
|
+
// important assertion is that with envValue=undefined (default mode=all)
|
|
225
|
+
// the guard does NOT short-circuit and still fires the conflict.
|
|
226
|
+
const service = makeService({
|
|
227
|
+
envValue: undefined,
|
|
228
|
+
readers: { 'customers.company': async () => current },
|
|
229
|
+
})
|
|
230
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
231
|
+
expect(result.ok).toBe(false)
|
|
232
|
+
if (result.ok) throw new Error('expected failure')
|
|
233
|
+
expect(result.status).toBe(409)
|
|
234
|
+
expect(result.body).toMatchObject({
|
|
235
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
236
|
+
currentUpdatedAt: current,
|
|
237
|
+
expectedUpdatedAt: expected,
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('returns 409 with structured body when current is newer than expected', async () => {
|
|
242
|
+
const expected = '2026-05-25T08:00:00.000Z'
|
|
243
|
+
const current = '2026-05-25T08:00:01.000Z'
|
|
244
|
+
const headers = new Headers()
|
|
245
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
246
|
+
const service = makeService({
|
|
247
|
+
envValue: 'all',
|
|
248
|
+
readers: { 'customers.company': async () => current },
|
|
249
|
+
})
|
|
250
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
251
|
+
expect(result.ok).toBe(false)
|
|
252
|
+
if (result.ok) throw new Error('expected failure')
|
|
253
|
+
expect(result.status).toBe(409)
|
|
254
|
+
expect(result.body).toEqual({
|
|
255
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
256
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
257
|
+
currentUpdatedAt: current,
|
|
258
|
+
expectedUpdatedAt: expected,
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('returns 409 when current and expected differ by exactly 1 ms', async () => {
|
|
263
|
+
const expected = '2026-05-25T08:00:00.000Z'
|
|
264
|
+
const current = '2026-05-25T08:00:00.001Z'
|
|
265
|
+
const headers = new Headers()
|
|
266
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
267
|
+
const service = makeService({
|
|
268
|
+
envValue: 'all',
|
|
269
|
+
readers: { 'customers.company': async () => current },
|
|
270
|
+
})
|
|
271
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
272
|
+
expect(result.ok).toBe(false)
|
|
273
|
+
if (result.ok) throw new Error('expected failure')
|
|
274
|
+
expect(result.status).toBe(409)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('treats current OLDER than expected as a conflict too (clock skew safety)', async () => {
|
|
278
|
+
const expected = '2026-05-25T08:00:01.000Z'
|
|
279
|
+
const current = '2026-05-25T08:00:00.000Z'
|
|
280
|
+
const headers = new Headers()
|
|
281
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
282
|
+
const service = makeService({
|
|
283
|
+
envValue: 'all',
|
|
284
|
+
readers: { 'customers.company': async () => current },
|
|
285
|
+
})
|
|
286
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
287
|
+
expect(result.ok).toBe(false)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('honors delete operations', async () => {
|
|
291
|
+
const expected = '2026-05-25T08:00:00.000Z'
|
|
292
|
+
const current = '2026-05-25T08:00:05.000Z'
|
|
293
|
+
const headers = new Headers()
|
|
294
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
295
|
+
const service = makeService({
|
|
296
|
+
envValue: 'all',
|
|
297
|
+
readers: { 'customers.company': async () => current },
|
|
298
|
+
})
|
|
299
|
+
const result = await service.validateMutation(
|
|
300
|
+
makeInput({ requestHeaders: headers, operation: 'delete', requestMethod: 'DELETE' }),
|
|
301
|
+
)
|
|
302
|
+
expect(result.ok).toBe(false)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('passes the resolveExpected hook override (enterprise extension point)', async () => {
|
|
306
|
+
const headers = new Headers()
|
|
307
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, 'header-value')
|
|
308
|
+
const captured: Array<{ expectedFromHeader: string | null; resourceKind: string; resourceId: string }> = []
|
|
309
|
+
const service = createOptimisticLockGuardService({
|
|
310
|
+
getEm: () => ({} as never),
|
|
311
|
+
readers: { 'customers.company': async () => '2026-05-25T09:00:00.000Z' },
|
|
312
|
+
envValue: 'customers.company',
|
|
313
|
+
resolveExpected: (input) => {
|
|
314
|
+
captured.push(input)
|
|
315
|
+
return '2026-05-25T09:00:00.000Z'
|
|
316
|
+
},
|
|
317
|
+
})
|
|
318
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
319
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
320
|
+
expect(captured).toEqual([
|
|
321
|
+
{ expectedFromHeader: 'header-value', resourceKind: 'customers.company', resourceId: 'company-1' },
|
|
322
|
+
])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('exposes the resolved config via getConfig()', () => {
|
|
326
|
+
expect(makeService({ envValue: undefined }).getConfig()).toEqual({ mode: 'all' })
|
|
327
|
+
expect(makeService({ envValue: 'off' }).getConfig()).toEqual({ mode: 'off' })
|
|
328
|
+
expect(makeService({ envValue: 'all' }).getConfig()).toEqual({ mode: 'all' })
|
|
329
|
+
const allow = makeService({ envValue: 'customers.company' }).getConfig()
|
|
330
|
+
expect(allow.mode).toBe('allowlist')
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe('createGenericOptimisticLockReader', () => {
|
|
335
|
+
class FakeEntity {}
|
|
336
|
+
|
|
337
|
+
type FindOneCapture = {
|
|
338
|
+
entity: unknown
|
|
339
|
+
filter: Record<string, unknown>
|
|
340
|
+
options: Record<string, unknown> | undefined
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function makeEm(rowToReturn: Record<string, unknown> | null, captureSink: FindOneCapture[]) {
|
|
344
|
+
return {
|
|
345
|
+
async findOne(entity: unknown, filter: Record<string, unknown>, options?: Record<string, unknown>) {
|
|
346
|
+
captureSink.push({ entity, filter, options })
|
|
347
|
+
return rowToReturn
|
|
348
|
+
},
|
|
349
|
+
} as never
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
it('returns the updatedAt ISO string when the row exists', async () => {
|
|
353
|
+
const captures: FindOneCapture[] = []
|
|
354
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
355
|
+
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
356
|
+
const result = await reader(em, {
|
|
357
|
+
resourceKind: 'customers.deal',
|
|
358
|
+
resourceId: 'deal-1',
|
|
359
|
+
tenantId: 'tenant-1',
|
|
360
|
+
organizationId: 'org-1',
|
|
361
|
+
})
|
|
362
|
+
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
363
|
+
expect(captures).toHaveLength(1)
|
|
364
|
+
expect(captures[0].entity).toBe(FakeEntity)
|
|
365
|
+
expect(captures[0].filter).toEqual({
|
|
366
|
+
id: 'deal-1',
|
|
367
|
+
tenantId: 'tenant-1',
|
|
368
|
+
organizationId: 'org-1',
|
|
369
|
+
deletedAt: null,
|
|
370
|
+
})
|
|
371
|
+
expect(captures[0].options).toEqual({ fields: ['updatedAt'] })
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('accepts a string updatedAt as already-ISO', async () => {
|
|
375
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
376
|
+
const em = makeEm({ updatedAt: '2026-05-26T07:00:00.000Z' }, [])
|
|
377
|
+
const result = await reader(em, {
|
|
378
|
+
resourceKind: 'k',
|
|
379
|
+
resourceId: 'r',
|
|
380
|
+
tenantId: 't',
|
|
381
|
+
organizationId: null,
|
|
382
|
+
})
|
|
383
|
+
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('returns null when the row is missing', async () => {
|
|
387
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
388
|
+
const em = makeEm(null, [])
|
|
389
|
+
const result = await reader(em, {
|
|
390
|
+
resourceKind: 'k',
|
|
391
|
+
resourceId: 'r',
|
|
392
|
+
tenantId: 't',
|
|
393
|
+
organizationId: 't',
|
|
394
|
+
})
|
|
395
|
+
expect(result).toBeNull()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('omits the organizationId filter when none is provided', async () => {
|
|
399
|
+
const captures: FindOneCapture[] = []
|
|
400
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
401
|
+
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
402
|
+
await reader(em, {
|
|
403
|
+
resourceKind: 'k',
|
|
404
|
+
resourceId: 'r',
|
|
405
|
+
tenantId: 't',
|
|
406
|
+
organizationId: null,
|
|
407
|
+
})
|
|
408
|
+
expect(captures[0].filter).toEqual({ id: 'r', tenantId: 't', deletedAt: null })
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('skips tenant + org + softDelete filters when the caller disables them', async () => {
|
|
412
|
+
const captures: FindOneCapture[] = []
|
|
413
|
+
const reader = createGenericOptimisticLockReader({
|
|
414
|
+
entity: FakeEntity,
|
|
415
|
+
tenantField: null,
|
|
416
|
+
orgField: null,
|
|
417
|
+
softDeleteField: null,
|
|
418
|
+
})
|
|
419
|
+
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
420
|
+
await reader(em, {
|
|
421
|
+
resourceKind: 'k',
|
|
422
|
+
resourceId: 'r',
|
|
423
|
+
tenantId: 't',
|
|
424
|
+
organizationId: 'o',
|
|
425
|
+
})
|
|
426
|
+
expect(captures[0].filter).toEqual({ id: 'r' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('merges an extraFilter (discriminator on shared tables)', async () => {
|
|
430
|
+
const captures: FindOneCapture[] = []
|
|
431
|
+
const reader = createGenericOptimisticLockReader({
|
|
432
|
+
entity: FakeEntity,
|
|
433
|
+
extraFilter: { kind: 'company' },
|
|
434
|
+
})
|
|
435
|
+
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
436
|
+
await reader(em, {
|
|
437
|
+
resourceKind: 'customers.company',
|
|
438
|
+
resourceId: 'c',
|
|
439
|
+
tenantId: 't',
|
|
440
|
+
organizationId: 'o',
|
|
441
|
+
})
|
|
442
|
+
expect(captures[0].filter).toMatchObject({ kind: 'company' })
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('honours a custom idField / orgField / tenantField / updatedAtField', async () => {
|
|
446
|
+
const captures: FindOneCapture[] = []
|
|
447
|
+
const reader = createGenericOptimisticLockReader({
|
|
448
|
+
entity: FakeEntity,
|
|
449
|
+
idField: 'uuid',
|
|
450
|
+
tenantField: 'tenant',
|
|
451
|
+
orgField: 'org',
|
|
452
|
+
softDeleteField: 'archivedAt',
|
|
453
|
+
updatedAtField: 'modifiedAt',
|
|
454
|
+
})
|
|
455
|
+
const em = makeEm({ modifiedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
456
|
+
const result = await reader(em, {
|
|
457
|
+
resourceKind: 'k',
|
|
458
|
+
resourceId: 'abc',
|
|
459
|
+
tenantId: 'T',
|
|
460
|
+
organizationId: 'O',
|
|
461
|
+
})
|
|
462
|
+
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
463
|
+
expect(captures[0].filter).toEqual({
|
|
464
|
+
uuid: 'abc',
|
|
465
|
+
tenant: 'T',
|
|
466
|
+
org: 'O',
|
|
467
|
+
archivedAt: null,
|
|
468
|
+
})
|
|
469
|
+
expect(captures[0].options).toEqual({ fields: ['modifiedAt'] })
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('fails open (returns null) when findOne throws — never 500s the mutation', async () => {
|
|
473
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
474
|
+
const em = {
|
|
475
|
+
async findOne() {
|
|
476
|
+
throw new Error('column "updated_at" does not exist')
|
|
477
|
+
},
|
|
478
|
+
} as never
|
|
479
|
+
const result = await reader(em, {
|
|
480
|
+
resourceKind: 'k',
|
|
481
|
+
resourceId: 'r',
|
|
482
|
+
tenantId: 't',
|
|
483
|
+
organizationId: 'o',
|
|
484
|
+
})
|
|
485
|
+
expect(result).toBeNull()
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('returns null when the projected updatedAt is missing / null / non-Date', async () => {
|
|
489
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
490
|
+
expect(
|
|
491
|
+
await reader(makeEm({}, []), { resourceKind: 'k', resourceId: 'r', tenantId: 't', organizationId: null }),
|
|
492
|
+
).toBeNull()
|
|
493
|
+
expect(
|
|
494
|
+
await reader(makeEm({ updatedAt: null }, []), {
|
|
495
|
+
resourceKind: 'k',
|
|
496
|
+
resourceId: 'r',
|
|
497
|
+
tenantId: 't',
|
|
498
|
+
organizationId: null,
|
|
499
|
+
}),
|
|
500
|
+
).toBeNull()
|
|
501
|
+
expect(
|
|
502
|
+
await reader(makeEm({ updatedAt: 12345 }, []), {
|
|
503
|
+
resourceKind: 'k',
|
|
504
|
+
resourceId: 'r',
|
|
505
|
+
tenantId: 't',
|
|
506
|
+
organizationId: null,
|
|
507
|
+
}),
|
|
508
|
+
).toBeNull()
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('plugs into createOptimisticLockGuardService as a reader', async () => {
|
|
512
|
+
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
513
|
+
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, [])
|
|
514
|
+
const headers = new Headers()
|
|
515
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-26T07:00:00.000Z')
|
|
516
|
+
const service = createOptimisticLockGuardService({
|
|
517
|
+
getEm: () => em,
|
|
518
|
+
envValue: 'all',
|
|
519
|
+
readers: { 'customers.deal': reader },
|
|
520
|
+
})
|
|
521
|
+
const result = await service.validateMutation(
|
|
522
|
+
makeInput({ resourceKind: 'customers.deal', requestHeaders: headers }),
|
|
523
|
+
)
|
|
524
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
525
|
+
})
|
|
526
|
+
})
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -69,6 +69,8 @@ import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor
|
|
|
69
69
|
import { mergeIdFilter, parseIdsParam } from './ids'
|
|
70
70
|
import { mergeAdvancedFilters } from './advanced-filter-integration'
|
|
71
71
|
import { parseExtensionHeaders } from '../umes/extension-headers'
|
|
72
|
+
import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
73
|
+
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
72
74
|
|
|
73
75
|
type RbacServiceLike = {
|
|
74
76
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -939,6 +941,27 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
939
941
|
const resourceKind = resourceInfo.primary
|
|
940
942
|
const resourceAliases = resourceInfo.aliases
|
|
941
943
|
const resourceTargets = expandResourceAliases(resourceKind, resourceAliases)
|
|
944
|
+
|
|
945
|
+
// OSS opt-in optimistic locking — auto-register a generic reader for every
|
|
946
|
+
// CRUD entity using the factory's own ORM config (Step 13.3 of the spec at
|
|
947
|
+
// .ai/specs/2026-05-25-oss-optimistic-locking.md). Hand-wired readers
|
|
948
|
+
// registered earlier via module DI (customers/sales) always win because we
|
|
949
|
+
// use the `IfAbsent` variant. Skipped silently when the route has no
|
|
950
|
+
// resolvable resourceKind or no ORM entity class (e.g. virtual routes).
|
|
951
|
+
if (ormCfg.entity && resourceKind && resourceKind !== 'resource') {
|
|
952
|
+
const genericReader = createGenericOptimisticLockReader({
|
|
953
|
+
entity: ormCfg.entity,
|
|
954
|
+
idField: ormCfg.idField ?? 'id',
|
|
955
|
+
tenantField: ormCfg.tenantField,
|
|
956
|
+
orgField: ormCfg.orgField,
|
|
957
|
+
softDeleteField: ormCfg.softDeleteField,
|
|
958
|
+
})
|
|
959
|
+
const keysToRegister: Record<string, typeof genericReader> = { [resourceKind]: genericReader }
|
|
960
|
+
for (const alias of resourceAliases) {
|
|
961
|
+
if (alias && alias !== resourceKind) keysToRegister[alias] = genericReader
|
|
962
|
+
}
|
|
963
|
+
registerOptimisticLockReaderIfAbsent(keysToRegister)
|
|
964
|
+
}
|
|
942
965
|
const defaultIdentifierResolver: CrudIdentifierResolver = (entity, _action) => {
|
|
943
966
|
const id = normalizeIdentifierValue((entity as any)[ormCfg.idField!])
|
|
944
967
|
const orgId = ormCfg.orgField ? normalizeIdentifierValue((entity as any)[ormCfg.orgField]) : null
|