@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -0,0 +1,650 @@
1
+ import { asValue, createContainer, InjectionMode } from 'awilix'
2
+ import { CommandBus, registerCommand, unregisterCommand } from '@open-mercato/shared/lib/commands'
3
+ import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
4
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
5
+ import {
6
+ getTransactionLifetime,
7
+ onTransactionLifetimeComplete,
8
+ } from '@open-mercato/shared/lib/commands/transaction-lifetime'
9
+ import type { CommandExecuteResult } from '@open-mercato/shared/lib/commands/types'
10
+
11
+ type ReplayState = {
12
+ domain: string
13
+ source: 'done' | 'undoing' | 'undone' | 'redone'
14
+ logs: number
15
+ }
16
+
17
+ function buildTransactionalEm(state: ReplayState) {
18
+ let inTransaction = false
19
+ let saved: ReplayState | null = null
20
+ return {
21
+ isInTransaction: () => inTransaction,
22
+ begin: jest.fn(async () => {
23
+ saved = { ...state }
24
+ inTransaction = true
25
+ }),
26
+ commit: jest.fn(async () => {
27
+ inTransaction = false
28
+ saved = null
29
+ }),
30
+ rollback: jest.fn(async () => {
31
+ if (saved) Object.assign(state, saved)
32
+ inTransaction = false
33
+ saved = null
34
+ }),
35
+ flush: jest.fn(async () => undefined),
36
+ getUnitOfWork: () => ({
37
+ computeChangeSets: () => undefined,
38
+ getChangeSets: () => [],
39
+ }),
40
+ }
41
+ }
42
+
43
+ describe('CommandBus atomic replay', () => {
44
+ afterEach(() => {
45
+ unregisterCommand('auth.test.atomic-undo')
46
+ unregisterCommand('auth.test.atomic-redo')
47
+ unregisterCommand('auth.test.feature-race')
48
+ unregisterCommand('auth.test.ambient-undo')
49
+ unregisterCommand('auth.test.ambient-redo')
50
+ registerCommandInterceptors([])
51
+ })
52
+
53
+ it('fails closed before an atomic replay joins an ambient transaction without its owner lifetime', async () => {
54
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
55
+ const em = buildTransactionalEm(state)
56
+ await em.begin()
57
+ const service = {
58
+ findByUndoToken: jest.fn(async () => ({
59
+ id: 'source-log',
60
+ commandId: 'auth.test.ambient-undo',
61
+ commandPayload: {},
62
+ })),
63
+ claimForUndo: jest.fn(async () => true),
64
+ markUndone: jest.fn(async () => undefined),
65
+ }
66
+ const undo = jest.fn(async () => {
67
+ state.domain = 'before'
68
+ })
69
+ registerCommand({
70
+ id: 'auth.test.ambient-undo',
71
+ atomicReplay: true,
72
+ execute: jest.fn(),
73
+ undo,
74
+ })
75
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
76
+ container.register({
77
+ em: asValue(em),
78
+ actionLogService: asValue(service),
79
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
80
+ })
81
+
82
+ await expect(new CommandBus().undo('undo-token', {
83
+ container,
84
+ auth: null,
85
+ transactionalEm: em as never,
86
+ } as never)).rejects.toThrow('Ambient atomic replay requires its outer transaction lifetime owner')
87
+
88
+ expect(service.claimForUndo).not.toHaveBeenCalled()
89
+ expect(service.markUndone).not.toHaveBeenCalled()
90
+ expect(undo).not.toHaveBeenCalled()
91
+ await em.rollback()
92
+ })
93
+
94
+ it('defers ambient undo completion effects until the true outer commit', async () => {
95
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
96
+ const em = buildTransactionalEm(state)
97
+ const afterUndo = jest.fn(async () => undefined)
98
+ const flushOrmEntityChanges = jest.fn(async () => undefined)
99
+ let replayLeaseHeld = false
100
+ registerCommandInterceptors([{
101
+ moduleId: 'auth',
102
+ interceptors: [{
103
+ id: 'auth.test.ambient-undo-interceptor',
104
+ targetCommand: 'auth.test.ambient-undo',
105
+ afterUndo,
106
+ }],
107
+ }])
108
+ const service = {
109
+ findByUndoToken: jest.fn(async () => ({
110
+ id: 'source-log',
111
+ commandId: 'auth.test.ambient-undo',
112
+ commandPayload: {},
113
+ })),
114
+ claimForUndo: jest.fn(async () => {
115
+ state.source = 'undoing'
116
+ return true
117
+ }),
118
+ markUndone: jest.fn(async () => {
119
+ state.source = 'undone'
120
+ state.logs += 1
121
+ }),
122
+ }
123
+ registerCommand({
124
+ id: 'auth.test.ambient-undo',
125
+ atomicReplay: true,
126
+ execute: jest.fn(),
127
+ stabilizeReplay: jest.fn(async ({ ctx }) => {
128
+ expect(ctx.transactionLifetime).toBe(getTransactionLifetime(em as never))
129
+ replayLeaseHeld = true
130
+ onTransactionLifetimeComplete(em as never, () => {
131
+ replayLeaseHeld = false
132
+ })
133
+ }),
134
+ undo: jest.fn(async () => {
135
+ state.domain = 'before'
136
+ }),
137
+ })
138
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
139
+ container.register({
140
+ em: asValue(em),
141
+ actionLogService: asValue(service),
142
+ dataEngine: asValue({ flushOrmEntityChanges }),
143
+ })
144
+
145
+ await withAtomicFlush(em as never, [async () => {
146
+ const transactionLifetime = getTransactionLifetime(em as never)
147
+ expect(transactionLifetime).not.toBeNull()
148
+ await new CommandBus().undo('undo-token', {
149
+ container,
150
+ auth: null,
151
+ transactionalEm: em as never,
152
+ transactionLifetime: transactionLifetime!,
153
+ } as never)
154
+
155
+ expect(state).toEqual({ domain: 'before', source: 'undone', logs: 2 })
156
+ expect(replayLeaseHeld).toBe(true)
157
+ expect(afterUndo).not.toHaveBeenCalled()
158
+ expect(flushOrmEntityChanges).not.toHaveBeenCalled()
159
+ }], { transaction: true })
160
+
161
+ expect(replayLeaseHeld).toBe(false)
162
+ expect(afterUndo).toHaveBeenCalledTimes(1)
163
+ expect(flushOrmEntityChanges).toHaveBeenCalledTimes(1)
164
+ expect(em.commit.mock.invocationCallOrder[0]).toBeLessThan(afterUndo.mock.invocationCallOrder[0])
165
+ expect(state).toEqual({ domain: 'before', source: 'undone', logs: 2 })
166
+ })
167
+
168
+ it('releases the ambient undo lease and suppresses completion effects on outer rollback', async () => {
169
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
170
+ const em = buildTransactionalEm(state)
171
+ const afterUndo = jest.fn(async () => undefined)
172
+ const flushOrmEntityChanges = jest.fn(async () => undefined)
173
+ const rollbackError = new Error('outer undo rollback')
174
+ let replayLeaseHeld = false
175
+ registerCommandInterceptors([{
176
+ moduleId: 'auth',
177
+ interceptors: [{
178
+ id: 'auth.test.ambient-undo-interceptor',
179
+ targetCommand: 'auth.test.ambient-undo',
180
+ afterUndo,
181
+ }],
182
+ }])
183
+ const service = {
184
+ findByUndoToken: jest.fn(async () => ({
185
+ id: 'source-log',
186
+ commandId: 'auth.test.ambient-undo',
187
+ commandPayload: {},
188
+ })),
189
+ claimForUndo: jest.fn(async () => {
190
+ state.source = 'undoing'
191
+ return true
192
+ }),
193
+ markUndone: jest.fn(async () => {
194
+ state.source = 'undone'
195
+ state.logs += 1
196
+ }),
197
+ }
198
+ registerCommand({
199
+ id: 'auth.test.ambient-undo',
200
+ atomicReplay: true,
201
+ execute: jest.fn(),
202
+ stabilizeReplay: jest.fn(async () => {
203
+ replayLeaseHeld = true
204
+ onTransactionLifetimeComplete(em as never, () => {
205
+ replayLeaseHeld = false
206
+ })
207
+ }),
208
+ undo: jest.fn(async () => {
209
+ state.domain = 'before'
210
+ }),
211
+ })
212
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
213
+ container.register({
214
+ em: asValue(em),
215
+ actionLogService: asValue(service),
216
+ dataEngine: asValue({ flushOrmEntityChanges }),
217
+ })
218
+
219
+ await expect(withAtomicFlush(em as never, [async () => {
220
+ const transactionLifetime = getTransactionLifetime(em as never)
221
+ await new CommandBus().undo('undo-token', {
222
+ container,
223
+ auth: null,
224
+ transactionalEm: em as never,
225
+ transactionLifetime: transactionLifetime!,
226
+ } as never)
227
+ expect(replayLeaseHeld).toBe(true)
228
+ throw rollbackError
229
+ }], { transaction: true })).rejects.toBe(rollbackError)
230
+
231
+ expect(replayLeaseHeld).toBe(false)
232
+ expect(afterUndo).not.toHaveBeenCalled()
233
+ expect(flushOrmEntityChanges).not.toHaveBeenCalled()
234
+ expect(state).toEqual({ domain: 'after', source: 'done', logs: 1 })
235
+ })
236
+
237
+ it('reports ambient redo finalization and runs effects only after the outer commit', async () => {
238
+ const state: ReplayState = { domain: 'before', source: 'undone', logs: 1 }
239
+ const em = buildTransactionalEm(state)
240
+ const afterExecute = jest.fn(async () => ({ modifiedResult: { committed: true } }))
241
+ const flushOrmEntityChanges = jest.fn(async () => undefined)
242
+ let replayLeaseHeld = false
243
+ let executionResult: CommandExecuteResult<{ ok: boolean; committed?: boolean }> | null = null
244
+ registerCommandInterceptors([{
245
+ moduleId: 'auth',
246
+ interceptors: [{
247
+ id: 'auth.test.ambient-redo-interceptor',
248
+ targetCommand: 'auth.test.ambient-redo',
249
+ afterExecute,
250
+ }],
251
+ }])
252
+ const service = {
253
+ claimForRedo: jest.fn(async () => {
254
+ state.source = 'redone'
255
+ return true
256
+ }),
257
+ log: jest.fn(async () => {
258
+ state.logs += 1
259
+ return { id: 'redo-log' }
260
+ }),
261
+ }
262
+ registerCommand({
263
+ id: 'auth.test.ambient-redo',
264
+ atomicReplay: true,
265
+ execute: jest.fn(),
266
+ stabilizeReplay: jest.fn(async () => {
267
+ replayLeaseHeld = true
268
+ onTransactionLifetimeComplete(em as never, () => {
269
+ replayLeaseHeld = false
270
+ })
271
+ }),
272
+ redo: jest.fn(async () => {
273
+ state.domain = 'after'
274
+ return { ok: true }
275
+ }),
276
+ undo: jest.fn(),
277
+ buildLog: () => ({}),
278
+ })
279
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
280
+ container.register({
281
+ em: asValue(em),
282
+ actionLogService: asValue(service),
283
+ dataEngine: asValue({ flushOrmEntityChanges }),
284
+ })
285
+
286
+ await withAtomicFlush(em as never, [async () => {
287
+ const transactionLifetime = getTransactionLifetime(em as never)
288
+ executionResult = await new CommandBus().execute('auth.test.ambient-redo', {
289
+ input: {},
290
+ ctx: {
291
+ container,
292
+ auth: null,
293
+ transactionalEm: em as never,
294
+ transactionLifetime: transactionLifetime!,
295
+ } as never,
296
+ redoLogEntry: { id: 'source-log', commandId: 'auth.test.ambient-redo' },
297
+ })
298
+
299
+ expect(executionResult.replaySourceFinalized).toBe(false)
300
+ expect(executionResult.result).toEqual({ ok: true })
301
+ expect(replayLeaseHeld).toBe(true)
302
+ expect(afterExecute).not.toHaveBeenCalled()
303
+ expect(flushOrmEntityChanges).not.toHaveBeenCalled()
304
+ }], { transaction: true })
305
+
306
+ expect(executionResult!.replaySourceFinalized).toBe(true)
307
+ expect(executionResult!.result).toEqual({ ok: true, committed: true })
308
+ expect(replayLeaseHeld).toBe(false)
309
+ expect(afterExecute).toHaveBeenCalledTimes(1)
310
+ expect(flushOrmEntityChanges).toHaveBeenCalledTimes(1)
311
+ expect(em.commit.mock.invocationCallOrder[0]).toBeLessThan(afterExecute.mock.invocationCallOrder[0])
312
+ expect(state).toEqual({ domain: 'after', source: 'redone', logs: 2 })
313
+ })
314
+
315
+ it('keeps ambient redo unfinalized and suppresses effects after outer rollback', async () => {
316
+ const state: ReplayState = { domain: 'before', source: 'undone', logs: 1 }
317
+ const em = buildTransactionalEm(state)
318
+ const afterExecute = jest.fn(async () => ({ modifiedResult: { committed: true } }))
319
+ const flushOrmEntityChanges = jest.fn(async () => undefined)
320
+ const rollbackError = new Error('outer redo rollback')
321
+ let replayLeaseHeld = false
322
+ let executionResult: CommandExecuteResult<{ ok: boolean; committed?: boolean }> | null = null
323
+ registerCommandInterceptors([{
324
+ moduleId: 'auth',
325
+ interceptors: [{
326
+ id: 'auth.test.ambient-redo-interceptor',
327
+ targetCommand: 'auth.test.ambient-redo',
328
+ afterExecute,
329
+ }],
330
+ }])
331
+ const service = {
332
+ claimForRedo: jest.fn(async () => {
333
+ state.source = 'redone'
334
+ return true
335
+ }),
336
+ log: jest.fn(async () => {
337
+ state.logs += 1
338
+ return { id: 'redo-log' }
339
+ }),
340
+ }
341
+ registerCommand({
342
+ id: 'auth.test.ambient-redo',
343
+ atomicReplay: true,
344
+ execute: jest.fn(),
345
+ stabilizeReplay: jest.fn(async () => {
346
+ replayLeaseHeld = true
347
+ onTransactionLifetimeComplete(em as never, () => {
348
+ replayLeaseHeld = false
349
+ })
350
+ }),
351
+ redo: jest.fn(async () => {
352
+ state.domain = 'after'
353
+ return { ok: true }
354
+ }),
355
+ undo: jest.fn(),
356
+ buildLog: () => ({}),
357
+ })
358
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
359
+ container.register({
360
+ em: asValue(em),
361
+ actionLogService: asValue(service),
362
+ dataEngine: asValue({ flushOrmEntityChanges }),
363
+ })
364
+
365
+ await expect(withAtomicFlush(em as never, [async () => {
366
+ const transactionLifetime = getTransactionLifetime(em as never)
367
+ executionResult = await new CommandBus().execute('auth.test.ambient-redo', {
368
+ input: {},
369
+ ctx: {
370
+ container,
371
+ auth: null,
372
+ transactionalEm: em as never,
373
+ transactionLifetime: transactionLifetime!,
374
+ } as never,
375
+ redoLogEntry: { id: 'source-log', commandId: 'auth.test.ambient-redo' },
376
+ })
377
+ expect(executionResult.replaySourceFinalized).toBe(false)
378
+ throw rollbackError
379
+ }], { transaction: true })).rejects.toBe(rollbackError)
380
+
381
+ expect(executionResult!.replaySourceFinalized).toBe(false)
382
+ expect(executionResult!.result).toEqual({ ok: true })
383
+ expect(replayLeaseHeld).toBe(false)
384
+ expect(afterExecute).not.toHaveBeenCalled()
385
+ expect(flushOrmEntityChanges).not.toHaveBeenCalled()
386
+ expect(state).toEqual({ domain: 'before', source: 'undone', logs: 1 })
387
+ })
388
+
389
+ it('rolls back the undo mutation, source state, and trace log together', async () => {
390
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
391
+ const em = buildTransactionalEm(state)
392
+ const authorizeReplay = jest.fn(async ({ ctx }) => {
393
+ expect(ctx.transactionalEm).toBe(em)
394
+ expect(em.isInTransaction()).toBe(true)
395
+ })
396
+ const replayTransactionGuard = jest.fn(async ({ transactionalEm }) => {
397
+ expect(transactionalEm).toBe(em)
398
+ expect(em.isInTransaction()).toBe(true)
399
+ })
400
+ const getGrantedFeaturesWithEntityManager = jest.fn(async (operationEm: unknown) => {
401
+ expect(operationEm).toBe(em)
402
+ expect(em.isInTransaction()).toBe(true)
403
+ return ['auth.test.replay']
404
+ })
405
+ const getGrantedFeatures = jest.fn(async () => {
406
+ throw new Error('forking feature path must not run')
407
+ })
408
+ const beforeUndo = jest.fn(async () => {
409
+ expect(em.isInTransaction()).toBe(true)
410
+ return { ok: true }
411
+ })
412
+ registerCommandInterceptors([{
413
+ moduleId: 'auth',
414
+ interceptors: [{
415
+ id: 'auth.test.atomic-interceptor',
416
+ targetCommand: 'auth.test.atomic-undo',
417
+ features: ['auth.test.replay'],
418
+ beforeUndo,
419
+ }],
420
+ }])
421
+ const service = {
422
+ findByUndoToken: jest.fn(async () => ({
423
+ id: 'source-log',
424
+ commandId: 'auth.test.atomic-undo',
425
+ commandPayload: {},
426
+ })),
427
+ claimForUndo: jest.fn(async (_id: string, operationEm: unknown) => {
428
+ expect(operationEm).toBe(em)
429
+ state.source = 'undoing'
430
+ return true
431
+ }),
432
+ markUndone: jest.fn(async (_id: string, _trace: unknown, operationEm: unknown) => {
433
+ expect(operationEm).toBe(em)
434
+ state.source = 'undone'
435
+ state.logs += 1
436
+ throw new Error('finalization failed')
437
+ }),
438
+ }
439
+ registerCommand({
440
+ id: 'auth.test.atomic-undo',
441
+ atomicReplay: true,
442
+ execute: jest.fn(),
443
+ authorizeReplay,
444
+ undo: jest.fn(async ({ ctx }) => {
445
+ expect(ctx.transactionalEm).toBe(em)
446
+ state.domain = 'before'
447
+ }),
448
+ })
449
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
450
+ container.register({
451
+ em: asValue(em),
452
+ actionLogService: asValue(service),
453
+ rbacService: asValue({ getGrantedFeatures, getGrantedFeaturesWithEntityManager }),
454
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
455
+ })
456
+
457
+ await expect(new CommandBus().undo('undo-token', {
458
+ container,
459
+ auth: { sub: 'actor', tenantId: 'tenant', orgId: null },
460
+ replayTransactionGuard,
461
+ })).rejects.toThrow('finalization failed')
462
+
463
+ expect(state).toEqual({ domain: 'after', source: 'done', logs: 1 })
464
+ expect(authorizeReplay).toHaveBeenCalledTimes(1)
465
+ expect(replayTransactionGuard).toHaveBeenCalledTimes(1)
466
+ expect(getGrantedFeaturesWithEntityManager).toHaveBeenCalledWith(
467
+ em,
468
+ 'actor',
469
+ { tenantId: 'tenant', organizationId: null },
470
+ )
471
+ expect(getGrantedFeatures).not.toHaveBeenCalled()
472
+ expect(beforeUndo).toHaveBeenCalledTimes(1)
473
+ expect(em.rollback).toHaveBeenCalledTimes(1)
474
+ })
475
+
476
+ it('rolls back redo domain, new log, and source finalization together', async () => {
477
+ const state: ReplayState = { domain: 'before', source: 'undone', logs: 1 }
478
+ const em = buildTransactionalEm(state)
479
+ const authorizeReplay = jest.fn(async ({ ctx }) => {
480
+ expect(ctx.transactionalEm).toBe(em)
481
+ expect(em.isInTransaction()).toBe(true)
482
+ })
483
+ const service = {
484
+ claimForRedo: jest.fn(async (_id: string, operationEm: unknown) => {
485
+ expect(operationEm).toBe(em)
486
+ state.source = 'redone'
487
+ return true
488
+ }),
489
+ log: jest.fn(async (_input: unknown, operationEm: unknown) => {
490
+ expect(operationEm).toBe(em)
491
+ state.logs += 1
492
+ throw new Error('new log failed')
493
+ }),
494
+ }
495
+ registerCommand({
496
+ id: 'auth.test.atomic-redo',
497
+ atomicReplay: true,
498
+ execute: jest.fn(),
499
+ authorizeReplay,
500
+ redo: jest.fn(async ({ ctx }) => {
501
+ expect(ctx.transactionalEm).toBe(em)
502
+ state.domain = 'after'
503
+ return { ok: true }
504
+ }),
505
+ undo: jest.fn(),
506
+ buildLog: () => ({ resourceKind: 'auth.test', resourceId: 'record' }),
507
+ })
508
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
509
+ container.register({
510
+ em: asValue(em),
511
+ actionLogService: asValue(service),
512
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
513
+ })
514
+
515
+ await expect(new CommandBus().execute('auth.test.atomic-redo', {
516
+ input: {},
517
+ ctx: {
518
+ container,
519
+ auth: { sub: 'actor', tenantId: 'tenant', orgId: null },
520
+ },
521
+ redoLogEntry: { id: 'source-log', commandId: 'auth.test.atomic-redo' },
522
+ })).rejects.toThrow('new log failed')
523
+
524
+ expect(state).toEqual({ domain: 'before', source: 'undone', logs: 1 })
525
+ expect(authorizeReplay).toHaveBeenCalledTimes(1)
526
+ expect(em.rollback).toHaveBeenCalledTimes(1)
527
+ })
528
+
529
+ it('stabilizes replay authorization before resolving a newly applicable blocking interceptor', async () => {
530
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
531
+ const em = buildTransactionalEm(state)
532
+ let stabilized = false
533
+ const beforeUndo = jest.fn(async () => ({ ok: false, message: 'blocked after grant' }))
534
+ registerCommandInterceptors([{
535
+ moduleId: 'auth',
536
+ interceptors: [{
537
+ id: 'auth.test.feature-race-interceptor',
538
+ targetCommand: 'auth.test.feature-race',
539
+ features: ['auth.test.newly-granted'],
540
+ beforeUndo,
541
+ }],
542
+ }])
543
+ const service = {
544
+ findByUndoToken: jest.fn(async () => ({
545
+ id: 'feature-race-log',
546
+ commandId: 'auth.test.feature-race',
547
+ commandPayload: {},
548
+ })),
549
+ claimForUndo: jest.fn(async () => true),
550
+ releaseUndoClaim: jest.fn(async () => true),
551
+ markUndone: jest.fn(async () => undefined),
552
+ }
553
+ const undo = jest.fn(async () => {
554
+ state.domain = 'before'
555
+ })
556
+ registerCommand({
557
+ id: 'auth.test.feature-race',
558
+ atomicReplay: true,
559
+ execute: jest.fn(),
560
+ stabilizeReplay: jest.fn(async ({ ctx }) => {
561
+ expect(ctx.transactionalEm).toBe(em)
562
+ expect(em.isInTransaction()).toBe(true)
563
+ stabilized = true
564
+ }),
565
+ undo,
566
+ })
567
+ const getGrantedFeaturesWithEntityManager = jest.fn(async () => {
568
+ expect(stabilized).toBe(true)
569
+ return ['auth.test.newly-granted']
570
+ })
571
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
572
+ container.register({
573
+ em: asValue(em),
574
+ actionLogService: asValue(service),
575
+ rbacService: asValue({ getGrantedFeaturesWithEntityManager }),
576
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
577
+ })
578
+
579
+ await expect(new CommandBus().undo('feature-race-token', {
580
+ container,
581
+ auth: { sub: 'actor', tenantId: 'tenant', orgId: null },
582
+ replayTransactionGuard: jest.fn(async () => {
583
+ expect(em.isInTransaction()).toBe(true)
584
+ expect(stabilized).toBe(true)
585
+ }),
586
+ })).rejects.toThrow('blocked after grant')
587
+
588
+ expect(beforeUndo).toHaveBeenCalledTimes(1)
589
+ expect(service.claimForUndo).not.toHaveBeenCalled()
590
+ expect(undo).not.toHaveBeenCalled()
591
+ expect(state).toEqual({ domain: 'after', source: 'done', logs: 1 })
592
+ expect(em.rollback).toHaveBeenCalledTimes(1)
593
+ })
594
+
595
+ it('rethrows a failed in-transaction feature lookup instead of running interceptors with no features', async () => {
596
+ const state: ReplayState = { domain: 'after', source: 'done', logs: 1 }
597
+ const em = buildTransactionalEm(state)
598
+ const beforeUndo = jest.fn(async () => ({ ok: true }))
599
+ registerCommandInterceptors([{
600
+ moduleId: 'auth',
601
+ interceptors: [{
602
+ id: 'auth.test.feature-failure-interceptor',
603
+ targetCommand: 'auth.test.feature-failure',
604
+ features: ['auth.test.replay'],
605
+ beforeUndo,
606
+ }],
607
+ }])
608
+ const service = {
609
+ findByUndoToken: jest.fn(async () => ({
610
+ id: 'feature-failure-log',
611
+ commandId: 'auth.test.feature-failure',
612
+ commandPayload: {},
613
+ })),
614
+ claimForUndo: jest.fn(async () => true),
615
+ releaseUndoClaim: jest.fn(async () => true),
616
+ markUndone: jest.fn(async () => undefined),
617
+ }
618
+ const undo = jest.fn(async () => {
619
+ state.domain = 'before'
620
+ })
621
+ registerCommand({
622
+ id: 'auth.test.feature-failure',
623
+ atomicReplay: true,
624
+ execute: jest.fn(),
625
+ undo,
626
+ })
627
+ const getGrantedFeaturesWithEntityManager = jest.fn(async () => {
628
+ throw new Error('feature lookup aborted the transaction')
629
+ })
630
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
631
+ container.register({
632
+ em: asValue(em),
633
+ actionLogService: asValue(service),
634
+ rbacService: asValue({ getGrantedFeaturesWithEntityManager }),
635
+ dataEngine: asValue({ flushOrmEntityChanges: jest.fn(async () => undefined) }),
636
+ })
637
+
638
+ await expect(new CommandBus().undo('feature-failure-token', {
639
+ container,
640
+ auth: { sub: 'actor', tenantId: 'tenant', orgId: null },
641
+ replayTransactionGuard: jest.fn(async () => undefined),
642
+ })).rejects.toThrow('feature lookup aborted the transaction')
643
+
644
+ expect(getGrantedFeaturesWithEntityManager).toHaveBeenCalledTimes(1)
645
+ expect(beforeUndo).not.toHaveBeenCalled()
646
+ expect(undo).not.toHaveBeenCalled()
647
+ expect(state).toEqual({ domain: 'after', source: 'done', logs: 1 })
648
+ expect(em.rollback).toHaveBeenCalledTimes(1)
649
+ })
650
+ })