@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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/commands/flush.js +23 -1
  3. package/dist/lib/commands/flush.js.map +2 -2
  4. package/dist/lib/crud/factory.js +16 -0
  5. package/dist/lib/crud/factory.js.map +2 -2
  6. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  7. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  8. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  9. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  10. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  11. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  12. package/dist/lib/crud/optimistic-lock.js +172 -0
  13. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  14. package/dist/lib/di/container.js +18 -2
  15. package/dist/lib/di/container.js.map +2 -2
  16. package/dist/lib/version.js +1 -1
  17. package/dist/lib/version.js.map +1 -1
  18. package/package.json +2 -2
  19. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  20. package/src/lib/commands/flush.ts +79 -2
  21. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  22. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  23. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  24. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  25. package/src/lib/crud/factory.ts +23 -0
  26. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  27. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  28. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  29. package/src/lib/crud/optimistic-lock.ts +379 -0
  30. 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
+ })
@@ -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