@open-mercato/shared 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +10 -0
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/command-bus.js +56 -47
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/flush.js +23 -1
- package/dist/lib/commands/flush.js.map +2 -2
- package/dist/lib/commands/index.js +6 -1
- package/dist/lib/commands/index.js.map +2 -2
- package/dist/lib/commands/redo.js +106 -0
- package/dist/lib/commands/redo.js.map +7 -0
- package/dist/lib/commands/runCrudCommandWrite.js +38 -0
- package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/errors.js +22 -0
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +16 -0
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/optimistic-lock-command.js +109 -0
- package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-headers.js +15 -0
- package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
- package/dist/lib/crud/optimistic-lock-store.js +52 -0
- package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
- package/dist/lib/crud/optimistic-lock.js +172 -0
- package/dist/lib/crud/optimistic-lock.js.map +7 -0
- package/dist/lib/data/engine.js +2 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/di/container.js +18 -2
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +57 -23
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +41 -8
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/context.js +5 -0
- package/dist/lib/i18n/context.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/package.json +8 -9
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
- package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
- package/src/lib/commands/__tests__/flush.test.ts +110 -9
- package/src/lib/commands/__tests__/redo.test.ts +265 -0
- package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/command-bus.ts +62 -44
- package/src/lib/commands/flush.ts +79 -2
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/redo.ts +235 -0
- package/src/lib/commands/runCrudCommandWrite.ts +82 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/commands/types.ts +54 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
- package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
- package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
- package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
- package/src/lib/crud/errors.ts +29 -0
- package/src/lib/crud/factory.ts +23 -0
- package/src/lib/crud/optimistic-lock-command.ts +305 -0
- package/src/lib/crud/optimistic-lock-headers.ts +30 -0
- package/src/lib/crud/optimistic-lock-store.ts +87 -0
- package/src/lib/crud/optimistic-lock.ts +379 -0
- package/src/lib/data/engine.ts +11 -8
- package/src/lib/di/container.ts +17 -1
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +44 -6
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +76 -24
- package/src/lib/encryption/subscriber.ts +54 -9
- package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
- package/src/lib/i18n/context.tsx +11 -0
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/integrations/types.ts +14 -0
- package/src/modules/notifications/handler.ts +7 -0
- package/src/modules/search.ts +9 -0
- package/src/modules/vector.ts +7 -0
|
@@ -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
|
|
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
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|