@open-mercato/shared 0.6.4-develop.4371.1.8f3030407e → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +10 -0
  3. package/dist/lib/auth/apiKeyAuthCache.js +17 -6
  4. package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
  5. package/dist/lib/commands/command-bus.js +56 -47
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/flush.js +23 -1
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/index.js +6 -1
  10. package/dist/lib/commands/index.js.map +2 -2
  11. package/dist/lib/commands/redo.js +106 -0
  12. package/dist/lib/commands/redo.js.map +7 -0
  13. package/dist/lib/commands/runCrudCommandWrite.js +38 -0
  14. package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
  15. package/dist/lib/commands/scope.js +51 -37
  16. package/dist/lib/commands/scope.js.map +2 -2
  17. package/dist/lib/commands/types.js.map +2 -2
  18. package/dist/lib/crud/errors.js +22 -0
  19. package/dist/lib/crud/errors.js.map +2 -2
  20. package/dist/lib/crud/factory.js +16 -0
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  23. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  24. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  25. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  26. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  27. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  28. package/dist/lib/crud/optimistic-lock.js +172 -0
  29. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  30. package/dist/lib/data/engine.js +2 -2
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/di/container.js +18 -2
  33. package/dist/lib/di/container.js.map +2 -2
  34. package/dist/lib/encryption/aes.js +37 -3
  35. package/dist/lib/encryption/aes.js.map +2 -2
  36. package/dist/lib/encryption/kms.js +57 -23
  37. package/dist/lib/encryption/kms.js.map +2 -2
  38. package/dist/lib/encryption/subscriber.js +41 -8
  39. package/dist/lib/encryption/subscriber.js.map +2 -2
  40. package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
  41. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  42. package/dist/lib/i18n/context.js +5 -0
  43. package/dist/lib/i18n/context.js.map +2 -2
  44. package/dist/lib/query/engine.js +41 -31
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/version.js +1 -1
  47. package/dist/lib/version.js.map +1 -1
  48. package/dist/modules/integrations/types.js.map +2 -2
  49. package/dist/modules/search.js.map +2 -2
  50. package/package.json +8 -9
  51. package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
  52. package/src/lib/auth/apiKeyAuthCache.ts +20 -6
  53. package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
  54. package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
  55. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
  56. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  57. package/src/lib/commands/__tests__/redo.test.ts +265 -0
  58. package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
  59. package/src/lib/commands/__tests__/scope.test.ts +48 -0
  60. package/src/lib/commands/command-bus.ts +62 -44
  61. package/src/lib/commands/flush.ts +79 -2
  62. package/src/lib/commands/index.ts +9 -0
  63. package/src/lib/commands/redo.ts +235 -0
  64. package/src/lib/commands/runCrudCommandWrite.ts +82 -0
  65. package/src/lib/commands/scope.ts +70 -55
  66. package/src/lib/commands/types.ts +54 -1
  67. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  68. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  69. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  70. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  71. package/src/lib/crud/errors.ts +29 -0
  72. package/src/lib/crud/factory.ts +23 -0
  73. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  74. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  75. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  76. package/src/lib/crud/optimistic-lock.ts +379 -0
  77. package/src/lib/data/engine.ts +11 -8
  78. package/src/lib/di/container.ts +17 -1
  79. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
  80. package/src/lib/encryption/__tests__/kms.test.ts +44 -6
  81. package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
  82. package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
  83. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
  84. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
  85. package/src/lib/encryption/aes.ts +78 -2
  86. package/src/lib/encryption/kms.ts +76 -24
  87. package/src/lib/encryption/subscriber.ts +54 -9
  88. package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
  89. package/src/lib/i18n/context.tsx +11 -0
  90. package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
  91. package/src/lib/query/engine.ts +59 -30
  92. package/src/modules/integrations/types.ts +14 -0
  93. package/src/modules/notifications/handler.ts +7 -0
  94. package/src/modules/search.ts +9 -0
  95. package/src/modules/vector.ts +7 -0
@@ -83,4 +83,52 @@ describe('ensureOrganizationScope', () => {
83
83
  expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
84
84
  })
85
85
  })
