@open-mercato/shared 0.6.5-canary.4425.1.75a266b232 → 0.6.5-develop.4393.1.de282b5dfd
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/crud/factory.js +0 -16
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/di/container.js +2 -18
- 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/crud/__tests__/crud-factory.test.ts +0 -106
- package/src/lib/crud/factory.ts +0 -23
- package/src/lib/di/container.ts +1 -17
- package/dist/lib/crud/optimistic-lock-command.js +0 -109
- package/dist/lib/crud/optimistic-lock-command.js.map +0 -7
- package/dist/lib/crud/optimistic-lock-headers.js +0 -15
- package/dist/lib/crud/optimistic-lock-headers.js.map +0 -7
- package/dist/lib/crud/optimistic-lock-store.js +0 -52
- package/dist/lib/crud/optimistic-lock-store.js.map +0 -7
- package/dist/lib/crud/optimistic-lock.js +0 -172
- package/dist/lib/crud/optimistic-lock.js.map +0 -7
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +0 -425
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +0 -194
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +0 -493
- package/src/lib/crud/optimistic-lock-command.ts +0 -305
- package/src/lib/crud/optimistic-lock-headers.ts +0 -30
- package/src/lib/crud/optimistic-lock-store.ts +0 -87
- package/src/lib/crud/optimistic-lock.ts +0 -379
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,493 +0,0 @@
|
|
|
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
|
-
it('passes when env is unset (default mode=all) but no reader is registered (strict-additive opt-in)', async () => {
|
|
107
|
-
const service = makeService({ envValue: undefined, readers: {} })
|
|
108
|
-
const result = await service.validateMutation(makeInput())
|
|
109
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('passes when env is unset (default mode=all) but the client did not send the extension header', async () => {
|
|
113
|
-
const service = makeService({
|
|
114
|
-
envValue: undefined,
|
|
115
|
-
readers: { 'customers.company': async () => '2026-05-25T08:00:00.000Z' },
|
|
116
|
-
})
|
|
117
|
-
const result = await service.validateMutation(makeInput())
|
|
118
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('passes when operation is "create" (we never lock on create)', async () => {
|
|
122
|
-
const service = makeService({ envValue: 'all', readers: {} })
|
|
123
|
-
const result = await service.validateMutation(makeInput({ operation: 'create' }))
|
|
124
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
it('passes when entity is not on the allow-list', async () => {
|
|
128
|
-
const service = makeService({ envValue: 'sales.order' })
|
|
129
|
-
const result = await service.validateMutation(makeInput({ resourceKind: 'customers.company' }))
|
|
130
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('passes when no reader is registered for the entity (env is on but module did not opt in)', async () => {
|
|
134
|
-
const service = makeService({ envValue: 'all', readers: {} })
|
|
135
|
-
const result = await service.validateMutation(makeInput())
|
|
136
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('passes when the client did not send the extension header', async () => {
|
|
140
|
-
const service = makeService({
|
|
141
|
-
envValue: 'all',
|
|
142
|
-
readers: { 'customers.company': async () => new Date().toISOString() },
|
|
143
|
-
})
|
|
144
|
-
const result = await service.validateMutation(makeInput())
|
|
145
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('passes when the client header is malformed (non-ISO)', async () => {
|
|
149
|
-
const headers = new Headers()
|
|
150
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, 'not-a-date')
|
|
151
|
-
const service = makeService({
|
|
152
|
-
envValue: 'all',
|
|
153
|
-
readers: { 'customers.company': async () => new Date().toISOString() },
|
|
154
|
-
})
|
|
155
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
156
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('passes when the record no longer exists (lets the CRUD route 404 fire)', async () => {
|
|
160
|
-
const headers = new Headers()
|
|
161
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-25T08:00:00.000Z')
|
|
162
|
-
const service = makeService({
|
|
163
|
-
envValue: 'all',
|
|
164
|
-
readers: { 'customers.company': async () => null },
|
|
165
|
-
})
|
|
166
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
167
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
168
|
-
})
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
describe('createOptimisticLockGuardService — match / mismatch', () => {
|
|
172
|
-
it('passes when expected matches current exactly', async () => {
|
|
173
|
-
const iso = '2026-05-25T08:00:00.000Z'
|
|
174
|
-
const headers = new Headers()
|
|
175
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, iso)
|
|
176
|
-
const service = makeService({
|
|
177
|
-
envValue: 'all',
|
|
178
|
-
readers: { 'customers.company': async () => iso },
|
|
179
|
-
})
|
|
180
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
181
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('detects mismatches end-to-end with env UNSET (proves default-ON behavior)', async () => {
|
|
185
|
-
const expected = '2026-05-25T08:00:00.000Z'
|
|
186
|
-
const current = '2026-05-25T08:00:05.000Z'
|
|
187
|
-
const headers = new Headers()
|
|
188
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
189
|
-
// No envValue / readers passed via constructor — but we DO pass an
|
|
190
|
-
// inline readers map so the service has a reader to consult. The
|
|
191
|
-
// important assertion is that with envValue=undefined (default mode=all)
|
|
192
|
-
// the guard does NOT short-circuit and still fires the conflict.
|
|
193
|
-
const service = makeService({
|
|
194
|
-
envValue: undefined,
|
|
195
|
-
readers: { 'customers.company': async () => current },
|
|
196
|
-
})
|
|
197
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
198
|
-
expect(result.ok).toBe(false)
|
|
199
|
-
if (result.ok) throw new Error('expected failure')
|
|
200
|
-
expect(result.status).toBe(409)
|
|
201
|
-
expect(result.body).toMatchObject({
|
|
202
|
-
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
203
|
-
currentUpdatedAt: current,
|
|
204
|
-
expectedUpdatedAt: expected,
|
|
205
|
-
})
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('returns 409 with structured body when current is newer than expected', async () => {
|
|
209
|
-
const expected = '2026-05-25T08:00:00.000Z'
|
|
210
|
-
const current = '2026-05-25T08:00:01.000Z'
|
|
211
|
-
const headers = new Headers()
|
|
212
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
213
|
-
const service = makeService({
|
|
214
|
-
envValue: 'all',
|
|
215
|
-
readers: { 'customers.company': async () => current },
|
|
216
|
-
})
|
|
217
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
218
|
-
expect(result.ok).toBe(false)
|
|
219
|
-
if (result.ok) throw new Error('expected failure')
|
|
220
|
-
expect(result.status).toBe(409)
|
|
221
|
-
expect(result.body).toEqual({
|
|
222
|
-
error: OPTIMISTIC_LOCK_CONFLICT_ERROR,
|
|
223
|
-
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
224
|
-
currentUpdatedAt: current,
|
|
225
|
-
expectedUpdatedAt: expected,
|
|
226
|
-
})
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
it('returns 409 when current and expected differ by exactly 1 ms', async () => {
|
|
230
|
-
const expected = '2026-05-25T08:00:00.000Z'
|
|
231
|
-
const current = '2026-05-25T08:00:00.001Z'
|
|
232
|
-
const headers = new Headers()
|
|
233
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
234
|
-
const service = makeService({
|
|
235
|
-
envValue: 'all',
|
|
236
|
-
readers: { 'customers.company': async () => current },
|
|
237
|
-
})
|
|
238
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
239
|
-
expect(result.ok).toBe(false)
|
|
240
|
-
if (result.ok) throw new Error('expected failure')
|
|
241
|
-
expect(result.status).toBe(409)
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('treats current OLDER than expected as a conflict too (clock skew safety)', async () => {
|
|
245
|
-
const expected = '2026-05-25T08:00:01.000Z'
|
|
246
|
-
const current = '2026-05-25T08:00:00.000Z'
|
|
247
|
-
const headers = new Headers()
|
|
248
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
249
|
-
const service = makeService({
|
|
250
|
-
envValue: 'all',
|
|
251
|
-
readers: { 'customers.company': async () => current },
|
|
252
|
-
})
|
|
253
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
254
|
-
expect(result.ok).toBe(false)
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it('honors delete operations', async () => {
|
|
258
|
-
const expected = '2026-05-25T08:00:00.000Z'
|
|
259
|
-
const current = '2026-05-25T08:00:05.000Z'
|
|
260
|
-
const headers = new Headers()
|
|
261
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, expected)
|
|
262
|
-
const service = makeService({
|
|
263
|
-
envValue: 'all',
|
|
264
|
-
readers: { 'customers.company': async () => current },
|
|
265
|
-
})
|
|
266
|
-
const result = await service.validateMutation(
|
|
267
|
-
makeInput({ requestHeaders: headers, operation: 'delete', requestMethod: 'DELETE' }),
|
|
268
|
-
)
|
|
269
|
-
expect(result.ok).toBe(false)
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
it('passes the resolveExpected hook override (enterprise extension point)', async () => {
|
|
273
|
-
const headers = new Headers()
|
|
274
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, 'header-value')
|
|
275
|
-
const captured: Array<{ expectedFromHeader: string | null; resourceKind: string; resourceId: string }> = []
|
|
276
|
-
const service = createOptimisticLockGuardService({
|
|
277
|
-
getEm: () => ({} as never),
|
|
278
|
-
readers: { 'customers.company': async () => '2026-05-25T09:00:00.000Z' },
|
|
279
|
-
envValue: 'customers.company',
|
|
280
|
-
resolveExpected: (input) => {
|
|
281
|
-
captured.push(input)
|
|
282
|
-
return '2026-05-25T09:00:00.000Z'
|
|
283
|
-
},
|
|
284
|
-
})
|
|
285
|
-
const result = await service.validateMutation(makeInput({ requestHeaders: headers }))
|
|
286
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
287
|
-
expect(captured).toEqual([
|
|
288
|
-
{ expectedFromHeader: 'header-value', resourceKind: 'customers.company', resourceId: 'company-1' },
|
|
289
|
-
])
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
it('exposes the resolved config via getConfig()', () => {
|
|
293
|
-
expect(makeService({ envValue: undefined }).getConfig()).toEqual({ mode: 'all' })
|
|
294
|
-
expect(makeService({ envValue: 'off' }).getConfig()).toEqual({ mode: 'off' })
|
|
295
|
-
expect(makeService({ envValue: 'all' }).getConfig()).toEqual({ mode: 'all' })
|
|
296
|
-
const allow = makeService({ envValue: 'customers.company' }).getConfig()
|
|
297
|
-
expect(allow.mode).toBe('allowlist')
|
|
298
|
-
})
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
describe('createGenericOptimisticLockReader', () => {
|
|
302
|
-
class FakeEntity {}
|
|
303
|
-
|
|
304
|
-
type FindOneCapture = {
|
|
305
|
-
entity: unknown
|
|
306
|
-
filter: Record<string, unknown>
|
|
307
|
-
options: Record<string, unknown> | undefined
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function makeEm(rowToReturn: Record<string, unknown> | null, captureSink: FindOneCapture[]) {
|
|
311
|
-
return {
|
|
312
|
-
async findOne(entity: unknown, filter: Record<string, unknown>, options?: Record<string, unknown>) {
|
|
313
|
-
captureSink.push({ entity, filter, options })
|
|
314
|
-
return rowToReturn
|
|
315
|
-
},
|
|
316
|
-
} as never
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
it('returns the updatedAt ISO string when the row exists', async () => {
|
|
320
|
-
const captures: FindOneCapture[] = []
|
|
321
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
322
|
-
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
323
|
-
const result = await reader(em, {
|
|
324
|
-
resourceKind: 'customers.deal',
|
|
325
|
-
resourceId: 'deal-1',
|
|
326
|
-
tenantId: 'tenant-1',
|
|
327
|
-
organizationId: 'org-1',
|
|
328
|
-
})
|
|
329
|
-
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
330
|
-
expect(captures).toHaveLength(1)
|
|
331
|
-
expect(captures[0].entity).toBe(FakeEntity)
|
|
332
|
-
expect(captures[0].filter).toEqual({
|
|
333
|
-
id: 'deal-1',
|
|
334
|
-
tenantId: 'tenant-1',
|
|
335
|
-
organizationId: 'org-1',
|
|
336
|
-
deletedAt: null,
|
|
337
|
-
})
|
|
338
|
-
expect(captures[0].options).toEqual({ fields: ['updatedAt'] })
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it('accepts a string updatedAt as already-ISO', async () => {
|
|
342
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
343
|
-
const em = makeEm({ updatedAt: '2026-05-26T07:00:00.000Z' }, [])
|
|
344
|
-
const result = await reader(em, {
|
|
345
|
-
resourceKind: 'k',
|
|
346
|
-
resourceId: 'r',
|
|
347
|
-
tenantId: 't',
|
|
348
|
-
organizationId: null,
|
|
349
|
-
})
|
|
350
|
-
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it('returns null when the row is missing', async () => {
|
|
354
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
355
|
-
const em = makeEm(null, [])
|
|
356
|
-
const result = await reader(em, {
|
|
357
|
-
resourceKind: 'k',
|
|
358
|
-
resourceId: 'r',
|
|
359
|
-
tenantId: 't',
|
|
360
|
-
organizationId: 't',
|
|
361
|
-
})
|
|
362
|
-
expect(result).toBeNull()
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
it('omits the organizationId filter when none is provided', async () => {
|
|
366
|
-
const captures: FindOneCapture[] = []
|
|
367
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
368
|
-
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
369
|
-
await reader(em, {
|
|
370
|
-
resourceKind: 'k',
|
|
371
|
-
resourceId: 'r',
|
|
372
|
-
tenantId: 't',
|
|
373
|
-
organizationId: null,
|
|
374
|
-
})
|
|
375
|
-
expect(captures[0].filter).toEqual({ id: 'r', tenantId: 't', deletedAt: null })
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
it('skips tenant + org + softDelete filters when the caller disables them', async () => {
|
|
379
|
-
const captures: FindOneCapture[] = []
|
|
380
|
-
const reader = createGenericOptimisticLockReader({
|
|
381
|
-
entity: FakeEntity,
|
|
382
|
-
tenantField: null,
|
|
383
|
-
orgField: null,
|
|
384
|
-
softDeleteField: null,
|
|
385
|
-
})
|
|
386
|
-
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
387
|
-
await reader(em, {
|
|
388
|
-
resourceKind: 'k',
|
|
389
|
-
resourceId: 'r',
|
|
390
|
-
tenantId: 't',
|
|
391
|
-
organizationId: 'o',
|
|
392
|
-
})
|
|
393
|
-
expect(captures[0].filter).toEqual({ id: 'r' })
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
it('merges an extraFilter (discriminator on shared tables)', async () => {
|
|
397
|
-
const captures: FindOneCapture[] = []
|
|
398
|
-
const reader = createGenericOptimisticLockReader({
|
|
399
|
-
entity: FakeEntity,
|
|
400
|
-
extraFilter: { kind: 'company' },
|
|
401
|
-
})
|
|
402
|
-
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
403
|
-
await reader(em, {
|
|
404
|
-
resourceKind: 'customers.company',
|
|
405
|
-
resourceId: 'c',
|
|
406
|
-
tenantId: 't',
|
|
407
|
-
organizationId: 'o',
|
|
408
|
-
})
|
|
409
|
-
expect(captures[0].filter).toMatchObject({ kind: 'company' })
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
it('honours a custom idField / orgField / tenantField / updatedAtField', async () => {
|
|
413
|
-
const captures: FindOneCapture[] = []
|
|
414
|
-
const reader = createGenericOptimisticLockReader({
|
|
415
|
-
entity: FakeEntity,
|
|
416
|
-
idField: 'uuid',
|
|
417
|
-
tenantField: 'tenant',
|
|
418
|
-
orgField: 'org',
|
|
419
|
-
softDeleteField: 'archivedAt',
|
|
420
|
-
updatedAtField: 'modifiedAt',
|
|
421
|
-
})
|
|
422
|
-
const em = makeEm({ modifiedAt: new Date('2026-05-26T07:00:00.000Z') }, captures)
|
|
423
|
-
const result = await reader(em, {
|
|
424
|
-
resourceKind: 'k',
|
|
425
|
-
resourceId: 'abc',
|
|
426
|
-
tenantId: 'T',
|
|
427
|
-
organizationId: 'O',
|
|
428
|
-
})
|
|
429
|
-
expect(result).toBe('2026-05-26T07:00:00.000Z')
|
|
430
|
-
expect(captures[0].filter).toEqual({
|
|
431
|
-
uuid: 'abc',
|
|
432
|
-
tenant: 'T',
|
|
433
|
-
org: 'O',
|
|
434
|
-
archivedAt: null,
|
|
435
|
-
})
|
|
436
|
-
expect(captures[0].options).toEqual({ fields: ['modifiedAt'] })
|
|
437
|
-
})
|
|
438
|
-
|
|
439
|
-
it('fails open (returns null) when findOne throws — never 500s the mutation', async () => {
|
|
440
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
441
|
-
const em = {
|
|
442
|
-
async findOne() {
|
|
443
|
-
throw new Error('column "updated_at" does not exist')
|
|
444
|
-
},
|
|
445
|
-
} as never
|
|
446
|
-
const result = await reader(em, {
|
|
447
|
-
resourceKind: 'k',
|
|
448
|
-
resourceId: 'r',
|
|
449
|
-
tenantId: 't',
|
|
450
|
-
organizationId: 'o',
|
|
451
|
-
})
|
|
452
|
-
expect(result).toBeNull()
|
|
453
|
-
})
|
|
454
|
-
|
|
455
|
-
it('returns null when the projected updatedAt is missing / null / non-Date', async () => {
|
|
456
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
457
|
-
expect(
|
|
458
|
-
await reader(makeEm({}, []), { resourceKind: 'k', resourceId: 'r', tenantId: 't', organizationId: null }),
|
|
459
|
-
).toBeNull()
|
|
460
|
-
expect(
|
|
461
|
-
await reader(makeEm({ updatedAt: null }, []), {
|
|
462
|
-
resourceKind: 'k',
|
|
463
|
-
resourceId: 'r',
|
|
464
|
-
tenantId: 't',
|
|
465
|
-
organizationId: null,
|
|
466
|
-
}),
|
|
467
|
-
).toBeNull()
|
|
468
|
-
expect(
|
|
469
|
-
await reader(makeEm({ updatedAt: 12345 }, []), {
|
|
470
|
-
resourceKind: 'k',
|
|
471
|
-
resourceId: 'r',
|
|
472
|
-
tenantId: 't',
|
|
473
|
-
organizationId: null,
|
|
474
|
-
}),
|
|
475
|
-
).toBeNull()
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
it('plugs into createOptimisticLockGuardService as a reader', async () => {
|
|
479
|
-
const reader = createGenericOptimisticLockReader({ entity: FakeEntity })
|
|
480
|
-
const em = makeEm({ updatedAt: new Date('2026-05-26T07:00:00.000Z') }, [])
|
|
481
|
-
const headers = new Headers()
|
|
482
|
-
headers.set(OPTIMISTIC_LOCK_HEADER_NAME, '2026-05-26T07:00:00.000Z')
|
|
483
|
-
const service = createOptimisticLockGuardService({
|
|
484
|
-
getEm: () => em,
|
|
485
|
-
envValue: 'all',
|
|
486
|
-
readers: { 'customers.deal': reader },
|
|
487
|
-
})
|
|
488
|
-
const result = await service.validateMutation(
|
|
489
|
-
makeInput({ resourceKind: 'customers.deal', requestHeaders: headers }),
|
|
490
|
-
)
|
|
491
|
-
expect(result).toEqual({ ok: true, shouldRunAfterSuccess: false })
|
|
492
|
-
})
|
|
493
|
-
})
|