@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/lib/auth/jwt.js +4 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationScope.js +15 -1
- package/dist/lib/auth/organizationScope.js.map +2 -2
- package/dist/lib/availability/catalogOnlyProvider.js +77 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/catalog-visibility/index.js +11 -0
- package/dist/lib/catalog-visibility/index.js.map +7 -0
- package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
- package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesOne.js +27 -0
- package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
- package/dist/lib/catalog-visibility/matchesScope.js +9 -0
- package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
- package/dist/lib/catalog-visibility/types.js +1 -0
- package/dist/lib/catalog-visibility/types.js.map +7 -0
- package/dist/lib/catalog-visibility/unionScopes.js +9 -0
- package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
- package/dist/lib/commands/command-bus.js +383 -125
- package/dist/lib/commands/command-bus.js.map +3 -3
- package/dist/lib/commands/flush.js +16 -0
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +2 -7
- package/dist/lib/commands/redo.js.map +2 -2
- package/dist/lib/commands/transaction-lifetime.js +67 -0
- package/dist/lib/commands/transaction-lifetime.js.map +7 -0
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/commands/undo.js +54 -2
- package/dist/lib/commands/undo.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +64 -9
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +42 -18
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +12 -3
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/accentInsensitiveSearch.js +21 -0
- package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
- package/dist/lib/encryption/kms.js +1 -5
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +3 -1
- package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
- package/dist/lib/encryption/migration-backfill.js +69 -0
- package/dist/lib/encryption/migration-backfill.js.map +7 -0
- package/dist/lib/encryption/policyMemo.js +28 -0
- package/dist/lib/encryption/policyMemo.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +30 -3
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
- package/dist/lib/modules/api-route-identity.js +102 -0
- package/dist/lib/modules/api-route-identity.js.map +7 -0
- package/dist/lib/telemetry/runtime.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +20 -0
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +3 -1
- package/dist/modules/registry.js.map +2 -2
- package/package.json +11 -3
- package/src/lib/auth/__tests__/jwt.test.ts +12 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
- package/src/lib/auth/jwt.ts +9 -0
- package/src/lib/auth/organizationScope.ts +39 -0
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
- package/src/lib/availability/__tests__/registry.test.ts +130 -0
- package/src/lib/availability/catalogOnlyProvider.ts +152 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +131 -0
- package/src/lib/availability/types.ts +86 -0
- package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
- package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
- package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
- package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
- package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
- package/src/lib/catalog-visibility/index.ts +5 -0
- package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
- package/src/lib/catalog-visibility/matchesOne.ts +28 -0
- package/src/lib/catalog-visibility/matchesScope.ts +8 -0
- package/src/lib/catalog-visibility/types.ts +31 -0
- package/src/lib/catalog-visibility/unionScopes.ts +16 -0
- package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
- package/src/lib/commands/__tests__/redo.test.ts +4 -0
- package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
- package/src/lib/commands/__tests__/undo.test.ts +90 -1
- package/src/lib/commands/command-bus.ts +463 -148
- package/src/lib/commands/flush.ts +16 -0
- package/src/lib/commands/index.ts +7 -0
- package/src/lib/commands/redo.ts +4 -8
- package/src/lib/commands/transaction-lifetime.ts +88 -0
- package/src/lib/commands/types.ts +91 -0
- package/src/lib/commands/undo.ts +79 -1
- package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
- package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
- package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
- package/src/lib/crud/enricher-runner.ts +83 -9
- package/src/lib/crud/factory.ts +46 -19
- package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
- package/src/lib/data/engine.ts +12 -0
- package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
- package/src/lib/db/accentInsensitiveSearch.ts +70 -0
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
- package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
- package/src/lib/encryption/__tests__/kms.test.ts +45 -3
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
- package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
- package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
- package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
- package/src/lib/encryption/kms.ts +1 -6
- package/src/lib/encryption/likeFilterWarning.ts +10 -2
- package/src/lib/encryption/migration-backfill.ts +90 -0
- package/src/lib/encryption/policyMemo.ts +51 -0
- package/src/lib/encryption/subscriber.ts +41 -4
- package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
- package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
- package/src/lib/modules/api-route-identity.ts +116 -0
- package/src/lib/telemetry/runtime.ts +8 -0
- package/src/modules/__tests__/registry.test.ts +92 -0
- package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
- package/src/modules/events/factory.ts +31 -0
- package/src/modules/events/types.ts +13 -0
- package/src/modules/notifications/types.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
241
|
-
selectedOrganizationId:
|
|
242
|
-
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,
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
if (
|
|
309
|
-
mergedMeta =
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
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
|
-
|
|
328
|
-
let
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
477
|
-
//
|
|
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:
|
|
883
|
+
let service: CommandActionLogService | null = null
|
|
576
884
|
try {
|
|
577
|
-
service = (options.ctx.container.resolve('actionLogService') as
|
|
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
|
-
|
|
632
|
-
|
|
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
|
|
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 {
|