86
+
87
+ describe('fail-open-by-omission hardening (#2441)', () => {
88
+ const originalNodeEnv = process.env.NODE_ENV
89
+ const originalStrict = process.env.OM_ENFORCE_ORG_SCOPE_STRICT
90
+ let warnSpy: jest.SpyInstance
91
+
92
+ beforeEach(() => {
93
+ // The scope loggers suppress output under NODE_ENV==='test'; flip it so the
94
+ // observability signal becomes assertable, then restore in afterEach.
95
+ process.env.NODE_ENV = 'development'
96
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
97
+ })
98
+
99
+ afterEach(() => {
100
+ warnSpy.mockRestore()
101
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV
102
+ else process.env.NODE_ENV = originalNodeEnv
103
+ if (originalStrict === undefined) delete process.env.OM_ENFORCE_ORG_SCOPE_STRICT
104
+ else process.env.OM_ENFORCE_ORG_SCOPE_STRICT = originalStrict
105
+ })
106
+
107
+ it('emits an observability warning when scope and currentOrg are both null', () => {
108
+ delete process.env.OM_ENFORCE_ORG_SCOPE_STRICT
109
+ const ctx = buildCtx({ orgId: null, selectedOrganizationId: null, organizationScope: null })
110
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).not.toThrow()
111
+ expect(warnSpy).toHaveBeenCalledWith(
112
+ '[scope] Unscoped organization command executed without organization context',
113
+ expect.objectContaining({ targetOrganizationId: 'org-b', strictEnforcement: false })
114
+ )
115
+ })
116
+
117
+ it('throws when OM_ENFORCE_ORG_SCOPE_STRICT is enabled', () => {
118
+ process.env.OM_ENFORCE_ORG_SCOPE_STRICT = 'true'
119
+ const ctx = buildCtx({ orgId: null, selectedOrganizationId: null, organizationScope: null })
120
+ expect(() => ensureOrganizationScope(ctx, 'org-b')).toThrow(CrudHttpError)
121
+ expect(warnSpy).toHaveBeenCalledWith(
122
+ '[scope] Unscoped organization command executed without organization context',
123
+ expect.objectContaining({ targetOrganizationId: 'org-b', strictEnforcement: true })
124
+ )
125
+ })
126
+
127
+ it('does not warn or throw when there is no target organization to validate', () => {
128
+ delete process.env.OM_ENFORCE_ORG_SCOPE_STRICT
129
+ const ctx = buildCtx({ orgId: null, selectedOrganizationId: null, organizationScope: null })
130
+ expect(() => ensureOrganizationScope(ctx, '')).not.toThrow()
131
+ expect(warnSpy).not.toHaveBeenCalled()
132
+ })
133
+ })
86
134
  })
@@ -230,7 +230,11 @@ export class CommandBus {
230
230
  }
231
231
 
232
232
  const snapshots = await this.prepareSnapshots(handler, effectiveOptions)
233
- const result = await handler.execute(effectiveOptions.input, effectiveOptions.ctx)
233
+ const redoLogEntry = effectiveOptions.redoLogEntry ?? null
234
+ const result =
235
+ redoLogEntry && typeof handler.redo === 'function'
236
+ ? await handler.redo({ input: effectiveOptions.input, ctx: effectiveOptions.ctx, logEntry: redoLogEntry })
237
+ : await handler.execute(effectiveOptions.input, effectiveOptions.ctx)
234
238
  const afterSnapshot = await this.captureAfter(handler, effectiveOptions, result)
235
239
  const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
236
240
  const logMeta = await this.buildLog(handler, effectiveOptions, result, snapshotsWithAfter)
@@ -302,53 +306,67 @@ export class CommandBus {
302
306
  throw new Error(`Command ${log.commandId} is not undoable`)
303
307
  }
304
308
 
