@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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/AGENTS.md +10 -0
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/command-bus.js +56 -47
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +6 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +106 -0
- package/dist/lib/commands/redo.js.map +7 -0
- package/dist/lib/commands/runCrudCommandWrite.js +38 -0
- package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/errors.js +22 -0
- package/dist/lib/crud/errors.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/data/engine.js +2 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +57 -23
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +41 -8
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/context.js +5 -0
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/package.json +8 -9
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/__tests__/redo.test.ts +265 -0
- package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/command-bus.ts +62 -44
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/redo.ts +235 -0
- package/src/lib/commands/runCrudCommandWrite.ts +82 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/commands/types.ts +54 -1
- 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/errors.ts +29 -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/data/engine.ts +11 -8
- package/src/lib/di/container.ts +17 -1
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +44 -6
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +76 -24
- package/src/lib/encryption/subscriber.ts +54 -9
- package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
- package/src/lib/i18n/context.tsx +11 -0
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/integrations/types.ts +14 -0
- package/src/modules/notifications/handler.ts +7 -0
- package/src/modules/search.ts +9 -0
- package/src/modules/vector.ts +7 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { reviveSnapshotSeed, serializeRowSnapshot, makeCreateRedo } from '../redo'
|
|
2
|
+
|
|
3
|
+
describe('reviveSnapshotSeed', () => {
|
|
4
|
+
it('revives the default date fields from ISO strings to Date', () => {
|
|
5
|
+
const seed = reviveSnapshotSeed({
|
|
6
|
+
id: 'row-1',
|
|
7
|
+
code: 'USD',
|
|
8
|
+
createdAt: '2026-01-02T03:04:05.000Z',
|
|
9
|
+
updatedAt: '2026-01-02T03:04:05.000Z',
|
|
10
|
+
deletedAt: null,
|
|
11
|
+
})
|
|
12
|
+
expect(seed.id).toBe('row-1')
|
|
13
|
+
expect(seed.code).toBe('USD')
|
|
14
|
+
expect(seed.createdAt).toBeInstanceOf(Date)
|
|
15
|
+
expect((seed.createdAt as Date).toISOString()).toBe('2026-01-02T03:04:05.000Z')
|
|
16
|
+
expect(seed.updatedAt).toBeInstanceOf(Date)
|
|
17
|
+
expect(seed.deletedAt).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('revives explicitly declared extra date fields', () => {
|
|
21
|
+
const seed = reviveSnapshotSeed(
|
|
22
|
+
{ id: 'row-1', effectiveAt: '2026-02-03T00:00:00.000Z', updatedAt: '2026-02-03T00:00:00.000Z' },
|
|
23
|
+
['createdAt', 'updatedAt', 'deletedAt', 'effectiveAt'],
|
|
24
|
+
)
|
|
25
|
+
expect(seed.effectiveAt).toBeInstanceOf(Date)
|
|
26
|
+
expect(seed.updatedAt).toBeInstanceOf(Date)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves non-date values untouched and clones the input', () => {
|
|
30
|
+
const snapshot = { id: 'row-1', symbol: null, decimalPlaces: 2 }
|
|
31
|
+
const seed = reviveSnapshotSeed(snapshot)
|
|
32
|
+
expect(seed).not.toBe(snapshot)
|
|
33
|
+
expect(seed.symbol).toBeNull()
|
|
34
|
+
expect(seed.decimalPlaces).toBe(2)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('serializeRowSnapshot', () => {
|
|
39
|
+
it('picks the requested fields and converts dates to ISO strings', () => {
|
|
40
|
+
const entity = {
|
|
41
|
+
id: 'row-1',
|
|
42
|
+
code: 'USD',
|
|
43
|
+
symbol: undefined as unknown as string,
|
|
44
|
+
createdAt: new Date('2026-01-02T03:04:05.000Z'),
|
|
45
|
+
updatedAt: new Date('2026-01-02T03:04:05.000Z'),
|
|
46
|
+
ignored: 'nope',
|
|
47
|
+
}
|
|
48
|
+
const snapshot = serializeRowSnapshot(entity, ['id', 'code', 'symbol', 'createdAt', 'updatedAt'])
|
|
49
|
+
expect(snapshot).toEqual({
|
|
50
|
+
id: 'row-1',
|
|
51
|
+
code: 'USD',
|
|
52
|
+
symbol: null,
|
|
53
|
+
createdAt: '2026-01-02T03:04:05.000Z',
|
|
54
|
+
updatedAt: '2026-01-02T03:04:05.000Z',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('makeCreateRedo defaults', () => {
|
|
60
|
+
function buildContext(em: { fork: () => unknown }) {
|
|
61
|
+
const dataEngine = { markOrmEntityChange: () => undefined }
|
|
62
|
+
return {
|
|
63
|
+
container: {
|
|
64
|
+
resolve: (name: string) => (name === 'em' ? em : dataEngine),
|
|
65
|
+
},
|
|
66
|
+
} as never
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it('uses the snapshot as the seed (with date revival) when no seedFromSnapshot is given', async () => {
|
|
70
|
+
const created: Array<Record<string, unknown>> = []
|
|
71
|
+
const forked = {
|
|
72
|
+
findOne: async () => null,
|
|
73
|
+
create: (_cls: unknown, data: Record<string, unknown>) => {
|
|
74
|
+
created.push(data)
|
|
75
|
+
return { ...data }
|
|
76
|
+
},
|
|
77
|
+
persist: () => undefined,
|
|
78
|
+
flush: async () => undefined,
|
|
79
|
+
}
|
|
80
|
+
const em = { fork: () => forked }
|
|
81
|
+
const redo = makeCreateRedo<{ id: string; organizationId?: string | null; tenantId?: string | null }, { id: string; code: string; createdAt: string; updatedAt: string }>({
|
|
82
|
+
entityClass: class {} as never,
|
|
83
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
84
|
+
})
|
|
85
|
+
const logEntry = {
|
|
86
|
+
snapshotAfter: { id: 'row-1', code: 'USD', createdAt: '2026-01-02T03:04:05.000Z', updatedAt: '2026-01-02T03:04:05.000Z' },
|
|
87
|
+
}
|
|
88
|
+
const result = await redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })
|
|
89
|
+
expect(result).toEqual({ id: 'row-1' })
|
|
90
|
+
expect(created).toHaveLength(1)
|
|
91
|
+
expect(created[0].id).toBe('row-1')
|
|
92
|
+
expect(created[0].code).toBe('USD')
|
|
93
|
+
expect(created[0].createdAt).toBeInstanceOf(Date)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('merges beforeRestore overrides into the create seed and runs before restore', async () => {
|
|
97
|
+
const created: Array<Record<string, unknown>> = []
|
|
98
|
+
const calls: string[] = []
|
|
99
|
+
const forked = {
|
|
100
|
+
findOne: async () => { calls.push('find'); return null },
|
|
101
|
+
create: (_cls: unknown, data: Record<string, unknown>) => { created.push(data); return { ...data } },
|
|
102
|
+
persist: () => undefined,
|
|
103
|
+
flush: async () => undefined,
|
|
104
|
+
}
|
|
105
|
+
const em = { fork: () => forked }
|
|
106
|
+
const resolvedRelation = { id: 'rel-1' }
|
|
107
|
+
const redo = makeCreateRedo<{ id: string }, { id: string; relationId: string }>({
|
|
108
|
+
entityClass: class {} as never,
|
|
109
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
110
|
+
beforeRestore: async ({ snapshot }) => { calls.push('before'); return { relation: resolvedRelation, relationId: undefined } },
|
|
111
|
+
})
|
|
112
|
+
const logEntry = { snapshotAfter: { id: 'row-1', relationId: 'rel-1' } }
|
|
113
|
+
await redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })
|
|
114
|
+
expect(calls).toEqual(['before', 'find'])
|
|
115
|
+
expect(created[0].relation).toBe(resolvedRelation)
|
|
116
|
+
expect(created[0].relationId).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('uses the findRow override instead of em.findOne', async () => {
|
|
120
|
+
const surviving: Record<string, unknown> = { id: 'row-1', deletedAt: new Date() }
|
|
121
|
+
let findRowCalled = false
|
|
122
|
+
const forked = {
|
|
123
|
+
findOne: async () => { throw new Error('default findOne must not run when findRow is set') },
|
|
124
|
+
create: () => { throw new Error('should not create when findRow returns a row') },
|
|
125
|
+
persist: () => undefined,
|
|
126
|
+
flush: async () => undefined,
|
|
127
|
+
}
|
|
128
|
+
const em = { fork: () => forked }
|
|
129
|
+
const redo = makeCreateRedo<{ id: string; deletedAt?: Date | null }, { id: string }>({
|
|
130
|
+
entityClass: class {} as never,
|
|
131
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
132
|
+
findRow: async ({ id }) => { findRowCalled = true; return id === 'row-1' ? (surviving as never) : null },
|
|
133
|
+
})
|
|
134
|
+
await redo({ input: {}, ctx: buildContext(em), logEntry: { snapshotAfter: { id: 'row-1' } } as never })
|
|
135
|
+
expect(findRowCalled).toBe(true)
|
|
136
|
+
expect(surviving.deletedAt).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('passes logEntry to afterRestore', async () => {
|
|
140
|
+
let seenLogEntry: unknown = null
|
|
141
|
+
const forked = {
|
|
142
|
+
findOne: async () => null,
|
|
143
|
+
create: (_cls: unknown, data: Record<string, unknown>) => ({ ...data }),
|
|
144
|
+
persist: () => undefined,
|
|
145
|
+
flush: async () => undefined,
|
|
146
|
+
}
|
|
147
|
+
const em = { fork: () => forked }
|
|
148
|
+
const redo = makeCreateRedo<{ id: string }, { id: string }>({
|
|
149
|
+
entityClass: class {} as never,
|
|
150
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
151
|
+
afterRestore: async ({ logEntry }) => { seenLogEntry = logEntry },
|
|
152
|
+
})
|
|
153
|
+
const logEntry = { snapshotAfter: { id: 'row-1' }, resourceId: 'row-1' }
|
|
154
|
+
await redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })
|
|
155
|
+
expect(seenLogEntry).toBe(logEntry)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('wraps the restore in a transaction when transaction is true', async () => {
|
|
159
|
+
const order: string[] = []
|
|
160
|
+
const forked = {
|
|
161
|
+
isInTransaction: () => false,
|
|
162
|
+
begin: async () => { order.push('begin') },
|
|
163
|
+
commit: async () => { order.push('commit') },
|
|
164
|
+
rollback: async () => { order.push('rollback') },
|
|
165
|
+
getUnitOfWork: () => ({ getChangeSets: () => [] }),
|
|
166
|
+
findOne: async () => null,
|
|
167
|
+
create: (_cls: unknown, data: Record<string, unknown>) => { order.push('create'); return { ...data } },
|
|
168
|
+
persist: () => undefined,
|
|
169
|
+
flush: async () => { order.push('flush') },
|
|
170
|
+
}
|
|
171
|
+
const em = { fork: () => forked }
|
|
172
|
+
const redo = makeCreateRedo<{ id: string }, { id: string }>({
|
|
173
|
+
entityClass: class {} as never,
|
|
174
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
175
|
+
transaction: true,
|
|
176
|
+
})
|
|
177
|
+
await redo({ input: {}, ctx: buildContext(em), logEntry: { snapshotAfter: { id: 'row-1' } } as never })
|
|
178
|
+
expect(order[0]).toBe('begin')
|
|
179
|
+
expect(order).toContain('create')
|
|
180
|
+
expect(order[order.length - 1]).toBe('commit')
|
|
181
|
+
expect(order).not.toContain('rollback')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('runs afterRestore inside the transaction before commit when transaction is true', async () => {
|
|
185
|
+
const order: string[] = []
|
|
186
|
+
const forked = {
|
|
187
|
+
isInTransaction: () => false,
|
|
188
|
+
begin: async () => { order.push('begin') },
|
|
189
|
+
commit: async () => { order.push('commit') },
|
|
190
|
+
rollback: async () => { order.push('rollback') },
|
|
191
|
+
getUnitOfWork: () => ({ getChangeSets: () => [] }),
|
|
192
|
+
findOne: async () => null,
|
|
193
|
+
create: (_cls: unknown, data: Record<string, unknown>) => { order.push('create'); return { ...data } },
|
|
194
|
+
persist: () => undefined,
|
|
195
|
+
flush: async () => { order.push('flush') },
|
|
196
|
+
}
|
|
197
|
+
const em = { fork: () => forked }
|
|
198
|
+
const redo = makeCreateRedo<{ id: string }, { id: string }>({
|
|
199
|
+
entityClass: class {} as never,
|
|
200
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
201
|
+
transaction: true,
|
|
202
|
+
afterRestore: async () => { order.push('afterRestore') },
|
|
203
|
+
})
|
|
204
|
+
await redo({ input: {}, ctx: buildContext(em), logEntry: { snapshotAfter: { id: 'row-1' } } as never })
|
|
205
|
+
expect(order).toEqual(['begin', 'create', 'flush', 'afterRestore', 'flush', 'commit'])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('maps a Postgres unique-constraint violation thrown during flush to a 409 conflict', async () => {
|
|
209
|
+
const uniqueError = Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' })
|
|
210
|
+
const forked = {
|
|
211
|
+
findOne: async () => null,
|
|
212
|
+
create: (_cls: unknown, data: Record<string, unknown>) => ({ ...data }),
|
|
213
|
+
persist: () => undefined,
|
|
214
|
+
flush: async () => { throw uniqueError },
|
|
215
|
+
}
|
|
216
|
+
const em = { fork: () => forked }
|
|
217
|
+
const redo = makeCreateRedo<{ id: string }, { id: string }>({
|
|
218
|
+
entityClass: class {} as never,
|
|
219
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
220
|
+
})
|
|
221
|
+
const logEntry = { snapshotAfter: { id: 'row-1' } }
|
|
222
|
+
await expect(redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })).rejects.toMatchObject({
|
|
223
|
+
status: 409,
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('propagates a non-unique flush error unchanged', async () => {
|
|
228
|
+
const otherError = Object.assign(new Error('some other failure'), { code: '23503' })
|
|
229
|
+
const forked = {
|
|
230
|
+
findOne: async () => null,
|
|
231
|
+
create: (_cls: unknown, data: Record<string, unknown>) => ({ ...data }),
|
|
232
|
+
persist: () => undefined,
|
|
233
|
+
flush: async () => { throw otherError },
|
|
234
|
+
}
|
|
235
|
+
const em = { fork: () => forked }
|
|
236
|
+
const redo = makeCreateRedo<{ id: string }, { id: string }>({
|
|
237
|
+
entityClass: class {} as never,
|
|
238
|
+
buildResult: (entity) => ({ id: entity.id }),
|
|
239
|
+
})
|
|
240
|
+
const logEntry = { snapshotAfter: { id: 'row-1' } }
|
|
241
|
+
await expect(redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })).rejects.toBe(otherError)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('defaults getSnapshotId to snapshot.id and restores a surviving row in place', async () => {
|
|
245
|
+
const surviving: Record<string, unknown> = { id: 'row-1', deletedAt: new Date(), isActive: false }
|
|
246
|
+
const forked = {
|
|
247
|
+
findOne: async () => surviving,
|
|
248
|
+
create: () => {
|
|
249
|
+
throw new Error('should not create when row survives')
|
|
250
|
+
},
|
|
251
|
+
persist: () => undefined,
|
|
252
|
+
flush: async () => undefined,
|
|
253
|
+
}
|
|
254
|
+
const em = { fork: () => forked }
|
|
255
|
+
const redo = makeCreateRedo<{ id: string; deletedAt?: Date | null; isActive?: boolean }, { id: string; isActive: boolean }>({
|
|
256
|
+
entityClass: class {} as never,
|
|
257
|
+
buildResult: (entity) => ({ id: entity.id, isActive: entity.isActive }),
|
|
258
|
+
})
|
|
259
|
+
const logEntry = { snapshotAfter: { id: 'row-1', isActive: true } }
|
|
260
|
+
const result = await redo({ input: {}, ctx: buildContext(em), logEntry: logEntry as never })
|
|
261
|
+
expect(result).toEqual({ id: 'row-1', isActive: true })
|
|
262
|
+
expect(surviving.deletedAt).toBeNull()
|
|
263
|
+
expect(surviving.isActive).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { runCrudCommandWrite } from '../runCrudCommandWrite'
|
|
2
|
+
|
|
3
|
+
type FakeEntityManager = {
|
|
4
|
+
flush: jest.Mock<Promise<void>, []>
|
|
5
|
+
begin: jest.Mock<Promise<void>, []>
|
|
6
|
+
commit: jest.Mock<Promise<void>, []>
|
|
7
|
+
rollback: jest.Mock<Promise<void>, []>
|
|
8
|
+
fork: jest.Mock<FakeEntityManager, []>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type FakeDataEngine = {
|
|
12
|
+
setCustomFields: jest.Mock<Promise<void>, [unknown]>
|
|
13
|
+
markOrmEntityChange: jest.Mock<void, [unknown]>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createFakeEm(): FakeEntityManager {
|
|
17
|
+
const em: FakeEntityManager = {
|
|
18
|
+
flush: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
begin: jest.fn().mockResolvedValue(undefined),
|
|
20
|
+
commit: jest.fn().mockResolvedValue(undefined),
|
|
21
|
+
rollback: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
fork: jest.fn(),
|
|
23
|
+
}
|
|
24
|
+
em.fork.mockReturnValue(em)
|
|
25
|
+
return em
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createFakeDataEngine(): FakeDataEngine {
|
|
29
|
+
return {
|
|
30
|
+
setCustomFields: jest.fn().mockResolvedValue(undefined),
|
|
31
|
+
markOrmEntityChange: jest.fn(),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createCtx(em: FakeEntityManager, de: FakeDataEngine) {
|
|
36
|
+
return {
|
|
37
|
+
container: {
|
|
38
|
+
resolve: jest.fn((token: string) => {
|
|
39
|
+
if (token === 'em') return em
|
|
40
|
+
if (token === 'dataEngine') return de
|
|
41
|
+
throw new Error(`unexpected resolve(${token})`)
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
auth: null,
|
|
45
|
+
organizationScope: null,
|
|
46
|
+
selectedOrganizationId: null,
|
|
47
|
+
organizationIds: null,
|
|
48
|
+
} as any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const baseScope = { tenantId: 't1', organizationId: 'o1' } as const
|
|
52
|
+
const entity = { id: 'rec-1', tenantId: 't1', organizationId: 'o1' }
|
|
53
|
+
const identifiers = { id: 'rec-1', tenantId: 't1', organizationId: 'o1' }
|
|
54
|
+
const events = { module: 'customers', entity: 'deal', persistent: true } as const
|
|
55
|
+
const indexer = { entityType: 'customers:customer_deal' } as const
|
|
56
|
+
|
|
57
|
+
describe('runCrudCommandWrite', () => {
|
|
58
|
+
it('runs phases on the helper-forked EM, then writes custom fields, then queues exactly one CRUD side-effect (AC1 + AC4)', async () => {
|
|
59
|
+
const rootEm = createFakeEm()
|
|
60
|
+
const forked = createFakeEm()
|
|
61
|
+
rootEm.fork.mockReturnValue(forked)
|
|
62
|
+
const de = createFakeDataEngine()
|
|
63
|
+
const ctx = createCtx(rootEm, de)
|
|
64
|
+
const events_called: string[] = []
|
|
65
|
+
let observedPhaseEm: FakeEntityManager | null = null
|
|
66
|
+
|
|
67
|
+
await runCrudCommandWrite({
|
|
68
|
+
ctx,
|
|
69
|
+
entityId: 'customers:customer_deal',
|
|
70
|
+
action: 'updated',
|
|
71
|
+
scope: baseScope,
|
|
72
|
+
customFields: { priority: 3 },
|
|
73
|
+
events,
|
|
74
|
+
indexer,
|
|
75
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
76
|
+
phases: [
|
|
77
|
+
async ({ em }) => {
|
|
78
|
+
events_called.push('phase')
|
|
79
|
+
observedPhaseEm = em as unknown as FakeEntityManager
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(rootEm.fork).toHaveBeenCalledTimes(1)
|
|
85
|
+
expect(observedPhaseEm).toBe(forked)
|
|
86
|
+
expect(forked.begin).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(forked.flush).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(forked.commit).toHaveBeenCalledTimes(1)
|
|
89
|
+
expect(forked.rollback).not.toHaveBeenCalled()
|
|
90
|
+
|
|
91
|
+
expect(de.setCustomFields).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(de.setCustomFields).toHaveBeenCalledWith(
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
entityId: 'customers:customer_deal',
|
|
95
|
+
recordId: 'rec-1',
|
|
96
|
+
tenantId: 't1',
|
|
97
|
+
organizationId: 'o1',
|
|
98
|
+
values: { priority: 3 },
|
|
99
|
+
notify: false,
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledWith({
|
|
105
|
+
action: 'updated',
|
|
106
|
+
entity,
|
|
107
|
+
identifiers,
|
|
108
|
+
syncOrigin: null,
|
|
109
|
+
events,
|
|
110
|
+
indexer,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Order: setCustomFields must complete before markOrmEntityChange is queued
|
|
114
|
+
const customFieldsOrder = de.setCustomFields.mock.invocationCallOrder[0]
|
|
115
|
+
const markOrder = de.markOrmEntityChange.mock.invocationCallOrder[0]
|
|
116
|
+
expect(customFieldsOrder).toBeLessThan(markOrder)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('skips setCustomFields when customFields is undefined but still queues the side-effect', async () => {
|
|
120
|
+
const em = createFakeEm()
|
|
121
|
+
const de = createFakeDataEngine()
|
|
122
|
+
const ctx = createCtx(em, de)
|
|
123
|
+
|
|
124
|
+
await runCrudCommandWrite({
|
|
125
|
+
ctx,
|
|
126
|
+
entityId: 'customers:customer_deal',
|
|
127
|
+
action: 'created',
|
|
128
|
+
scope: baseScope,
|
|
129
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
130
|
+
phases: [() => {}],
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(de.setCustomFields).not.toHaveBeenCalled()
|
|
134
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledTimes(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('skips setCustomFields when customFields is an empty object', async () => {
|
|
138
|
+
const em = createFakeEm()
|
|
139
|
+
const de = createFakeDataEngine()
|
|
140
|
+
const ctx = createCtx(em, de)
|
|
141
|
+
|
|
142
|
+
await runCrudCommandWrite({
|
|
143
|
+
ctx,
|
|
144
|
+
entityId: 'customers:customer_deal',
|
|
145
|
+
action: 'updated',
|
|
146
|
+
scope: baseScope,
|
|
147
|
+
customFields: {},
|
|
148
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
149
|
+
phases: [() => {}],
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(de.setCustomFields).not.toHaveBeenCalled()
|
|
153
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledTimes(1)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does NOT emit side-effects when a phase throws (AC2)', async () => {
|
|
157
|
+
const em = createFakeEm()
|
|
158
|
+
const de = createFakeDataEngine()
|
|
159
|
+
const ctx = createCtx(em, de)
|
|
160
|
+
const failure = new Error('phase-failure')
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
runCrudCommandWrite({
|
|
164
|
+
ctx,
|
|
165
|
+
entityId: 'customers:customer_deal',
|
|
166
|
+
action: 'updated',
|
|
167
|
+
scope: baseScope,
|
|
168
|
+
customFields: { priority: 3 },
|
|
169
|
+
events,
|
|
170
|
+
indexer,
|
|
171
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
172
|
+
phases: [
|
|
173
|
+
() => {
|
|
174
|
+
throw failure
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toBe(failure)
|
|
179
|
+
|
|
180
|
+
expect(em.begin).toHaveBeenCalledTimes(1)
|
|
181
|
+
expect(em.rollback).toHaveBeenCalledTimes(1)
|
|
182
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
183
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
184
|
+
expect(de.setCustomFields).not.toHaveBeenCalled()
|
|
185
|
+
expect(de.markOrmEntityChange).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('does NOT emit side-effects when setCustomFields throws (AC3)', async () => {
|
|
189
|
+
const em = createFakeEm()
|
|
190
|
+
const de = createFakeDataEngine()
|
|
191
|
+
const ctx = createCtx(em, de)
|
|
192
|
+
const failure = new Error('custom-field-failure')
|
|
193
|
+
de.setCustomFields.mockRejectedValueOnce(failure)
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
runCrudCommandWrite({
|
|
197
|
+
ctx,
|
|
198
|
+
entityId: 'customers:customer_deal',
|
|
199
|
+
action: 'updated',
|
|
200
|
+
scope: baseScope,
|
|
201
|
+
customFields: { priority: 3 },
|
|
202
|
+
events,
|
|
203
|
+
indexer,
|
|
204
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
205
|
+
phases: [() => {}],
|
|
206
|
+
}),
|
|
207
|
+
).rejects.toBe(failure)
|
|
208
|
+
|
|
209
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
210
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
211
|
+
expect(de.setCustomFields).toHaveBeenCalledTimes(1)
|
|
212
|
+
expect(de.markOrmEntityChange).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('uses a caller-supplied EM instead of forking a new one when opts.em is provided', async () => {
|
|
216
|
+
const rootEm = createFakeEm()
|
|
217
|
+
const callerEm = createFakeEm()
|
|
218
|
+
const de = createFakeDataEngine()
|
|
219
|
+
const ctx = createCtx(rootEm, de)
|
|
220
|
+
let observedPhaseEm: FakeEntityManager | null = null
|
|
221
|
+
|
|
222
|
+
await runCrudCommandWrite({
|
|
223
|
+
ctx,
|
|
224
|
+
entityId: 'customers:customer_deal',
|
|
225
|
+
action: 'updated',
|
|
226
|
+
scope: baseScope,
|
|
227
|
+
em: callerEm as any,
|
|
228
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
229
|
+
phases: [
|
|
230
|
+
({ em }) => {
|
|
231
|
+
observedPhaseEm = em as unknown as FakeEntityManager
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
expect(rootEm.fork).not.toHaveBeenCalled()
|
|
237
|
+
expect(observedPhaseEm).toBe(callerEm)
|
|
238
|
+
expect(callerEm.begin).toHaveBeenCalledTimes(1)
|
|
239
|
+
expect(callerEm.flush).toHaveBeenCalledTimes(1)
|
|
240
|
+
expect(callerEm.commit).toHaveBeenCalledTimes(1)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('uses a caller-supplied DataEngine instead of resolving from container', async () => {
|
|
244
|
+
const em = createFakeEm()
|
|
245
|
+
const containerDe = createFakeDataEngine()
|
|
246
|
+
const callerDe = createFakeDataEngine()
|
|
247
|
+
const ctx = createCtx(em, containerDe)
|
|
248
|
+
|
|
249
|
+
await runCrudCommandWrite({
|
|
250
|
+
ctx,
|
|
251
|
+
entityId: 'customers:customer_deal',
|
|
252
|
+
action: 'updated',
|
|
253
|
+
scope: baseScope,
|
|
254
|
+
customFields: { priority: 3 },
|
|
255
|
+
dataEngine: callerDe as any,
|
|
256
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
257
|
+
phases: [() => {}],
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
expect(callerDe.setCustomFields).toHaveBeenCalledTimes(1)
|
|
261
|
+
expect(callerDe.markOrmEntityChange).toHaveBeenCalledTimes(1)
|
|
262
|
+
expect(containerDe.setCustomFields).not.toHaveBeenCalled()
|
|
263
|
+
expect(containerDe.markOrmEntityChange).not.toHaveBeenCalled()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('evaluates sideEffect lazily after phases commit (supports closure-captured entities created inside a phase)', async () => {
|
|
267
|
+
const em = createFakeEm()
|
|
268
|
+
const de = createFakeDataEngine()
|
|
269
|
+
const ctx = createCtx(em, de)
|
|
270
|
+
let createdEntity: { id: string; tenantId: string; organizationId: string } | null = null
|
|
271
|
+
const sideEffectSpy = jest.fn(() => ({
|
|
272
|
+
entity: createdEntity!,
|
|
273
|
+
identifiers: { id: createdEntity!.id, tenantId: 't1', organizationId: 'o1' },
|
|
274
|
+
}))
|
|
275
|
+
|
|
276
|
+
await runCrudCommandWrite({
|
|
277
|
+
ctx,
|
|
278
|
+
entityId: 'customers:customer_deal',
|
|
279
|
+
action: 'created',
|
|
280
|
+
scope: baseScope,
|
|
281
|
+
sideEffect: sideEffectSpy,
|
|
282
|
+
phases: [
|
|
283
|
+
() => {
|
|
284
|
+
createdEntity = { id: 'fresh-id', tenantId: 't1', organizationId: 'o1' }
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
expect(sideEffectSpy).toHaveBeenCalledTimes(1)
|
|
290
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledWith(
|
|
291
|
+
expect.objectContaining({
|
|
292
|
+
entity: { id: 'fresh-id', tenantId: 't1', organizationId: 'o1' },
|
|
293
|
+
identifiers: { id: 'fresh-id', tenantId: 't1', organizationId: 'o1' },
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('runs phases sequentially in a single transaction, flushing per phase (SPEC-018)', async () => {
|
|
299
|
+
const em = createFakeEm()
|
|
300
|
+
const de = createFakeDataEngine()
|
|
301
|
+
const ctx = createCtx(em, de)
|
|
302
|
+
const order: string[] = []
|
|
303
|
+
|
|
304
|
+
await runCrudCommandWrite({
|
|
305
|
+
ctx,
|
|
306
|
+
entityId: 'customers:customer_deal',
|
|
307
|
+
action: 'updated',
|
|
308
|
+
scope: baseScope,
|
|
309
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
310
|
+
phases: [
|
|
311
|
+
async () => {
|
|
312
|
+
await Promise.resolve()
|
|
313
|
+
order.push('phase-1')
|
|
314
|
+
},
|
|
315
|
+
() => {
|
|
316
|
+
order.push('phase-2')
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(order).toEqual(['phase-1', 'phase-2'])
|
|
322
|
+
expect(em.begin).toHaveBeenCalledTimes(1)
|
|
323
|
+
// SPEC-018: withAtomicFlush flushes after each phase (one per phase) so a
|
|
324
|
+
// later phase's reads see the prior phase's mutations without UoW reset.
|
|
325
|
+
expect(em.flush).toHaveBeenCalledTimes(2)
|
|
326
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('honours transaction:false (no begin/commit, single flush still happens)', async () => {
|
|
330
|
+
const em = createFakeEm()
|
|
331
|
+
const de = createFakeDataEngine()
|
|
332
|
+
const ctx = createCtx(em, de)
|
|
333
|
+
|
|
334
|
+
await runCrudCommandWrite({
|
|
335
|
+
ctx,
|
|
336
|
+
entityId: 'customers:customer_deal',
|
|
337
|
+
action: 'updated',
|
|
338
|
+
scope: baseScope,
|
|
339
|
+
transaction: false,
|
|
340
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
341
|
+
phases: [() => {}],
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
expect(em.begin).not.toHaveBeenCalled()
|
|
345
|
+
expect(em.commit).not.toHaveBeenCalled()
|
|
346
|
+
expect(em.flush).toHaveBeenCalledTimes(1)
|
|
347
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledTimes(1)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('forwards syncOrigin to the side-effect emit', async () => {
|
|
351
|
+
const em = createFakeEm()
|
|
352
|
+
const de = createFakeDataEngine()
|
|
353
|
+
const ctx = createCtx(em, de)
|
|
354
|
+
|
|
355
|
+
await runCrudCommandWrite({
|
|
356
|
+
ctx,
|
|
357
|
+
entityId: 'customers:customer_deal',
|
|
358
|
+
action: 'updated',
|
|
359
|
+
scope: baseScope,
|
|
360
|
+
syncOrigin: 'sync_excel',
|
|
361
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
362
|
+
phases: [() => {}],
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(de.markOrmEntityChange).toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({ syncOrigin: 'sync_excel' }),
|
|
367
|
+
)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('passes notifyCustomFields:true through to setCustomFields when requested', async () => {
|
|
371
|
+
const em = createFakeEm()
|
|
372
|
+
const de = createFakeDataEngine()
|
|
373
|
+
const ctx = createCtx(em, de)
|
|
374
|
+
|
|
375
|
+
await runCrudCommandWrite({
|
|
376
|
+
ctx,
|
|
377
|
+
entityId: 'customers:customer_deal',
|
|
378
|
+
action: 'updated',
|
|
379
|
+
scope: baseScope,
|
|
380
|
+
customFields: { priority: 3 },
|
|
381
|
+
notifyCustomFields: true,
|
|
382
|
+
sideEffect: () => ({ entity, identifiers }),
|
|
383
|
+
phases: [() => {}],
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
expect(de.setCustomFields).toHaveBeenCalledWith(
|
|
387
|
+
expect.objectContaining({ notify: true }),
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
})
|