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

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 +11 -3
  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
@@ -9,9 +9,9 @@ import type {
9
9
  CommandLogBuilderArgs,
10
10
  CommandLogMetadata,
11
11
  CommandRuntimeContext,
12
+ CommandUndoLogEntry,
12
13
  } from './types'
13
14
  import { defaultUndoToken } from './types'
14
- import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
15
15
  import type { AwilixContainer } from 'awilix'
16
16
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
17
17
  import {
@@ -33,9 +33,57 @@ import type { CommandInterceptorContext } from './command-interceptor'
33
33
  import { CommandInterceptorError } from './errors'
34
34
  import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
35
35
  import { createLogger } from '../logger'
36
+ import { withAtomicFlush } from './flush'
37
+ import type { EntityManager } from '@mikro-orm/postgresql'
38
+ import {
39
+ getTransactionLifetime,
40
+ onTransactionLifetimeComplete,
41
+ ownsTransactionLifetime,
42
+ type TransactionLifetime,
43
+ } from './transaction-lifetime'
36
44
 
37
45
  const logger = createLogger('shared').child({ component: 'commands' })
38
46
 
47
+ type CommandActionLogService = {
48
+ findByUndoToken(undoToken: string): Promise<ActionLog | null>
49
+ claimForUndo(id: string, transactionalEm?: EntityManager): Promise<boolean>
50
+ releaseUndoClaim(id: string, transactionalEm?: EntityManager): Promise<boolean>
51
+ markUndone(id: string, traceInput?: ActionLogCreateInput, transactionalEm?: EntityManager): Promise<ActionLog | null | void>
52
+ claimForRedo(id: string, transactionalEm?: EntityManager): Promise<boolean>
53
+ log(input: ActionLogCreateInput, transactionalEm?: EntityManager): Promise<ActionLog | null>
54
+ }
55
+
56
+ type AtomicReplayTransaction = {
57
+ em: EntityManager
58
+ joinsAmbient: boolean
59
+ }
60
+
61
+ function isInTransaction(em: EntityManager): boolean {
62
+ const probe = (em as { isInTransaction?: () => boolean }).isInTransaction
63
+ return typeof probe === 'function' && probe.call(em)
64
+ }
65
+
66
+ function resolveAtomicReplayTransaction(ctx: CommandRuntimeContext): AtomicReplayTransaction {
67
+ const em = ctx.transactionalEm ?? (ctx.container.resolve('em') as EntityManager)
68
+ const joinsAmbient = isInTransaction(em)
69
+ if (joinsAmbient && !ownsTransactionLifetime(em, ctx.transactionLifetime)) {
70
+ throw new Error('[internal] Ambient atomic replay requires its outer transaction lifetime owner')
71
+ }
72
+ return { em, joinsAmbient }
73
+ }
74
+
75
+ function bindReplayTransactionContext(
76
+ ctx: CommandRuntimeContext,
77
+ em: EntityManager,
78
+ expectedLifetime?: TransactionLifetime,
79
+ ): CommandRuntimeContext {
80
+ const transactionLifetime = getTransactionLifetime(em)
81
+ if (!transactionLifetime || (expectedLifetime && transactionLifetime !== expectedLifetime)) {
82
+ throw new Error('[internal] Atomic replay transaction lifetime owner changed before replay')
83
+ }
84
+ return { ...ctx, transactionalEm: em, transactionLifetime }
85
+ }
86
+
39
87
  const SKIPPED_ACTION_LOG_RESOURCE_KINDS = new Set<string>([
40
88
  'audit_logs.access',
41
89
  'audit_logs.action',
@@ -227,134 +275,249 @@ export class CommandBus {
227
275
  ): Promise<CommandExecuteResult<TResult>> {
228
276
  const handler = await this.resolveHandler<TInput, TResult>(commandId)
229
277
 
230
- // Run beforeExecute command interceptors
278
+ const replayLogEntry = options.redoLogEntry ?? null
279
+ const isAtomicReplay = replayLogEntry !== null && handler.atomicReplay === true
231
280
  const allInterceptors = getAllCommandInterceptorInstances()
232
281
  let interceptorMetadata = new Map<string, Record<string, unknown>>()
233
282
  let effectiveOptions = options
234
- const userFeatures = allInterceptors.length
235
- ? await this.resolveUserFeaturesForInterceptors(options.ctx)
236
- : []
237
- if (allInterceptors.length) {
283
+ let userFeatures: string[] = []
284
+ const runBeforeExecuteInterceptors = async (baseOptions: CommandExecutionOptions<TInput>) => {
285
+ const resolvedUserFeatures = allInterceptors.length
286
+ ? await this.resolveUserFeaturesForInterceptors(baseOptions.ctx)
287
+ : []
288
+ if (!allInterceptors.length) {
289
+ return {
290
+ effectiveOptions: baseOptions,
291
+ metadata: new Map<string, Record<string, unknown>>(),
292
+ userFeatures: resolvedUserFeatures,
293
+ }
294
+ }
238
295
  const interceptorCtx: CommandInterceptorContext = {
239
296
  commandId,
240
- auth: options.ctx.auth ?? null,
241
- selectedOrganizationId: options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null,
242
- container: options.ctx.container,
297
+ auth: baseOptions.ctx.auth ?? null,
298
+ selectedOrganizationId: baseOptions.ctx.selectedOrganizationId ?? baseOptions.ctx.auth?.orgId ?? null,
299
+ container: baseOptions.ctx.container,
243
300
  }
244
301
  const beforeResult = await runCommandInterceptorsBefore(
245
- allInterceptors, commandId, options.input, interceptorCtx, userFeatures,
302
+ allInterceptors, commandId, baseOptions.input, interceptorCtx, resolvedUserFeatures,
246
303
  )
247
304
  if (!beforeResult.ok) {
248
305
  const blocked = beforeResult.error!
249
306
  throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
250
307
  }
251
- interceptorMetadata = beforeResult.metadataByInterceptor
252
- if (beforeResult.modifiedInput) {
253
- effectiveOptions = {
254
- ...options,
255
- input: { ...(options.input as object), ...beforeResult.modifiedInput } as TInput,
256
- }
308
+ const preparedOptions = beforeResult.modifiedInput
309
+ ? {
310
+ ...baseOptions,
311
+ input: { ...(baseOptions.input as object), ...beforeResult.modifiedInput } as TInput,
312
+ }
313
+ : baseOptions
314
+ return {
315
+ effectiveOptions: preparedOptions,
316
+ metadata: beforeResult.metadataByInterceptor,
317
+ userFeatures: resolvedUserFeatures,
257
318
  }
258
319
  }
259
320
 
260
- const snapshots = await this.prepareSnapshots(handler, effectiveOptions)
261
- const redoLogEntry = effectiveOptions.redoLogEntry ?? null
262
- const result =
263
- redoLogEntry && typeof handler.redo === 'function'
264
- ? await handler.redo({ input: effectiveOptions.input, ctx: effectiveOptions.ctx, logEntry: redoLogEntry })
265
- : await handler.execute(effectiveOptions.input, effectiveOptions.ctx)
266
- const afterSnapshot = await this.captureAfter(handler, effectiveOptions, result)
267
- const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
268
- const logMeta = await this.buildLog(handler, effectiveOptions, result, snapshotsWithAfter)
269
- let mergedMeta = this.mergeMetadata(effectiveOptions.metadata, logMeta)
270
- // Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
271
- // than the generic `context` one, so the metadata an interceptor already passes to its
272
- // own afterExecute hook is never silently promoted into audit storage.
273
- // Map iteration order is interceptor priority order (see collectMatching), so a
274
- // later-priority interceptor overrides an earlier one on key collisions.
275
- let interceptorContextMerged: Record<string, unknown> = {}
276
- for (const meta of interceptorMetadata.values()) {
277
- const logContextRecord = asRecord(asRecord(meta)?.logContext)
278
- if (!logContextRecord) continue
279
- interceptorContextMerged = {
280
- ...interceptorContextMerged,
281
- ...logContextRecord,
321
+ if (replayLogEntry && !isAtomicReplay) {
322
+ await this.runReplayTransactionGuard(options.ctx, 'redo', replayLogEntry)
323
+ if (handler.authorizeReplay) {
324
+ await handler.authorizeReplay({
325
+ operation: 'redo',
326
+ input: options.input,
327
+ ctx: options.ctx,
328
+ logEntry: replayLogEntry,
329
+ })
282
330
  }
283
331
  }
284
- const baseContext = asRecord(effectiveOptions.metadata?.context) ?? {}
285
- const logMetaContext = asRecord(logMeta?.context) ?? {}
286
- if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
287
- mergedMeta = mergedMeta ?? {}
288
- mergedMeta.context = {
289
- ...baseContext,
290
- ...interceptorContextMerged,
291
- ...logMetaContext,
292
- }
332
+
333
+ if (!isAtomicReplay) {
334
+ const prepared = await runBeforeExecuteInterceptors(options)
335
+ effectiveOptions = prepared.effectiveOptions
336
+ interceptorMetadata = prepared.metadata
337
+ userFeatures = prepared.userFeatures
293
338
  }
294
- const undoable = this.isUndoable(handler)
295
- if (undoable) {
296
- mergedMeta = mergedMeta ?? {}
297
- if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
298
- if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = effectiveOptions.ctx.auth?.sub ?? null
299
- }
300
- if (afterSnapshot !== undefined && afterSnapshot !== null) {
301
- if (!mergedMeta) {
302
- mergedMeta = { snapshotAfter: afterSnapshot }
303
- } else if (!mergedMeta.snapshotAfter) {
304
- mergedMeta.snapshotAfter = afterSnapshot
339
+
340
+ const executeCore = async (coreOptions: CommandExecutionOptions<TInput>) => {
341
+ const sourceLog = coreOptions.redoLogEntry ?? null
342
+ const snapshots = await this.prepareSnapshots(handler, coreOptions)
343
+ const result =
344
+ sourceLog && typeof handler.redo === 'function'
345
+ ? await handler.redo({ input: coreOptions.input, ctx: coreOptions.ctx, logEntry: sourceLog })
346
+ : await handler.execute(coreOptions.input, coreOptions.ctx)
347
+ const afterSnapshot = await this.captureAfter(handler, coreOptions, result)
348
+ const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
349
+ const logMeta = await this.buildLog(handler, coreOptions, result, snapshotsWithAfter)
350
+ let mergedMeta = this.mergeMetadata(coreOptions.metadata, logMeta)
351
+ // Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
352
+ // than the generic `context` one, so the metadata an interceptor already passes to its
353
+ // own afterExecute hook is never silently promoted into audit storage.
354
+ // Map iteration order is interceptor priority order (see collectMatching), so a
355
+ // later-priority interceptor overrides an earlier one on key collisions.
356
+ let interceptorContextMerged: Record<string, unknown> = {}
357
+ for (const meta of interceptorMetadata.values()) {
358
+ const logContextRecord = asRecord(asRecord(meta)?.logContext)
359
+ if (!logContextRecord) continue
360
+ interceptorContextMerged = {
361
+ ...interceptorContextMerged,
362
+ ...logContextRecord,
363
+ }
305
364
  }
306
- }
307
- if (snapshots.before) {
308
- if (!mergedMeta) {
309
- mergedMeta = { snapshotBefore: snapshots.before }
310
- } else if (!mergedMeta.snapshotBefore) {
311
- mergedMeta.snapshotBefore = snapshots.before
365
+ const baseContext = asRecord(coreOptions.metadata?.context) ?? {}
366
+ const logMetaContext = asRecord(logMeta?.context) ?? {}
367
+ if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
368
+ mergedMeta = mergedMeta ?? {}
369
+ mergedMeta.context = {
370
+ ...baseContext,
371
+ ...interceptorContextMerged,
372
+ ...logMetaContext,
373
+ }
312
374
  }
313
- }
314
- if (mergedMeta?.snapshotBefore !== undefined && mergedMeta?.snapshotAfter !== undefined) {
315
- const currentChanges = mergedMeta.changes
316
- const shouldInfer =
317
- currentChanges === undefined ||
318
- currentChanges === null ||
319
- (typeof currentChanges === 'object' && !Array.isArray(currentChanges) && Object.keys(currentChanges).length === 0)
320
- if (shouldInfer) {
321
- const inferred = deriveChangesFromSnapshots(mergedMeta.snapshotBefore, mergedMeta.snapshotAfter)
322
- if (inferred) mergedMeta.changes = inferred
375
+ const undoable = this.isUndoable(handler)
376
+ if (undoable && mergedMeta?.replayable !== false) {
377
+ mergedMeta = mergedMeta ?? {}
378
+ if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
379
+ if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = coreOptions.ctx.auth?.sub ?? null
380
+ } else if (mergedMeta?.replayable === false) {
381
+ mergedMeta.undoToken = null
382
+ }
383
+ if (afterSnapshot !== undefined && afterSnapshot !== null) {
384
+ if (!mergedMeta) {
385
+ mergedMeta = { snapshotAfter: afterSnapshot }
386
+ } else if (!mergedMeta.snapshotAfter) {
387
+ mergedMeta.snapshotAfter = afterSnapshot
388
+ }
323
389
  }
390
+ if (snapshots.before) {
391
+ if (!mergedMeta) {
392
+ mergedMeta = { snapshotBefore: snapshots.before }
393
+ } else if (!mergedMeta.snapshotBefore) {
394
+ mergedMeta.snapshotBefore = snapshots.before
395
+ }
396
+ }
397
+ if (mergedMeta?.snapshotBefore !== undefined && mergedMeta?.snapshotAfter !== undefined) {
398
+ const currentChanges = mergedMeta.changes
399
+ const shouldInfer =
400
+ currentChanges === undefined ||
401
+ currentChanges === null ||
402
+ (typeof currentChanges === 'object' && !Array.isArray(currentChanges) && Object.keys(currentChanges).length === 0)
403
+ if (shouldInfer) {
404
+ const inferred = deriveChangesFromSnapshots(mergedMeta.snapshotBefore, mergedMeta.snapshotAfter)
405
+ if (inferred) mergedMeta.changes = inferred
406
+ }
407
+ }
408
+ const logEntry = await this.persistLog(commandId, coreOptions, mergedMeta)
409
+ return { result, mergedMeta, logEntry }
324
410
  }
325
- const logEntry = await this.persistLog(commandId, effectiveOptions, mergedMeta)
326
411
 
327
- // Run afterExecute command interceptors
328
- let finalResult = result
329
- if (allInterceptors.length) {
330
- const interceptorCtx: CommandInterceptorContext = {
331
- commandId,
332
- auth: effectiveOptions.ctx.auth ?? null,
333
- selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
334
- container: effectiveOptions.ctx.container,
412
+ let replaySourceFinalized = false
413
+ let coreResult: Awaited<ReturnType<typeof executeCore>>
414
+ const atomicSourceLog = options.redoLogEntry ?? null
415
+ let ambientReplay: AtomicReplayTransaction | null = null
416
+ let replayTransactionLifetime: TransactionLifetime | null = null
417
+ if (atomicSourceLog && isAtomicReplay) {
418
+ ambientReplay = resolveAtomicReplayTransaction(options.ctx)
419
+ const replayEm = ambientReplay.em
420
+ await withAtomicFlush(replayEm, [async () => {
421
+ const replayCtx = bindReplayTransactionContext(
422
+ options.ctx,
423
+ replayEm,
424
+ ambientReplay?.joinsAmbient ? options.ctx.transactionLifetime : undefined,
425
+ )
426
+ replayTransactionLifetime = replayCtx.transactionLifetime ?? null
427
+ const transactionalBaseOptions: CommandExecutionOptions<TInput> = {
428
+ ...options,
429
+ ctx: replayCtx,
430
+ }
431
+ if (!atomicSourceLog.id) throw new Error('[internal] Atomic redo source log id is required')
432
+ if (handler.stabilizeReplay) {
433
+ await handler.stabilizeReplay({
434
+ operation: 'redo',
435
+ input: transactionalBaseOptions.input,
436
+ ctx: transactionalBaseOptions.ctx,
437
+ logEntry: atomicSourceLog,
438
+ })
439
+ }
440
+ await this.runReplayTransactionGuard(
441
+ transactionalBaseOptions.ctx,
442
+ 'redo',
443
+ atomicSourceLog,
444
+ replayEm,
445
+ )
446
+ const prepared = await runBeforeExecuteInterceptors(transactionalBaseOptions)
447
+ effectiveOptions = prepared.effectiveOptions
448
+ interceptorMetadata = prepared.metadata
449
+ userFeatures = prepared.userFeatures
450
+ const service = effectiveOptions.ctx.container.resolve('actionLogService') as CommandActionLogService
451
+ if (handler.authorizeReplay) {
452
+ await handler.authorizeReplay({
453
+ operation: 'redo',
454
+ input: effectiveOptions.input,
455
+ ctx: effectiveOptions.ctx,
456
+ logEntry: atomicSourceLog,
457
+ })
458
+ }
459
+ const claimed = await service.claimForRedo(atomicSourceLog.id, replayEm)
460
+ if (!claimed) throw new Error('[internal] Redo source already consumed')
461
+ coreResult = await executeCore(effectiveOptions)
462
+ }], { transaction: true, label: `${commandId}.redo` })
463
+ replaySourceFinalized = !ambientReplay.joinsAmbient
464
+ } else {
465
+ coreResult = await executeCore(effectiveOptions)
466
+ }
467
+ const { result, mergedMeta, logEntry } = coreResult!
468
+
469
+ const runPostCommit = async () => {
470
+ let finalResult = result
471
+ if (allInterceptors.length) {
472
+ const interceptorCtx: CommandInterceptorContext = {
473
+ commandId,
474
+ auth: effectiveOptions.ctx.auth ?? null,
475
+ selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
476
+ container: effectiveOptions.ctx.container,
477
+ }
478
+ const afterResult = await runCommandInterceptorsAfter(
479
+ allInterceptors, commandId, effectiveOptions.input, result, interceptorCtx,
480
+ userFeatures, interceptorMetadata,
481
+ )
482
+ if (afterResult.modifiedResult && typeof result === 'object' && result) {
483
+ finalResult = { ...(result as object), ...afterResult.modifiedResult } as Awaited<TResult>
484
+ }
335
485
  }
336
- const afterResult = await runCommandInterceptorsAfter(
337
- allInterceptors, commandId, effectiveOptions.input, result, interceptorCtx,
338
- userFeatures, interceptorMetadata,
339
- )
340
- if (afterResult.modifiedResult && typeof result === 'object' && result) {
341
- finalResult = { ...(result as object), ...afterResult.modifiedResult } as Awaited<TResult>
486
+
487
+ if (!effectiveOptions.skipCacheInvalidation) {
488
+ await this.invalidateCacheAfterExecute(commandId, effectiveOptions, finalResult, mergedMeta)
342
489
  }
490
+ // Bulk-import backfills defer heavy per-record side effects: the ctx flags are read here and
491
+ // threaded as a local into the flush (never stored on the shared dataEngine), so a concurrent
492
+ // command with different flags can't observe them. Reindex is restored by the caller's
493
+ // end-of-run `query_index rebuild`. Mirrors `skipCacheInvalidation` above.
494
+ await this.flushCrudSideEffects(effectiveOptions.ctx.container, effectiveOptions.ctx?.bulkImport)
495
+ return finalResult
343
496
  }
344
497
 
345
- if (!effectiveOptions.skipCacheInvalidation) {
346
- await this.invalidateCacheAfterExecute(commandId, effectiveOptions, finalResult, mergedMeta)
498
+ const executionResult: CommandExecuteResult<TResult> = {
499
+ result,
500
+ logEntry,
501
+ replaySourceFinalized,
502
+ }
503
+ if (ambientReplay?.joinsAmbient) {
504
+ if (!replayTransactionLifetime || !ownsTransactionLifetime(ambientReplay.em, replayTransactionLifetime)) {
505
+ throw new Error('[internal] Ambient atomic replay lost its outer transaction lifetime owner')
506
+ }
507
+ onTransactionLifetimeComplete(ambientReplay.em, async (outcome) => {
508
+ if (outcome !== 'committed') return
509
+ executionResult.result = await runPostCommit()
510
+ executionResult.replaySourceFinalized = true
511
+ })
512
+ return executionResult
347
513
  }
348
- // Bulk-import backfills defer heavy per-record side effects: the ctx flags are read here and
349
- // threaded as a local into the flush (never stored on the shared dataEngine), so a concurrent
350
- // command with different flags can't observe them. Reindex is restored by the caller's
351
- // end-of-run `query_index rebuild`. Mirrors `skipCacheInvalidation` above.
352
- await this.flushCrudSideEffects(effectiveOptions.ctx.container, effectiveOptions.ctx?.bulkImport)
353
- return { result: finalResult, logEntry }
514
+
515
+ executionResult.result = await runPostCommit()
516
+ return executionResult
354
517
  }
355
518
 
356
519
  async undo(undoToken: string, ctx: CommandRuntimeContext): Promise<void> {
357
- const service = (ctx.container.resolve('actionLogService') as ActionLogService)
520
+ const service = (ctx.container.resolve('actionLogService') as CommandActionLogService)
358
521
  const log = await service.findByUndoToken(undoToken)
359
522
  if (!log) throw new Error('Undo token expired or not found')
360
523
  const handler = await this.resolveHandler(log.commandId)
@@ -362,46 +525,133 @@ export class CommandBus {
362
525
  throw new Error(`Command ${log.commandId} is not undoable`)
363
526
  }
364
527
 
365
- // Atomically claim the action-log row before running any undo side effects.
366
- // Two concurrent requests holding the same undo token can both pass
367
- // findByUndoToken/executionState checks; the compare-and-set below ensures
368
- // only one transitions `done` -> `undoing` and proceeds, the other bails out.
369
- const claimed = await service.claimForUndo(log.id)
370
- if (!claimed) throw new Error('Undo token already consumed')
371
-
372
- try {
373
- // Run beforeUndo command interceptors
374
- const allInterceptors = getAllCommandInterceptorInstances()
375
- let undoInterceptorMetadata = new Map<string, Record<string, unknown>>()
376
- const userFeatures = allInterceptors.length
377
- ? await this.resolveUserFeaturesForInterceptors(ctx)
528
+ const isAtomicReplay = handler.atomicReplay === true
529
+ const allInterceptors = getAllCommandInterceptorInstances()
530
+ let undoInterceptorMetadata = new Map<string, Record<string, unknown>>()
531
+ let userFeatures: string[] = []
532
+ const runBeforeUndoInterceptors = async (replayCtx: CommandRuntimeContext) => {
533
+ const resolvedUserFeatures = allInterceptors.length
534
+ ? await this.resolveUserFeaturesForInterceptors(replayCtx)
378
535
  : []
379
- if (allInterceptors.length) {
380
- const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
381
- const interceptorCtx: CommandInterceptorContext = {
382
- commandId: log.commandId,
383
- auth: ctx.auth ?? null,
384
- selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
385
- container: ctx.container,
386
- }
387
- const beforeResult = await runCommandInterceptorsBeforeUndo(
388
- allInterceptors, log.commandId, undoCtx, interceptorCtx, userFeatures,
389
- )
390
- if (!beforeResult.ok) {
391
- const blocked = beforeResult.error!
392
- throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
536
+ if (!allInterceptors.length) {
537
+ return {
538
+ metadata: new Map<string, Record<string, unknown>>(),
539
+ userFeatures: resolvedUserFeatures,
393
540
  }
394
- undoInterceptorMetadata = beforeResult.metadataByInterceptor
395
541
  }
542
+ const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
543
+ const interceptorCtx: CommandInterceptorContext = {
544
+ commandId: log.commandId,
545
+ auth: replayCtx.auth ?? null,
546
+ selectedOrganizationId: replayCtx.selectedOrganizationId ?? replayCtx.auth?.orgId ?? null,
547
+ container: replayCtx.container,
548
+ }
549
+ const beforeResult = await runCommandInterceptorsBeforeUndo(
550
+ allInterceptors, log.commandId, undoCtx, interceptorCtx, resolvedUserFeatures,
551
+ )
552
+ if (!beforeResult.ok) {
553
+ const blocked = beforeResult.error!
554
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
555
+ }
556
+ return {
557
+ metadata: beforeResult.metadataByInterceptor,
558
+ userFeatures: resolvedUserFeatures,
559
+ }
560
+ }
561
+
562
+ if (!isAtomicReplay) {
563
+ await this.runReplayTransactionGuard(ctx, 'undo', log)
564
+ if (handler.authorizeReplay) {
565
+ await handler.authorizeReplay({
566
+ operation: 'undo',
567
+ input: log.commandPayload as Parameters<NonNullable<typeof handler.authorizeReplay>>[0]['input'],
568
+ ctx,
569
+ logEntry: log,
570
+ })
571
+ }
572
+ }
396
573
 
397
- await handler.undo({
574
+ const claimUndo = async (replayEm?: EntityManager) => {
575
+ // Two contenders can both pass the initial lookup; only the compare-and-set
576
+ // below may proceed. On the atomic path this state is still uncommitted and
577
+ // rolls back with every handler/log write if anything later fails.
578
+ const claimed = replayEm
579
+ ? await service.claimForUndo(log.id, replayEm)
580
+ : await service.claimForUndo(log.id)
581
+ if (!claimed) throw new Error('[internal] Undo token already consumed')
582
+ }
583
+ const finishUndo = async (replayCtx: CommandRuntimeContext, replayEm?: EntityManager) => {
584
+ await handler.undo!({
398
585
  input: log.commandPayload as Parameters<NonNullable<typeof handler.undo>>[0]['input'],
399
- ctx,
586
+ ctx: replayCtx,
400
587
  logEntry: log,
401
588
  })
402
- await service.markUndone(log.id, this.buildUndoTraceLog(log, ctx))
589
+ const traceLog = this.buildUndoTraceLog(log, replayCtx)
590
+ if (replayEm) {
591
+ await service.markUndone(log.id, traceLog, replayEm)
592
+ } else {
593
+ await service.markUndone(log.id, traceLog)
594
+ }
595
+ }
403
596
 
404
- // Run afterUndo command interceptors
597
+ let ambientReplay: AtomicReplayTransaction | null = null
598
+ let replayTransactionLifetime: TransactionLifetime | null = null
599
+ if (isAtomicReplay) {
600
+ ambientReplay = resolveAtomicReplayTransaction(ctx)
601
+ const replayEm = ambientReplay.em
602
+ await withAtomicFlush(replayEm, [async () => {
603
+ const replayCtx = bindReplayTransactionContext(
604
+ ctx,
605
+ replayEm,
606
+ ambientReplay?.joinsAmbient ? ctx.transactionLifetime : undefined,
607
+ )
608
+ replayTransactionLifetime = replayCtx.transactionLifetime ?? null
609
+ if (handler.stabilizeReplay) {
610
+ await handler.stabilizeReplay({
611
+ operation: 'undo',
612
+ input: log.commandPayload as Parameters<NonNullable<typeof handler.stabilizeReplay>>[0]['input'],
613
+ ctx: replayCtx,
614
+ logEntry: log,
615
+ })
616
+ }
617
+ await this.runReplayTransactionGuard(replayCtx, 'undo', log, replayEm)
618
+ const prepared = await runBeforeUndoInterceptors(replayCtx)
619
+ undoInterceptorMetadata = prepared.metadata
620
+ userFeatures = prepared.userFeatures
621
+ if (handler.authorizeReplay) {
622
+ await handler.authorizeReplay({
623
+ operation: 'undo',
624
+ input: log.commandPayload as Parameters<NonNullable<typeof handler.authorizeReplay>>[0]['input'],
625
+ ctx: replayCtx,
626
+ logEntry: log,
627
+ })
628
+ }
629
+ await claimUndo(replayEm)
630
+ await finishUndo(replayCtx, replayEm)
631
+ }], {
632
+ transaction: true,
633
+ label: `${log.commandId}.undo`,
634
+ })
635
+ } else {
636
+ try {
637
+ // Preserve the established non-atomic lifecycle exactly: authorization,
638
+ // source claim, before-undo interceptors, handler, then finalization.
639
+ await claimUndo()
640
+ const prepared = await runBeforeUndoInterceptors(ctx)
641
+ undoInterceptorMetadata = prepared.metadata
642
+ userFeatures = prepared.userFeatures
643
+ await finishUndo(ctx)
644
+ } catch (err) {
645
+ // Legacy handlers retain their compensating release behavior because
646
+ // their domain mutation may not share the action-log transaction.
647
+ await service.releaseUndoClaim(log.id).catch(() => {})
648
+ throw err
649
+ }
650
+ }
651
+
652
+ const runPostCommit = async () => {
653
+ // Post-commit hooks and side effects run only after an atomic replay has
654
+ // durably committed both the domain state and source/trace log state.
405
655
  if (allInterceptors.length) {
406
656
  const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
407
657
  const interceptorCtx: CommandInterceptorContext = {
@@ -418,12 +668,19 @@ export class CommandBus {
418
668
 
419
669
  await this.invalidateCacheAfterUndo(log, ctx)
420
670
  await this.flushCrudSideEffects(ctx.container)
421
- } catch (err) {
422
- // Undo failed after claiming the row — release the claim so the action
423
- // remains retryable instead of being stranded in the `undoing` state.
424
- await service.releaseUndoClaim(log.id).catch(() => {})
425
- throw err
426
671
  }
672
+
673
+ if (ambientReplay?.joinsAmbient) {
674
+ if (!replayTransactionLifetime || !ownsTransactionLifetime(ambientReplay.em, replayTransactionLifetime)) {
675
+ throw new Error('[internal] Ambient atomic replay lost its outer transaction lifetime owner')
676
+ }
677
+ onTransactionLifetimeComplete(ambientReplay.em, async (outcome) => {
678
+ if (outcome === 'committed') await runPostCommit()
679
+ })
680
+ return
681
+ }
682
+
683
+ await runPostCommit()
427
684
  }
428
685
 
429
686
  private buildUndoTraceLog(log: ActionLog, ctx: CommandRuntimeContext): ActionLogCreateInput | undefined {
@@ -463,22 +720,66 @@ export class CommandBus {
463
720
 
464
721
  private async resolveUserFeaturesForInterceptors(ctx: CommandRuntimeContext): Promise<string[]> {
465
722
  if (!ctx.auth) return []
723
+ type RbacLike = {
724
+ getGrantedFeatures: (
725
+ userId: string,
726
+ opts: { tenantId: string | null; organizationId: string | null },
727
+ ) => Promise<string[]>
728
+ getGrantedFeaturesWithEntityManager?: (
729
+ em: EntityManager,
730
+ userId: string,
731
+ opts: { tenantId: string | null; organizationId: string | null },
732
+ ) => Promise<string[]>
733
+ }
734
+ let rbac: RbacLike | undefined
735
+ try {
736
+ rbac = ctx.container.resolve('rbacService') as RbacLike | undefined
737
+ } catch {
738
+ // Intentional: rbacService is not registered in all runtime contexts (CLI, tests, bootstrap).
739
+ return []
740
+ }
741
+ const scope = {
742
+ tenantId: ctx.auth.tenantId,
743
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
744
+ }
745
+ if (ctx.transactionalEm) {
746
+ if (!rbac?.getGrantedFeaturesWithEntityManager) return []
747
+ return await rbac.getGrantedFeaturesWithEntityManager(
748
+ ctx.transactionalEm,
749
+ ctx.auth.sub,
750
+ scope,
751
+ )
752
+ }
466
753
  try {
467
- type RbacLike = { getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]> }
468
- const rbac = ctx.container.resolve('rbacService') as RbacLike | undefined
469
754
  if (rbac?.getGrantedFeatures) {
470
- return await rbac.getGrantedFeatures(ctx.auth.sub, {
471
- tenantId: ctx.auth.tenantId,
472
- organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,
473
- })
755
+ return await rbac.getGrantedFeatures(ctx.auth.sub, scope)
474
756
  }
475
757
  } catch {
476
- // Intentional: rbacService is not registered in all runtime contexts (CLI, tests, bootstrap).
477
- // Falling through to return [] is safe — interceptors without feature gating still run.
758
+ // Outside a transaction a failed lookup cannot poison anything; interceptors without
759
+ // feature gating still run. Inside one the error is rethrown above, because the failed
760
+ // query has already aborted the transaction.
478
761
  }
479
762
  return []
480
763
  }
481
764
 
765
+ private async runReplayTransactionGuard(
766
+ ctx: CommandRuntimeContext,
767
+ operation: 'undo' | 'redo',
768
+ logEntry: CommandUndoLogEntry,
769
+ transactionalEm?: EntityManager,
770
+ ): Promise<void> {
771
+ if (!ctx.replayTransactionGuard) return
772
+ if (transactionalEm) {
773
+ await ctx.replayTransactionGuard({ operation, logEntry, transactionalEm })
774
+ return
775
+ }
776
+ const requestEm = ctx.container.resolve('em') as EntityManager
777
+ const guardEm = typeof requestEm.fork === 'function' ? requestEm.fork() : requestEm
778
+ await withAtomicFlush(guardEm, [async () => {
779
+ await ctx.replayTransactionGuard!({ operation, logEntry, transactionalEm: guardEm })
780
+ }], { transaction: true, label: `${logEntry.commandId}.${operation}.request-guard` })
781
+ }
782
+
482
783
  private async resolveHandler<TInput, TResult>(commandId: string): Promise<CommandHandler<TInput, TResult>> {
483
784
  const handler =
484
785
  commandRegistry.get<TInput, TResult>(commandId) ??
@@ -540,6 +841,10 @@ export class CommandBus {
540
841
  if (!primary && !secondary) return null
541
842
  return {
542
843
  skipLog: secondary?.skipLog ?? primary?.skipLog ?? false,
844
+ replayable:
845
+ secondary?.replayable === false || primary?.replayable === false
846
+ ? false
847
+ : secondary?.replayable ?? primary?.replayable,
543
848
  tenantId: secondary?.tenantId ?? primary?.tenantId ?? null,
544
849
  organizationId: secondary?.organizationId ?? primary?.organizationId ?? null,
545
850
  actorUserId: secondary?.actorUserId ?? primary?.actorUserId ?? null,
@@ -552,6 +857,9 @@ export class CommandBus {
552
857
  relatedResourceKind: secondary?.relatedResourceKind ?? primary?.relatedResourceKind ?? null,
553
858
  relatedResourceId: secondary?.relatedResourceId ?? primary?.relatedResourceId ?? null,
554
859
  undoToken: secondary?.undoToken ?? primary?.undoToken ?? null,
860
+ // A redacted redo input always wins: it exists precisely because the raw
861
+ // input carries a secret that must not reach `action_logs`.
862
+ redoInput: secondary?.redoInput !== undefined ? secondary.redoInput : primary?.redoInput,
555
863
  payload: secondary?.payload ?? primary?.payload ?? null,
556
864
  snapshotBefore: secondary?.snapshotBefore ?? primary?.snapshotBefore ?? null,
557
865
  snapshotAfter: secondary?.snapshotAfter ?? primary?.snapshotAfter ?? null,
@@ -572,9 +880,9 @@ export class CommandBus {
572
880
  if (resourceKind && SKIPPED_ACTION_LOG_RESOURCE_KINDS.has(resourceKind)) {
573
881
  return null
574
882
  }
575
- let service: ActionLogService | null = null
883
+ let service: CommandActionLogService | null = null
576
884
  try {
577
- service = (options.ctx.container.resolve('actionLogService') as ActionLogService)
885
+ service = (options.ctx.container.resolve('actionLogService') as CommandActionLogService)
578
886
  } catch {
579
887
  service = null
580
888
  }
@@ -628,10 +936,17 @@ export class CommandBus {
628
936
  payload.context = { ...baseContext, source: runAs.source }
629
937
  }
630
938
 
631
- const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, options.input)
632
- payload.commandPayload = redoEnvelope
939
+ if (metadata.replayable === false) {
940
+ delete payload.commandPayload
941
+ } else {
942
+ const redoInput = metadata.redoInput !== undefined ? metadata.redoInput : options.input
943
+ const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, redoInput)
944
+ payload.commandPayload = redoEnvelope
945
+ }
633
946
 
634
- return await service.log(payload as ActionLogCreateInput)
947
+ return options.ctx.transactionalEm
948
+ ? await service.log(payload as ActionLogCreateInput, options.ctx.transactionalEm)
949
+ : await service.log(payload as ActionLogCreateInput)
635
950
  }
636
951
 
637
952
  private isUndoable(handler: CommandHandler<unknown, unknown>): boolean {