305
- // Run beforeUndo command interceptors
306
- const allInterceptors = getAllCommandInterceptorInstances()
307
- let undoInterceptorMetadata = new Map<string, Record<string, unknown>>()
308
- const userFeatures = allInterceptors.length
309
- ? await this.resolveUserFeaturesForInterceptors(ctx)
310
- : []
311
- if (allInterceptors.length) {
312
- const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
313
- const interceptorCtx: CommandInterceptorContext = {
314
- commandId: log.commandId,
315
- auth: ctx.auth ?? null,
316
- selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
317
- container: ctx.container,
318
- }
319
- const beforeResult = await runCommandInterceptorsBeforeUndo(
320
- allInterceptors, log.commandId, undoCtx, interceptorCtx, userFeatures,
321
- )
322
- if (!beforeResult.ok) {
323
- throw new CommandInterceptorError(beforeResult.error!.message)
324
- }
325
- undoInterceptorMetadata = beforeResult.metadataByInterceptor
326
- }
309
+ // Atomically claim the action-log row before running any undo side effects.
310
+ // Two concurrent requests holding the same undo token can both pass
311
+ // findByUndoToken/executionState checks; the compare-and-set below ensures
312
+ // only one transitions `done` -> `undoing` and proceeds, the other bails out.
313
+ const claimed = await service.claimForUndo(log.id)
314
+ if (!claimed) throw new Error('Undo token already consumed')
327
315
 
328
- await handler.undo({
329
- input: log.commandPayload as Parameters<NonNullable<typeof handler.undo>>[0]['input'],
330
- ctx,
331
- logEntry: log,
332
- })
333
- await service.markUndone(log.id, this.buildUndoTraceLog(log, ctx))
316
+ try {
317
+ // Run beforeUndo command interceptors
318
+ const allInterceptors = getAllCommandInterceptorInstances()
319
+ let undoInterceptorMetadata = new Map<string, Record<string, unknown>>()
320
+ const userFeatures = allInterceptors.length
321
+ ? await this.resolveUserFeaturesForInterceptors(ctx)
322
+ : []
323
+ if (allInterceptors.length) {
324
+ const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
325
+ const interceptorCtx: CommandInterceptorContext = {
326
+ commandId: log.commandId,
327
+ auth: ctx.auth ?? null,
328
+ selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
329
+ container: ctx.container,
330
+ }
331
+ const beforeResult = await runCommandInterceptorsBeforeUndo(
332
+ allInterceptors, log.commandId, undoCtx, interceptorCtx, userFeatures,
333
+ )
334
+ if (!beforeResult.ok) {
335
+ throw new CommandInterceptorError(beforeResult.error!.message)
336
+ }
337
+ undoInterceptorMetadata = beforeResult.metadataByInterceptor
338
+ }
334
339
 
335
- // Run afterUndo command interceptors
336
- if (allInterceptors.length) {
337
- const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
338
- const interceptorCtx: CommandInterceptorContext = {
339
- commandId: log.commandId,
340
- auth: ctx.auth ?? null,
341
- selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
342
- container: ctx.container,
340
+ await handler.undo({
341
+ input: log.commandPayload as Parameters<NonNullable<typeof handler.undo>>[0]['input'],
342
+ ctx,
343
+ logEntry: log,
344
+ })
345
+ await service.markUndone(log.id, this.buildUndoTraceLog(log, ctx))
346
+
347
+ // Run afterUndo command interceptors
348
+ if (allInterceptors.length) {
349
+ const undoCtx = { input: log.commandPayload, logEntry: log, undoToken }
350
+ const interceptorCtx: CommandInterceptorContext = {
351
+ commandId: log.commandId,
352
+ auth: ctx.auth ?? null,
353
+ selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
354
+ container: ctx.container,
355
+ }
356
+ await runCommandInterceptorsAfterUndo(
357
+ allInterceptors, log.commandId, undoCtx, interceptorCtx,
358
+ userFeatures, undoInterceptorMetadata,
359
+ )
343
360
  }
344
- await runCommandInterceptorsAfterUndo(
345
- allInterceptors, log.commandId, undoCtx, interceptorCtx,
346
- userFeatures, undoInterceptorMetadata,
347
- )
348
- }
349
361
 
350
- await this.invalidateCacheAfterUndo(log, ctx)
351
- await this.flushCrudSideEffects(ctx.container)
362
+ await this.invalidateCacheAfterUndo(log, ctx)
363
+ await this.flushCrudSideEffects(ctx.container)
364
+ } catch (err) {
365
+ // Undo failed after claiming the row — release the claim so the action
366
+ // remains retryable instead of being stranded in the `undoing` state.
367
+ await service.releaseUndoClaim(log.id).catch(() => {})
368
+ throw err
369
+ }
352
370
  }
353
371
 
