@open-mercato/shared 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509
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,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertOptimisticLock,
|
|
3
|
+
buildOptimisticLockConflictBody,
|
|
4
|
+
createCommandOptimisticLockGuardService,
|
|
5
|
+
enforceCommandOptimisticLock,
|
|
6
|
+
enforceRecordGoneIsConflict,
|
|
7
|
+
readOptimisticLockExpected,
|
|
8
|
+
} from '../optimistic-lock-command'
|
|
9
|
+
import { CrudHttpError, isCrudHttpError } from '../errors'
|
|
10
|
+
import {
|
|
11
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
12
|
+
OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
13
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
14
|
+
} from '../optimistic-lock-headers'
|
|
15
|
+
|
|
16
|
+
const A = '2026-05-25T08:42:18.123Z'
|
|
17
|
+
const B = '2026-05-25T08:42:20.999Z'
|
|
18
|
+
|
|
19
|
+
function headersWith(token: string | null): Headers {
|
|
20
|
+
const h = new Headers()
|
|
21
|
+
if (token != null) h.set(OPTIMISTIC_LOCK_HEADER_NAME, token)
|
|
22
|
+
return h
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('readOptimisticLockExpected', () => {
|
|
26
|
+
it('reads + trims the header from a Headers object', () => {
|
|
27
|
+
expect(readOptimisticLockExpected(headersWith(` ${A} `))).toBe(A)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('reads the header from a Request', () => {
|
|
31
|
+
const req = new Request('https://example.test/api/sales/order-lines', {
|
|
32
|
+
method: 'PUT',
|
|
33
|
+
headers: headersWith(A),
|
|
34
|
+
})
|
|
35
|
+
expect(readOptimisticLockExpected(req)).toBe(A)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns null when the header is absent / empty / source missing', () => {
|
|
39
|
+
expect(readOptimisticLockExpected(headersWith(null))).toBeNull()
|
|
40
|
+
expect(readOptimisticLockExpected(headersWith(' '))).toBeNull()
|
|
41
|
+
expect(readOptimisticLockExpected(null)).toBeNull()
|
|
42
|
+
expect(readOptimisticLockExpected(undefined)).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('assertOptimisticLock', () => {
|
|
47
|
+
it('throws 409 with the structured body on a version mismatch', () => {
|
|
48
|
+
let caught: unknown
|
|
49
|
+
try {
|
|
50
|
+
assertOptimisticLock({
|
|
51
|
+
resourceKind: 'sales.order',
|
|
52
|
+
resourceId: 'order-1',
|
|
53
|
+
expected: A,
|
|
54
|
+
current: B,
|
|
55
|
+
envValue: 'all',
|
|
56
|
+
})
|
|
57
|
+
} catch (err) {
|
|
58
|
+
caught = err
|
|
59
|
+
}
|
|
60
|
+
expect(isCrudHttpError(caught)).toBe(true)
|
|
61
|
+
const httpError = caught as CrudHttpError
|
|
62
|
+
expect(httpError.status).toBe(409)
|
|
63
|
+
expect(httpError.body).toEqual({
|
|
64
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
65
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
66
|
+
currentUpdatedAt: B,
|
|
67
|
+
expectedUpdatedAt: A,
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('passes when expected matches current (normalized comparison across Date + string)', () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
assertOptimisticLock({
|
|
74
|
+
resourceKind: 'sales.order',
|
|
75
|
+
resourceId: 'order-1',
|
|
76
|
+
expected: A,
|
|
77
|
+
current: new Date(A),
|
|
78
|
+
envValue: 'all',
|
|
79
|
+
}),
|
|
80
|
+
).not.toThrow()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('is a no-op when no expected token is supplied (strictly additive)', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
assertOptimisticLock({
|
|
86
|
+
resourceKind: 'sales.order',
|
|
87
|
+
resourceId: 'order-1',
|
|
88
|
+
expected: null,
|
|
89
|
+
current: B,
|
|
90
|
+
envValue: 'all',
|
|
91
|
+
}),
|
|
92
|
+
).not.toThrow()
|
|
93
|
+
expect(() =>
|
|
94
|
+
assertOptimisticLock({
|
|
95
|
+
resourceKind: 'sales.order',
|
|
96
|
+
resourceId: 'order-1',
|
|
97
|
+
expected: ' ',
|
|
98
|
+
current: B,
|
|
99
|
+
envValue: 'all',
|
|
100
|
+
}),
|
|
101
|
+
).not.toThrow()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('is a no-op when the current version is missing (let the command 404)', () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
assertOptimisticLock({
|
|
107
|
+
resourceKind: 'sales.order',
|
|
108
|
+
resourceId: 'order-1',
|
|
109
|
+
expected: A,
|
|
110
|
+
current: null,
|
|
111
|
+
envValue: 'all',
|
|
112
|
+
}),
|
|
113
|
+
).not.toThrow()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('is a no-op when expected is an unparseable token', () => {
|
|
117
|
+
expect(() =>
|
|
118
|
+
assertOptimisticLock({
|
|
119
|
+
resourceKind: 'sales.order',
|
|
120
|
+
resourceId: 'order-1',
|
|
121
|
+
expected: 'not-a-date',
|
|
122
|
+
current: B,
|
|
123
|
+
envValue: 'all',
|
|
124
|
+
}),
|
|
125
|
+
).not.toThrow()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('respects OM_OPTIMISTIC_LOCK=off (no 409 even on mismatch)', () => {
|
|
129
|
+
expect(() =>
|
|
130
|
+
assertOptimisticLock({
|
|
131
|
+
resourceKind: 'sales.order',
|
|
132
|
+
resourceId: 'order-1',
|
|
133
|
+
expected: A,
|
|
134
|
+
current: B,
|
|
135
|
+
envValue: 'off',
|
|
136
|
+
}),
|
|
137
|
+
).not.toThrow()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('respects an allow-list that excludes the resourceKind', () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
assertOptimisticLock({
|
|
143
|
+
resourceKind: 'sales.order',
|
|
144
|
+
resourceId: 'order-1',
|
|
145
|
+
expected: A,
|
|
146
|
+
current: B,
|
|
147
|
+
envValue: 'customers.company',
|
|
148
|
+
}),
|
|
149
|
+
).not.toThrow()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('enforces the check for a resourceKind present in the allow-list', () => {
|
|
153
|
+
expect(() =>
|
|
154
|
+
assertOptimisticLock({
|
|
155
|
+
resourceKind: 'sales.order',
|
|
156
|
+
resourceId: 'order-1',
|
|
157
|
+
expected: A,
|
|
158
|
+
current: B,
|
|
159
|
+
envValue: 'sales.order',
|
|
160
|
+
}),
|
|
161
|
+
).toThrow(CrudHttpError)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('is ON by default (env unset) — mismatch 409s', () => {
|
|
165
|
+
const prev = process.env.OM_OPTIMISTIC_LOCK
|
|
166
|
+
delete process.env.OM_OPTIMISTIC_LOCK
|
|
167
|
+
try {
|
|
168
|
+
expect(() =>
|
|
169
|
+
assertOptimisticLock({
|
|
170
|
+
resourceKind: 'sales.order',
|
|
171
|
+
resourceId: 'order-1',
|
|
172
|
+
expected: A,
|
|
173
|
+
current: B,
|
|
174
|
+
}),
|
|
175
|
+
).toThrow(CrudHttpError)
|
|
176
|
+
} finally {
|
|
177
|
+
if (prev !== undefined) process.env.OM_OPTIMISTIC_LOCK = prev
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('enforceCommandOptimisticLock', () => {
|
|
183
|
+
it('reads the expected token from the request header and 409s on mismatch', () => {
|
|
184
|
+
const req = new Request('https://example.test/api/sales/order-lines', {
|
|
185
|
+
method: 'PUT',
|
|
186
|
+
headers: headersWith(A),
|
|
187
|
+
})
|
|
188
|
+
expect(() =>
|
|
189
|
+
enforceCommandOptimisticLock({
|
|
190
|
+
resourceKind: 'sales.order',
|
|
191
|
+
resourceId: 'order-1',
|
|
192
|
+
current: B,
|
|
193
|
+
request: req,
|
|
194
|
+
envValue: 'all',
|
|
195
|
+
}),
|
|
196
|
+
).toThrow(CrudHttpError)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('prefers the explicit expected override over the header', () => {
|
|
200
|
+
const req = new Request('https://example.test/api/sales/order-lines', {
|
|
201
|
+
method: 'PUT',
|
|
202
|
+
headers: headersWith(B), // header matches current → would pass if used
|
|
203
|
+
})
|
|
204
|
+
expect(() =>
|
|
205
|
+
enforceCommandOptimisticLock({
|
|
206
|
+
resourceKind: 'sales.order',
|
|
207
|
+
resourceId: 'order-1',
|
|
208
|
+
current: B,
|
|
209
|
+
expected: A, // stale override → must 409
|
|
210
|
+
request: req,
|
|
211
|
+
envValue: 'all',
|
|
212
|
+
}),
|
|
213
|
+
).toThrow(CrudHttpError)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('is a no-op when neither override nor header is present', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
enforceCommandOptimisticLock({
|
|
219
|
+
resourceKind: 'sales.order',
|
|
220
|
+
resourceId: 'order-1',
|
|
221
|
+
current: B,
|
|
222
|
+
request: headersWith(null),
|
|
223
|
+
envValue: 'all',
|
|
224
|
+
}),
|
|
225
|
+
).not.toThrow()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('passes when the header matches the current version', () => {
|
|
229
|
+
expect(() =>
|
|
230
|
+
enforceCommandOptimisticLock({
|
|
231
|
+
resourceKind: 'sales.order',
|
|
232
|
+
resourceId: 'order-1',
|
|
233
|
+
current: new Date(A),
|
|
234
|
+
request: headersWith(A),
|
|
235
|
+
envValue: 'all',
|
|
236
|
+
}),
|
|
237
|
+
).not.toThrow()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('enforceRecordGoneIsConflict', () => {
|
|
242
|
+
it('409s when the client opted in (header present) but the record is gone', () => {
|
|
243
|
+
let caught: unknown
|
|
244
|
+
try {
|
|
245
|
+
enforceRecordGoneIsConflict({
|
|
246
|
+
resourceKind: 'customers.interaction',
|
|
247
|
+
resourceId: 'int-1',
|
|
248
|
+
request: headersWith(A),
|
|
249
|
+
envValue: 'all',
|
|
250
|
+
})
|
|
251
|
+
} catch (err) {
|
|
252
|
+
caught = err
|
|
253
|
+
}
|
|
254
|
+
expect(isCrudHttpError(caught)).toBe(true)
|
|
255
|
+
const httpError = caught as CrudHttpError
|
|
256
|
+
expect(httpError.status).toBe(409)
|
|
257
|
+
// No current version exists for a deleted record, so it echoes the expected token.
|
|
258
|
+
expect(httpError.body).toEqual({
|
|
259
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
260
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
261
|
+
currentUpdatedAt: A,
|
|
262
|
+
expectedUpdatedAt: A,
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('is a no-op when the client did not opt in (no header → caller 404 fires)', () => {
|
|
267
|
+
expect(() =>
|
|
268
|
+
enforceRecordGoneIsConflict({
|
|
269
|
+
resourceKind: 'customers.interaction',
|
|
270
|
+
resourceId: 'int-1',
|
|
271
|
+
request: headersWith(null),
|
|
272
|
+
envValue: 'all',
|
|
273
|
+
}),
|
|
274
|
+
).not.toThrow()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('prefers an explicit expected override over the header', () => {
|
|
278
|
+
expect(() =>
|
|
279
|
+
enforceRecordGoneIsConflict({
|
|
280
|
+
resourceKind: 'customers.interaction',
|
|
281
|
+
resourceId: 'int-1',
|
|
282
|
+
expected: A,
|
|
283
|
+
request: headersWith(null),
|
|
284
|
+
envValue: 'all',
|
|
285
|
+
}),
|
|
286
|
+
).toThrow(CrudHttpError)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('is a no-op when the env disables the guard for the resource', () => {
|
|
290
|
+
expect(() =>
|
|
291
|
+
enforceRecordGoneIsConflict({
|
|
292
|
+
resourceKind: 'customers.interaction',
|
|
293
|
+
resourceId: 'int-1',
|
|
294
|
+
request: headersWith(A),
|
|
295
|
+
envValue: 'off',
|
|
296
|
+
}),
|
|
297
|
+
).not.toThrow()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('is a no-op when the expected token is unparseable', () => {
|
|
301
|
+
expect(() =>
|
|
302
|
+
enforceRecordGoneIsConflict({
|
|
303
|
+
resourceKind: 'customers.interaction',
|
|
304
|
+
resourceId: 'int-1',
|
|
305
|
+
request: headersWith('not-a-date'),
|
|
306
|
+
envValue: 'all',
|
|
307
|
+
}),
|
|
308
|
+
).not.toThrow()
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('buildOptimisticLockConflictBody', () => {
|
|
313
|
+
it('shapes the structured conflict body', () => {
|
|
314
|
+
expect(buildOptimisticLockConflictBody(B, A)).toEqual({
|
|
315
|
+
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
316
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
317
|
+
currentUpdatedAt: B,
|
|
318
|
+
expectedUpdatedAt: A,
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('createCommandOptimisticLockGuardService', () => {
|
|
324
|
+
it('default service mirrors enforceCommandOptimisticLock (header compare → 409 on mismatch)', async () => {
|
|
325
|
+
const guard = createCommandOptimisticLockGuardService()
|
|
326
|
+
let caught: unknown
|
|
327
|
+
try {
|
|
328
|
+
await guard.enforce({
|
|
329
|
+
resourceKind: 'sales.order',
|
|
330
|
+
resourceId: 'order-1',
|
|
331
|
+
current: B,
|
|
332
|
+
request: headersWith(A),
|
|
333
|
+
envValue: 'all',
|
|
334
|
+
})
|
|
335
|
+
} catch (err) {
|
|
336
|
+
caught = err
|
|
337
|
+
}
|
|
338
|
+
expect(isCrudHttpError(caught)).toBe(true)
|
|
339
|
+
expect((caught as CrudHttpError).status).toBe(409)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('default service is a no-op when the client sends no expected token (strictly additive)', async () => {
|
|
343
|
+
const guard = createCommandOptimisticLockGuardService()
|
|
344
|
+
await expect(
|
|
345
|
+
guard.enforce({
|
|
346
|
+
resourceKind: 'sales.order',
|
|
347
|
+
resourceId: 'order-1',
|
|
348
|
+
current: B,
|
|
349
|
+
request: headersWith(null),
|
|
350
|
+
envValue: 'all',
|
|
351
|
+
}),
|
|
352
|
+
).resolves.toBeUndefined()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('default service is a no-op when the env disables the guard for the resource', async () => {
|
|
356
|
+
const guard = createCommandOptimisticLockGuardService()
|
|
357
|
+
await expect(
|
|
358
|
+
guard.enforce({
|
|
359
|
+
resourceKind: 'sales.order',
|
|
360
|
+
resourceId: 'order-1',
|
|
361
|
+
current: B,
|
|
362
|
+
request: headersWith(A),
|
|
363
|
+
envValue: 'off',
|
|
364
|
+
}),
|
|
365
|
+
).resolves.toBeUndefined()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('resolveExpected overrides the header-derived token (enterprise extension point)', async () => {
|
|
369
|
+
const seen: Array<{ expectedFromHeader: string | null; resourceKind: string; resourceId: string }> = []
|
|
370
|
+
// Resolver supplies a stale token even though the client header matches `current`,
|
|
371
|
+
// proving enterprise can drive the expected version from a lock record.
|
|
372
|
+
const guard = createCommandOptimisticLockGuardService({
|
|
373
|
+
resolveExpected: (input) => {
|
|
374
|
+
seen.push(input)
|
|
375
|
+
return A
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
let caught: unknown
|
|
379
|
+
try {
|
|
380
|
+
await guard.enforce({
|
|
381
|
+
resourceKind: 'sales.order',
|
|
382
|
+
resourceId: 'order-9',
|
|
383
|
+
current: B,
|
|
384
|
+
request: headersWith(B),
|
|
385
|
+
envValue: 'all',
|
|
386
|
+
})
|
|
387
|
+
} catch (err) {
|
|
388
|
+
caught = err
|
|
389
|
+
}
|
|
390
|
+
expect(isCrudHttpError(caught)).toBe(true)
|
|
391
|
+
expect((caught as CrudHttpError).status).toBe(409)
|
|
392
|
+
expect(seen).toHaveLength(1)
|
|
393
|
+
expect(seen[0]).toEqual({ expectedFromHeader: B, resourceKind: 'sales.order', resourceId: 'order-9' })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('awaits an async resolveExpected and passes when it returns the current token', async () => {
|
|
397
|
+
const guard = createCommandOptimisticLockGuardService({
|
|
398
|
+
resolveExpected: async () => B,
|
|
399
|
+
})
|
|
400
|
+
await expect(
|
|
401
|
+
guard.enforce({
|
|
402
|
+
resourceKind: 'sales.order',
|
|
403
|
+
resourceId: 'order-9',
|
|
404
|
+
current: B,
|
|
405
|
+
request: headersWith(A),
|
|
406
|
+
envValue: 'all',
|
|
407
|
+
}),
|
|
408
|
+
).resolves.toBeUndefined()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('resolveExpected returning null skips the check (no expected → additive no-op)', async () => {
|
|
412
|
+
const guard = createCommandOptimisticLockGuardService({
|
|
413
|
+
resolveExpected: () => null,
|
|
414
|
+
})
|
|
415
|
+
await expect(
|
|
416
|
+
guard.enforce({
|
|
417
|
+
resourceKind: 'sales.order',
|
|
418
|
+
resourceId: 'order-9',
|
|
419
|
+
current: B,
|
|
420
|
+
request: headersWith(A),
|
|
421
|
+
envValue: 'all',
|
|
422
|
+
}),
|
|
423
|
+
).resolves.toBeUndefined()
|
|
424
|
+
})
|
|
425
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearOptimisticLockReadersForTests,
|
|
3
|
+
getAllOptimisticLockReaders,
|
|
4
|
+
registerOptimisticLockReaderIfAbsent,
|
|
5
|
+
registerOptimisticLockReaders,
|
|
6
|
+
} from '../optimistic-lock-store'
|
|
7
|
+
import { createOptimisticLockGuardService } from '../optimistic-lock'
|
|
8
|
+
import {
|
|
9
|
+
OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
10
|
+
OPTIMISTIC_LOCK_HEADER_NAME,
|
|
11
|
+
} from '../optimistic-lock-headers'
|
|
12
|
+
import type { CrudMutationGuardValidateInput } from '../mutation-guard'
|
|
13
|
+
|
|
14
|
+
describe('optimistic-lock-store', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
clearOptimisticLockReadersForTests()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
clearOptimisticLockReadersForTests()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('starts empty', () => {
|
|
24
|
+
expect(getAllOptimisticLockReaders()).toEqual({})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('registers a single reader', () => {
|
|
28
|
+
const reader = async () => '2026-01-01T00:00:00.000Z'
|
|
29
|
+
registerOptimisticLockReaders({ 'customers.company': reader })
|
|
30
|
+
expect(Object.keys(getAllOptimisticLockReaders())).toEqual(['customers.company'])
|
|
31
|
+
expect(getAllOptimisticLockReaders()['customers.company']).toBe(reader)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('merges multiple registrations across modules', () => {
|
|
35
|
+
const a = async () => '2026-01-01T00:00:00.000Z'
|
|
36
|
+
const b = async () => '2026-02-02T00:00:00.000Z'
|
|
37
|
+
registerOptimisticLockReaders({ 'customers.company': a })
|
|
38
|
+
registerOptimisticLockReaders({ 'sales.order': b })
|
|
39
|
+
const all = getAllOptimisticLockReaders()
|
|
40
|
+
expect(Object.keys(all).sort()).toEqual(['customers.company', 'sales.order'])
|
|
41
|
+
expect(all['customers.company']).toBe(a)
|
|
42
|
+
expect(all['sales.order']).toBe(b)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('later registration overrides earlier for the same key', () => {
|
|
46
|
+
const v1 = async () => 'v1'
|
|
47
|
+
const v2 = async () => 'v2'
|
|
48
|
+
registerOptimisticLockReaders({ 'customers.company': v1 })
|
|
49
|
+
registerOptimisticLockReaders({ 'customers.company': v2 })
|
|
50
|
+
expect(getAllOptimisticLockReaders()['customers.company']).toBe(v2)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('registerOptimisticLockReaderIfAbsent', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
clearOptimisticLockReadersForTests()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterAll(() => {
|
|
60
|
+
clearOptimisticLockReadersForTests()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('writes readers for keys that have no entry yet', () => {
|
|
64
|
+
const reader = async () => '2026-05-26T07:00:00.000Z'
|
|
65
|
+
const written = registerOptimisticLockReaderIfAbsent({ 'customers.deal': reader })
|
|
66
|
+
expect(written).toEqual(['customers.deal'])
|
|
67
|
+
expect(getAllOptimisticLockReaders()['customers.deal']).toBe(reader)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('skips keys that already have a reader (hand-wired wins)', () => {
|
|
71
|
+
const handWired = async () => 'hand-wired'
|
|
72
|
+
const generic = async () => 'generic'
|
|
73
|
+
registerOptimisticLockReaders({ 'customers.company': handWired })
|
|
74
|
+
const written = registerOptimisticLockReaderIfAbsent({ 'customers.company': generic })
|
|
75
|
+
expect(written).toEqual([])
|
|
76
|
+
expect(getAllOptimisticLockReaders()['customers.company']).toBe(handWired)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles a mixed batch — keeps the hand-wired one, writes the new one', () => {
|
|
80
|
+
const handWired = async () => 'hand-wired'
|
|
81
|
+
const genericDeal = async () => 'deal'
|
|
82
|
+
const genericQuote = async () => 'quote'
|
|
83
|
+
registerOptimisticLockReaders({ 'customers.company': handWired })
|
|
84
|
+
const written = registerOptimisticLockReaderIfAbsent({
|
|
85
|
+
'customers.company': async () => 'no-op',
|
|
86
|
+
'customers.deal': genericDeal,
|
|
87
|
+
'sales.quote': genericQuote,
|
|
88
|
+
})
|
|
89
|
+
expect(written.sort()).toEqual(['customers.deal', 'sales.quote'])
|
|
90
|
+
const all = getAllOptimisticLockReaders()
|
|
91
|
+
expect(all['customers.company']).toBe(handWired)
|
|
92
|
+
expect(all['customers.deal']).toBe(genericDeal)
|
|
93
|
+
expect(all['sales.quote']).toBe(genericQuote)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not touch global state when every key is already taken', () => {
|
|
97
|
+
const handWired = async () => 'hand-wired'
|
|
98
|
+
registerOptimisticLockReaders({ 'customers.company': handWired })
|
|
99
|
+
const before = getAllOptimisticLockReaders()
|
|
100
|
+
const written = registerOptimisticLockReaderIfAbsent({ 'customers.company': async () => 'x' })
|
|
101
|
+
expect(written).toEqual([])
|
|
102
|
+
const after = getAllOptimisticLockReaders()
|
|
103
|
+
expect(after['customers.company']).toBe(handWired)
|
|
104
|
+
expect(Object.keys(after)).toEqual(Object.keys(before))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('repeated calls are idempotent — second invocation writes nothing', () => {
|
|
108
|
+
const generic = async () => 'generic'
|
|
109
|
+
expect(registerOptimisticLockReaderIfAbsent({ 'customers.deal': generic })).toEqual([
|
|
110
|
+
'customers.deal',
|
|
111
|
+
])
|
|
112
|
+
expect(registerOptimisticLockReaderIfAbsent({ 'customers.deal': async () => 'x' })).toEqual([])
|
|
113
|
+
expect(getAllOptimisticLockReaders()['customers.deal']).toBe(generic)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('createOptimisticLockGuardService — store-backed fallback', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
clearOptimisticLockReadersForTests()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
afterAll(() => {
|
|
123
|
+
clearOptimisticLockReadersForTests()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
function makeInput(overrides: Partial<CrudMutationGuardValidateInput> = {}): CrudMutationGuardValidateInput {
|
|
127
|
+
const headers = overrides.requestHeaders ?? new Headers()
|
|
128
|
+
return {
|
|
129
|
+
tenantId: 'tenant-1',
|
|
130
|
+
organizationId: 'org-1',
|
|
131
|
+
userId: 'user-1',
|
|
132
|
+
resourceKind: 'customers.company',
|
|
133
|
+
resourceId: 'company-1',
|
|
134
|
+
operation: 'update',
|
|
135
|
+
requestMethod: 'PUT',
|
|
136
|
+
requestHeaders: headers,
|
|
137
|
+
mutationPayload: null,
|
|
138
|
+
...overrides,
|
|
139
|
+
requestHeaders: headers,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('pulls readers from the store when opts.readers is omitted', async () => {
|
|
144
|
+
registerOptimisticLockReaders({
|
|
145
|
+
'customers.company': async () => '2026-05-25T08:00:00.000Z',
|
|
146
|
+
})
|
|
147
|
+
const headers = new Headers()
|
|
148
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-25T08:00:00.000Z')
|
|
149
|
+
const service = createOptimisticLockGuardService({
|
|
150
|
+
getEm: () => ({} as never),
|
|
151
|
+
envValue: 'all',
|
|
152
|
+
})
|
|
153
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
154
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns 409 when the store-backed reader reports a different current value', async () => {
|
|
158
|
+
registerOptimisticLockReaders({
|
|
159
|
+
'customers.company': async () => '2026-05-25T08:00:05.000Z',
|
|
160
|
+
})
|
|
161
|
+
const headers = new Headers()
|
|
162
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-25T08:00:00.000Z')
|
|
163
|
+
const service = createOptimisticLockGuardService({
|
|
164
|
+
getEm: () => ({} as never),
|
|
165
|
+
envValue: 'all',
|
|
166
|
+
})
|
|
167
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
168
|
+
expect(result.ok).toBe(false)
|
|
169
|
+
if (result.ok) throw new Error('expected failure')
|
|
170
|
+
expect(result.status).toBe(409)
|
|
171
|
+
expect(result.body).toMatchObject({
|
|
172
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
173
|
+
currentUpdatedAt: '2026-05-25T08:00:05.000Z',
|
|
174
|
+
expectedUpdatedAt: '2026-05-25T08:00:00.000Z',
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('explicit opts.readers wins over the store for the same key', async () => {
|
|
179
|
+
registerOptimisticLockReaders({
|
|
180
|
+
'customers.company': async () => 'from-store',
|
|
181
|
+
})
|
|
182
|
+
const headers = new Headers()
|
|
183
|
+
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-25T08:00:00.000Z')
|
|
184
|
+
const service = createOptimisticLockGuardService({
|
|
185
|
+
getEm: () => ({} as never),
|
|
186
|
+
envValue: 'all',
|
|
187
|
+
readers: {
|
|
188
|
+
'customers.company': async () => '2026-05-25T08:00:00.000Z', // matches client header
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
192
|
+
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
193
|
+
})
|
|
194
|
+
})
|