354
372
  private buildUndoTraceLog(log: ActionLog, ctx: CommandRuntimeContext): ActionLogCreateInput | undefined {
@@ -19,11 +19,68 @@ export type AtomicFlushOptions = {
19
19
  */
20
20
  isolationLevel?: IsolationLevel
21
21
  /**
22
- * Optional label for diagnostics. Currently informational only.
22
+ * Optional label for diagnostics. Surfaced in the commit-boundary guard's
23
+ * dev warning when a pending change set had to be flushed defensively, so the
24
+ * offending command is identifiable.
23
25
  */
24
26
  label?: string
25
27
  }
26
28
 
29
+ type UnitOfWorkProbe = {
30
+ computeChangeSets?: () => void
31
+ getChangeSets?: () => ReadonlyArray<unknown>
32
+ }
33
+
34
+ type FlushGuardEntityManager = {
35
+ flush: () => Promise<void>
36
+ getUnitOfWork?: () => UnitOfWorkProbe | undefined
37
+ }
38
+
39
+ /**
40
+ * Commit-boundary safety net.
41
+ *
42
+ * After every phase has run and flushed, this asserts the UnitOfWork holds NO
43
+ * pending change sets before the transaction commits. If it still does — a phase
44
+ * mutated a managed entity AFTER its own per-phase flush boundary (the exact
45
+ * shape that silently drops a scalar UPDATE under MikroORM v7) — the guard
46
+ * flushes those changes defensively so the write can never be lost, and warns in
47
+ * non-production so the latent ordering bug gets fixed at the source.
48
+ *
49
+ * Detection is best-effort and fail-safe: it only issues the extra flush when it
50
+ * can PROVE the UnitOfWork is dirty (`computeChangeSets()` → `getChangeSets()`
51
+ * non-empty). On EntityManagers that don't expose a UnitOfWork (partial/mock EMs
52
+ * in unit tests) it does nothing — the per-phase flushes already ran — so it
53
+ * never double-flushes a clean unit of work and never changes flush counts for
54
+ * callers that were already correct.
55
+ */
56
+ async function flushPendingChangesGuard(
57
+ em: FlushGuardEntityManager,
58
+ label?: string,
59
+ ): Promise<void> {
60
+ let pendingCount = -1
61
+ try {
62
+ const uow = typeof em.getUnitOfWork === 'function' ? em.getUnitOfWork() : undefined
63
+ if (uow && typeof uow.computeChangeSets === 'function' && typeof uow.getChangeSets === 'function') {
64
+ uow.computeChangeSets()
65
+ pendingCount = uow.getChangeSets().length
66
+ }
67
+ } catch {
68
+ // Probing the UnitOfWork must never break a command; fall back to "unknown".
69
+ pendingCount = -1
70
+ }
71
+
72
+ if (pendingCount > 0) {
73
+ await em.flush()
74
+ if (process.env.NODE_ENV !== 'production') {
75
+ const where = label ? ` (${label})` : ''
76
+ console.warn(
77
+ `[withAtomicFlush]${where}: ${pendingCount} pending change-set(s) remained at the commit boundary and were flushed defensively. ` +
78
+ 'A phase mutated a managed entity after its flush boundary — split the mutation and any dependent read/sync into separate phases so the change is never at risk of being dropped.',
79
+ )
80
+ }
81
+ }
82
+ }
83
+
27
84
  /**
28
85
  * Wraps multiple mutation phases in a single atomic flush.
29
86
  *
@@ -67,11 +124,31 @@ export async function withAtomicFlush(
67
124
  ): Promise<void> {
68
125
  if (phases.length === 0) return
69
126
 
127
+ // SPEC-018: the phases ARE flush boundaries — flush AFTER EACH phase, not once
128
+ // at the end. A phase's scalar mutations must be persisted before the NEXT
129
+ // phase runs any query (em.find / findOne / nativeUpdate / a sync helper);
130
+ // otherwise the interleaved read resets MikroORM v7's identity-map changeset
131
+ // and the pending scalar UPDATE is silently dropped (the #2453 family). This
132
+ // is the framework-level guarantee that lets commands keep mutations and the
133
+ // reads that depend on them in separate phases without hand-rolled flushes.
134
+ //
135
+ // Atomicity is preserved: when `transaction: true` (or an ambient transaction
136
+ // is joined), each `em.flush()` only emits SQL inside the open transaction —
137
+ // the single commit/rollback below still spans every phase, so a later-phase
138
+ // failure rolls back all earlier phases. Without a transaction the helper
139
+ // keeps its documented "each phase flushes independently" behavior.
140
+ //
141
+ // Commit-boundary guarantee: after the last phase flush, `flushPendingChangesGuard`
142
+ // re-checks the UnitOfWork and flushes once more if ANY pending change set remains
143
+ // (a phase mutated state after its boundary). The transaction therefore can never
144
+ // commit with unflushed work — if a per-phase flush was missed "for some reason",
145
+ // the guard catches it inside the same transaction and warns in dev.
70
146
  const runPhasesAndFlush = async () => {
71
147
  for (const phase of phases) {
72
148
  await phase()
149
+ await em.flush()
73
150
  }
74
- await em.flush()
151
+ await flushPendingChangesGuard(em as unknown as FlushGuardEntityManager, options?.label)
75
152
  }
76
153
 
77
154
  if (!options?.transaction) {
@@ -3,4 +3,13 @@ export * from './registry'
3
3
  export { CommandBus } from './command-bus'
4
4
  export * from './customFieldSnapshots'
5
5
  export * from './undo'
6
+ export * from './redo'
6
7
  export { CommandInterceptorError } from './errors'
8
+ export {
9
+ runCrudCommandWrite,
10
+ type RunCrudCommandWriteOptions,
11
+ type RunCrudCommandWriteResult,
12
+ type CrudCommandWritePhase,
13
+ type CrudCommandWriteScope,
14
+ type CrudCommandWriteSideEffectTarget,
15
+ } from './runCrudCommandWrite'
@@ -0,0 +1,235 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandRuntimeContext, CommandUndoLogEntry } from './types'
3
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
4
+ import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
5
+ import { CrudHttpError, conflict, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
6
+ import { extractUndoPayload, type UndoPayload } from './undo'
7
+ import { emitCrudSideEffects } from './helpers'
8
+ import { withAtomicFlush } from './flush'
9
+
10
+ type EntityClass<T> = abstract new (...args: never[]) => T
11
+
12
+ type ScopedSoftDeletable = {
13
+ id: string
14
+ organizationId?: string | null
15
+ tenantId?: string | null
16
+ deletedAt?: Date | null
17
+ isActive?: boolean
18
+ }
19
+
20
+ /** Snapshot keys revived from ISO strings to `Date` when no explicit `dateFields` is given. */
21
+ const DEFAULT_SNAPSHOT_DATE_FIELDS = ['createdAt', 'updatedAt', 'deletedAt'] as const
22
+
23
+ /**
24
+ * Turn an after-snapshot into a create seed by shallow-cloning it and reviving the
25
+ * declared date fields from ISO strings back to `Date`. Single-row snapshots are a
26
+ * faithful serialized row whose keys already equal entity property names, so the
27
+ * snapshot doubles as the seed once dates are revived — no per-command mapping needed.
28
+ */
29
+ export function reviveSnapshotSeed(
30
+ snapshot: Record<string, unknown>,
31
+ dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,
32
+ ): Record<string, unknown> {
33
+ const seed: Record<string, unknown> = { ...snapshot }
34
+ for (const field of dateFields) {
35
+ const value = seed[field]
36
+ if (typeof value === 'string') seed[field] = new Date(value)
37
+ }
38
+ return seed
39
+ }
40
+
41
+ /**
42
+ * Serialize a persisted row into a plain after-snapshot object: pick `fields` from
43
+ * the entity and convert the declared `dateFields` from `Date` to ISO strings. Use
44
+ * for single-row snapshot loaders that are a clean 1:1 column copy; loaders that
45
+ * shape nested/related data keep their bespoke mapping.
46
+ */
47
+ export function serializeRowSnapshot<TEntity extends Record<string, unknown>>(
48
+ entity: TEntity,
49
+ fields: readonly string[],
50
+ dateFields: readonly string[] = DEFAULT_SNAPSHOT_DATE_FIELDS,
51
+ ): Record<string, unknown> {
52
+ const snapshot: Record<string, unknown> = {}
53
+ const dateFieldSet = new Set(dateFields)
54
+ for (const field of fields) {
55
+ const value = entity[field]
56
+ if (dateFieldSet.has(field)) {
57
+ snapshot[field] = value instanceof Date ? value.toISOString() : (value ?? null)
58
+ } else {
59
+ snapshot[field] = value ?? null
60
+ }
61
+ }
62
+ return snapshot
63
+ }
64
+
65
+ /**
66
+ * Resolve the after-snapshot a create command persisted for its action log, so a
67
+ * `redo` handler can re-materialize the original record. Reads the `undo.after`
68
+ * snapshot via {@link extractUndoPayload} and falls back to `snapshotAfter`.
69
+ */
70
+ export function resolveRedoSnapshot<T>(logEntry: CommandUndoLogEntry | null | undefined): T | null {
71
+ if (!logEntry) return null
72
+ const undo = extractUndoPayload<UndoPayload<T>>(logEntry)
73
+ if (undo && undo.after != null) return undo.after as T
74
+ if (logEntry.snapshotAfter != null) return logEntry.snapshotAfter as T
75
+ return null
76
+ }
77
+
78
+ /**
79
+ * Re-materialize a single row that a create command produced, reusing its original
80
+ * id. If the row still exists (it was soft-deleted by undo), clears `deletedAt` and
81
+ * restores `isActive` from the seed. If it was hard-deleted, re-creates it from the
82
+ * seed (which MUST include the original `id`). This keeps redo idempotent on id so
83
+ * undo/redo snapshots and cross-references stay stable (issue #2506, invariant I6).
84
+ */
85
+ export async function restoreCreatedRow<TEntity extends ScopedSoftDeletable>(
86
+ em: EntityManager,
87
+ entityClass: EntityClass<TEntity>,
88
+ id: string,
89
+ seedFromSnapshot: () => Record<string, unknown>,
90
+ findRow?: (em: EntityManager, id: string) => Promise<TEntity | null>,
91
+ ): Promise<TEntity> {
92
+ const existing = findRow
93
+ ? await findRow(em, id)
94
+ : ((await em.findOne(entityClass as never, { id } as never)) as TEntity | null)
95
+ if (existing) {
96
+ existing.deletedAt = null
97
+ const seed = seedFromSnapshot()
98
+ if (typeof seed.isActive === 'boolean') existing.isActive = seed.isActive
99
+ return existing
100
+ }
101
+ const record = em.create(entityClass as never, { ...seedFromSnapshot(), deletedAt: null } as never) as TEntity
102
+ em.persist(record as never)
103
+ return record
104
+ }
105
+
106
+ export type CreateRedoConfig<TEntity extends ScopedSoftDeletable, TSnapshot, TResult> = {
107
+ entityClass: EntityClass<TEntity>
108
+ /**
109
+ * Pulls the original primary id out of the after-snapshot. Defaults to
110
+ * `(snapshot) => snapshot.id`, which fits single-row snapshots whose top-level
111
+ * `id` is the row's primary key. Override only when the id lives elsewhere.
112
+ */
113
+ getSnapshotId?: (snapshot: TSnapshot) => string | null | undefined
114
+ /**
115
+ * Maps the after-snapshot back to a create seed; MUST include the original id.
116
+ * Defaults to {@link reviveSnapshotSeed} — the snapshot itself with `dateFields`
117
+ * revived to `Date`. Override only when the snapshot keys diverge from entity
118
+ * columns (e.g. nested shapes or derived columns).
119
+ */
120
+ seedFromSnapshot?: (snapshot: TSnapshot) => Record<string, unknown>
121
+ /**
122
+ * Snapshot keys to revive from ISO string to `Date` for the default seed.
123
+ * Defaults to `['createdAt', 'updatedAt', 'deletedAt']`; list additional date
124
+ * columns (e.g. `effectiveAt`, `returnedAt`) when the entity has them.
125
+ */
126
+ dateFields?: readonly string[]
127
+ /** Builds the command result (mirrors `execute`'s return), e.g. `(e) => ({ currencyId: e.id })`. */
128
+ buildResult: (entity: TEntity, snapshot: TSnapshot) => TResult
129
+ events?: CrudEventsConfig<any>
130
+ indexer?: CrudIndexerConfig<any>
131
+ /**
132
+ * Override how the existing row is looked up before restore. Defaults to
133
+ * `em.findOne(entityClass, { id })`. Pass a decryption-aware finder
134
+ * (`findOneWithDecryption`) for encrypted entities so the revive-in-place path
135
+ * sees the same row the rest of the module does.
136
+ */
137
+ findRow?: (args: { em: EntityManager; ctx: CommandRuntimeContext; id: string; snapshot: TSnapshot }) => Promise<TEntity | null>
138
+ /**
139
+ * Runs after the em fork, before the row is restored. Use to validate
140
+ * referenced relations (throw `CrudHttpError` to fail the redo) or resolve
141
+ * relation entities. Anything it returns is shallow-merged into the create
142
+ * seed (e.g. `{ entity: resolvedEntity }`), letting the seed reference a live
143
+ * relation instead of a raw id.
144
+ */
145
+ beforeRestore?: (args: {
146
+ em: EntityManager
147
+ ctx: CommandRuntimeContext
148
+ snapshot: TSnapshot
149
+ }) => Promise<Record<string, unknown> | void> | Record<string, unknown> | void
150
+ /** Optional extra side effects after the row is restored (e.g. query-index upserts, custom-field restore). */
151
+ afterRestore?: (args: {
152
+ em: EntityManager
153
+ ctx: CommandRuntimeContext
154
+ entity: TEntity
155
+ snapshot: TSnapshot
156
+ logEntry: CommandUndoLogEntry
157
+ }) => Promise<void> | void
158
+ /**
159
+ * Wrap the restore (create/revive + flush) in a single transaction via
160
+ * {@link withAtomicFlush}. Use when the create participated in a multi-phase
161
+ * atomic flush in `execute` and partial commits must be impossible.
162
+ */
163
+ transaction?: boolean
164
+ }
165
+
166
+ /**
167
+ * Build a create command's `redo` handler that restores the original row in place
168
+ * (reusing its id) instead of replaying `execute` and minting a new id. Wires the
169
+ * `created` side effects (events + query index) exactly like `execute`. Use this for
170
+ * single-row create commands; multi-entity creates implement `redo` by hand.
171
+ */
172
+ export function makeCreateRedo<
173
+ TEntity extends ScopedSoftDeletable,
174
+ TSnapshot,
175
+ TInput = unknown,
176
+ TResult = unknown,
177
+ >(config: CreateRedoConfig<TEntity, TSnapshot, TResult>) {
178
+ const getSnapshotId = config.getSnapshotId ?? ((snapshot: TSnapshot) => (snapshot as { id?: string | null }).id ?? null)
179
+ const seedFromSnapshot =
180
+ config.seedFromSnapshot ?? ((snapshot: TSnapshot) => reviveSnapshotSeed(snapshot as Record<string, unknown>, config.dateFields))
181
+ return async ({ ctx, logEntry }: { input: TInput; ctx: CommandRuntimeContext; logEntry: CommandUndoLogEntry }): Promise<TResult> => {
182
+ const snapshot = resolveRedoSnapshot<TSnapshot>(logEntry)
183
+ const id = snapshot ? getSnapshotId(snapshot) : (logEntry.resourceId ?? null)
184
+ if (!snapshot || !id) {
185
+ throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for create command' })
186
+ }
187
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
188
+ const overrides = config.beforeRestore ? await config.beforeRestore({ em, ctx, snapshot }) : undefined
189
+ const buildSeed = () => (overrides ? { ...seedFromSnapshot(snapshot), ...overrides } : seedFromSnapshot(snapshot))
190
+ const findRow = config.findRow
191
+ ? (forkedEm: EntityManager, rowId: string) => config.findRow!({ em: forkedEm, ctx, id: rowId, snapshot })
192
+ : undefined
193
+ let entity!: TEntity
194
+ const restorePhase = async () => {
195
+ entity = await restoreCreatedRow(em, config.entityClass, id, buildSeed, findRow)
196
+ }
197
+ const afterRestorePhase = async () => {
198
+ if (config.afterRestore) {
199
+ await config.afterRestore({ em, ctx, entity, snapshot, logEntry })
200
+ }
201
+ }
202
+ try {
203
+ if (config.transaction) {
204
+ const phases = config.afterRestore ? [restorePhase, afterRestorePhase] : [restorePhase]
205
+ await withAtomicFlush(em, phases, { transaction: true })
206
+ } else {
207
+ await restorePhase()
208
+ await em.flush()
209
+ await afterRestorePhase()
210
+ }
211
+ } catch (err) {
212
+ if (isUniqueViolation(err)) {
213
+ // [internal] prefix: this shared-lib helper has no `t(...)` context; the
214
+ // redo unique-collision is a rare developer-facing edge (the after-snapshot's
215
+ // unique key was re-taken since undo), surfaced via normal error handling.
216
+ throw conflict('[internal] Cannot redo: a record with the same unique key already exists.')
217
+ }
218
+ throw err
219
+ }
220
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
221
+ await emitCrudSideEffects({
222
+ dataEngine,
223
+ action: 'created',
224
+ entity,
225
+ identifiers: {
226
+ id,
227
+ organizationId: entity.organizationId ?? null,
228
+ tenantId: entity.tenantId ?? null,
229
+ },
230
+ events: config.events,
231
+ indexer: config.indexer,
232
+ })
233
+ return config.buildResult(entity, snapshot)
234
+ }
235
+ }
@@ -0,0 +1,82 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandRuntimeContext } from './types'
3
+ import type { DataEngine } from '../data/engine'
4
+ import type {
5
+ CrudEventAction,
6
+ CrudEventsConfig,
7
+ CrudIndexerConfig,
8
+ CrudEntityIdentifiers,
9
+ } from '../crud/types'
10
+ import { withAtomicFlush } from './flush'
11
+ import { setCustomFieldsIfAny, emitCrudSideEffects } from './helpers'
12
+
13
+ export type CrudCommandWritePhase = (args: { em: EntityManager }) => void | Promise<void>
14
+
15
+ export type CrudCommandWriteSideEffectTarget<TEntity> = {
16
+ entity: TEntity
17
+ identifiers: CrudEntityIdentifiers
18
+ }
19
+
20
+ export type CrudCommandWriteScope = {
21
+ tenantId: string | null
22
+ organizationId: string | null
23
+ }
24
+
25
+ export type RunCrudCommandWriteOptions<TEntity> = {
26
+ ctx: CommandRuntimeContext
27
+ entityId: string
28
+ action: CrudEventAction
29
+ scope: CrudCommandWriteScope
30
+ phases: CrudCommandWritePhase[]
31
+ customFields?: Record<string, unknown>
32
+ notifyCustomFields?: boolean
33
+ events?: CrudEventsConfig<TEntity>
34
+ indexer?: CrudIndexerConfig<TEntity>
35
+ syncOrigin?: string | null
36
+ sideEffect: () => CrudCommandWriteSideEffectTarget<TEntity>
37
+ em?: EntityManager
38
+ transaction?: boolean
39
+ dataEngine?: DataEngine
40
+ }
41
+
42
+ export type RunCrudCommandWriteResult = { em: EntityManager }
43
+
44
+ export async function runCrudCommandWrite<TEntity>(
45
+ opts: RunCrudCommandWriteOptions<TEntity>,
46
+ ): Promise<RunCrudCommandWriteResult> {
47
+ const em = opts.em ?? (opts.ctx.container.resolve('em') as EntityManager).fork()
48
+ const transaction = opts.transaction ?? true
49
+
50
+ await withAtomicFlush(
51
+ em,
52
+ opts.phases.map((phase) => () => phase({ em })),
53
+ { transaction },
54
+ )
55
+
56
+ const dataEngine = opts.dataEngine ?? (opts.ctx.container.resolve('dataEngine') as DataEngine)
57
+ const target = opts.sideEffect()
58
+
59
+ if (opts.customFields && Object.keys(opts.customFields).length > 0) {
60
+ await setCustomFieldsIfAny({
61
+ dataEngine,
62
+ entityId: opts.entityId,
63
+ recordId: target.identifiers.id,
64
+ tenantId: opts.scope.tenantId,
65
+ organizationId: opts.scope.organizationId,
66
+ values: opts.customFields,
67
+ notify: opts.notifyCustomFields ?? false,
68
+ })
69
+ }
70
+
71
+ await emitCrudSideEffects({
72
+ dataEngine,
73
+ action: opts.action,
74
+ entity: target.entity,
75
+ identifiers: target.identifiers,
76
+ syncOrigin: opts.syncOrigin ?? null,
77
+ events: opts.events,
78
+ indexer: opts.indexer,
79
+ })
80
+
81
+ return { em }
82
+ }