@open-mercato/shared 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +12 -5
- 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
|
@@ -49,6 +49,181 @@ describe('CommandBus', () => {
|
|
|
49
49
|
expect(logEntry).toEqual({ id: 'log-entry' })
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
it('authorizes undo before claiming the log or running mutations', async () => {
|
|
53
|
+
const authorizeReplay = jest.fn(async () => {
|
|
54
|
+
throw new Error('replay denied')
|
|
55
|
+
})
|
|
56
|
+
const undo = jest.fn(async () => undefined)
|
|
57
|
+
const claimForUndo = jest.fn(async () => true)
|
|
58
|
+
const markUndone = jest.fn(async () => undefined)
|
|
59
|
+
registerCommand({
|
|
60
|
+
id: 'test.command.undo-guarded',
|
|
61
|
+
execute: jest.fn(),
|
|
62
|
+
authorizeReplay,
|
|
63
|
+
undo,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const logEntry = {
|
|
67
|
+
id: 'log-guarded',
|
|
68
|
+
commandId: 'test.command.undo-guarded',
|
|
69
|
+
commandPayload: { __redoInput: { id: 'record-1' } },
|
|
70
|
+
}
|
|
71
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
72
|
+
container.register({
|
|
73
|
+
actionLogService: asValue({
|
|
74
|
+
findByUndoToken: jest.fn(async () => logEntry),
|
|
75
|
+
claimForUndo,
|
|
76
|
+
markUndone,
|
|
77
|
+
}),
|
|
78
|
+
})
|
|
79
|
+
const ctx = {
|
|
80
|
+
container,
|
|
81
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
82
|
+
organizationScope: null,
|
|
83
|
+
selectedOrganizationId: null,
|
|
84
|
+
organizationIds: null,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await expect(new CommandBus().undo('undo-token', ctx)).rejects.toThrow('replay denied')
|
|
88
|
+
|
|
89
|
+
expect(authorizeReplay).toHaveBeenCalledWith(
|
|
90
|
+
expect.objectContaining({ operation: 'undo', logEntry })
|
|
91
|
+
)
|
|
92
|
+
expect(claimForUndo).not.toHaveBeenCalled()
|
|
93
|
+
expect(undo).not.toHaveBeenCalled()
|
|
94
|
+
expect(markUndone).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('authorizes redo before snapshots, mutations, or log writes', async () => {
|
|
98
|
+
const authorizeReplay = jest.fn(async () => {
|
|
99
|
+
throw new Error('redo denied')
|
|
100
|
+
})
|
|
101
|
+
const prepare = jest.fn(async () => ({ before: { value: 'before' } }))
|
|
102
|
+
const execute = jest.fn(async () => ({ ok: true }))
|
|
103
|
+
const undo = jest.fn(async () => undefined)
|
|
104
|
+
const log = jest.fn(async () => ({ id: 'unexpected-log' }))
|
|
105
|
+
registerCommand({
|
|
106
|
+
id: 'test.command.redo-guarded',
|
|
107
|
+
prepare,
|
|
108
|
+
execute,
|
|
109
|
+
authorizeReplay,
|
|
110
|
+
undo,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
114
|
+
container.register({ actionLogService: asValue({ log }) })
|
|
115
|
+
const ctx = {
|
|
116
|
+
container,
|
|
117
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
118
|
+
organizationScope: null,
|
|
119
|
+
selectedOrganizationId: null,
|
|
120
|
+
organizationIds: null,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
new CommandBus().execute('test.command.redo-guarded', {
|
|
125
|
+
input: { id: 'record-1' },
|
|
126
|
+
ctx,
|
|
127
|
+
redoLogEntry: { id: 'source-log', commandId: 'test.command.redo-guarded' },
|
|
128
|
+
})
|
|
129
|
+
).rejects.toThrow('redo denied')
|
|
130
|
+
|
|
131
|
+
expect(authorizeReplay).toHaveBeenCalledWith(
|
|
132
|
+
expect.objectContaining({ operation: 'redo' })
|
|
133
|
+
)
|
|
134
|
+
expect(prepare).not.toHaveBeenCalled()
|
|
135
|
+
expect(execute).not.toHaveBeenCalled()
|
|
136
|
+
expect(log).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it.each([
|
|
140
|
+
['auth.users.create', 'Password-bearing user create'],
|
|
141
|
+
['auth.users.update', 'Password-bearing user update'],
|
|
142
|
+
])('persists %s as audit-only without credentials, hashes, payload, or undo token', async (commandId, actionLabel) => {
|
|
143
|
+
const log = jest.fn(async (entry: Record<string, unknown>) => ({ id: 'log-sensitive', ...entry }))
|
|
144
|
+
registerCommand({
|
|
145
|
+
id: commandId,
|
|
146
|
+
execute: jest.fn(async () => ({ ok: true })),
|
|
147
|
+
undo: jest.fn(async () => undefined),
|
|
148
|
+
buildLog: jest.fn(() => ({
|
|
149
|
+
replayable: false,
|
|
150
|
+
actionLabel,
|
|
151
|
+
resourceKind: 'auth.user',
|
|
152
|
+
resourceId: 'user-1',
|
|
153
|
+
snapshotAfter: { email: 'person@example.com' },
|
|
154
|
+
payload: {
|
|
155
|
+
undo: {
|
|
156
|
+
after: {
|
|
157
|
+
passwordHash: '$2b$10$persisted-hash-must-not-survive',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
165
|
+
container.register({ actionLogService: asValue({ log }) })
|
|
166
|
+
const ctx = {
|
|
167
|
+
container,
|
|
168
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
169
|
+
organizationScope: null,
|
|
170
|
+
selectedOrganizationId: null,
|
|
171
|
+
organizationIds: null,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await new CommandBus().execute(commandId, {
|
|
175
|
+
input: { password: 'plain-text-secret' },
|
|
176
|
+
ctx,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const persisted = log.mock.calls[0]?.[0]
|
|
180
|
+
expect(persisted.undoToken).toBeUndefined()
|
|
181
|
+
expect(persisted.commandPayload).toBeUndefined()
|
|
182
|
+
const persistedJson = JSON.stringify(persisted)
|
|
183
|
+
expect(persistedJson).not.toContain('plain-text-secret')
|
|
184
|
+
expect(persistedJson).not.toContain('persisted-hash-must-not-survive')
|
|
185
|
+
expect(persistedJson).not.toContain('passwordHash')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('persists a redacted redoInput without dropping the undo token', async () => {
|
|
189
|
+
// A credential-bearing create must keep the secret out of `action_logs`
|
|
190
|
+
// WITHOUT losing undo: undoing a create only deletes the row, so suppressing
|
|
191
|
+
// the whole entry (`replayable: false`) would remove a safe affordance.
|
|
192
|
+
const log = jest.fn(async (entry: Record<string, unknown>) => ({ id: 'log-redacted', ...entry }))
|
|
193
|
+
registerCommand({
|
|
194
|
+
id: 'test.command.redacted-input',
|
|
195
|
+
execute: jest.fn(async () => ({ ok: true })),
|
|
196
|
+
undo: jest.fn(async () => undefined),
|
|
197
|
+
buildLog: jest.fn(() => ({
|
|
198
|
+
redoInput: { email: 'person@example.com' },
|
|
199
|
+
actionLabel: 'Create user',
|
|
200
|
+
resourceKind: 'auth.user',
|
|
201
|
+
resourceId: 'user-1',
|
|
202
|
+
payload: { undo: { after: { id: 'user-1', email: 'person@example.com' } } },
|
|
203
|
+
})),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
207
|
+
container.register({ actionLogService: asValue({ log }) })
|
|
208
|
+
|
|
209
|
+
await new CommandBus().execute('test.command.redacted-input', {
|
|
210
|
+
input: { email: 'person@example.com', password: 'plain-text-secret' },
|
|
211
|
+
ctx: {
|
|
212
|
+
container,
|
|
213
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
|
|
214
|
+
organizationScope: null,
|
|
215
|
+
selectedOrganizationId: null,
|
|
216
|
+
organizationIds: null,
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const persisted = log.mock.calls[0]?.[0] as Record<string, unknown>
|
|
221
|
+
const commandPayload = persisted.commandPayload as Record<string, unknown>
|
|
222
|
+
expect(typeof persisted.undoToken).toBe('string')
|
|
223
|
+
expect(commandPayload.__redoInput).toEqual({ email: 'person@example.com' })
|
|
224
|
+
expect(JSON.stringify(persisted)).not.toContain('plain-text-secret')
|
|
225
|
+
})
|
|
226
|
+
|
|
52
227
|
it('records the system actor marker when a trusted command has no auth actor', async () => {
|
|
53
228
|
const logMock = jest.fn(async () => ({ id: 'system-log-entry' }))
|
|
54
229
|
registerCommand({
|
|
@@ -92,7 +92,7 @@ describe('CommandBus.undo TOCTOU race guard', () => {
|
|
|
92
92
|
const bus = new CommandBus()
|
|
93
93
|
await expect(
|
|
94
94
|
bus.undo('undo-token', { ...ctx, container: buildContainer(service) } as never),
|
|
95
|
-
).rejects.toThrow('Undo token already consumed')
|
|
95
|
+
).rejects.toThrow('[internal] Undo token already consumed')
|
|
96
96
|
|
|
97
97
|
expect(undoMock).not.toHaveBeenCalled()
|
|
98
98
|
expect(service.markUndone).not.toHaveBeenCalled()
|
|
@@ -33,6 +33,10 @@ describe('reviveSnapshotSeed', () => {
|
|
|
33
33
|
expect(seed.symbol).toBeNull()
|
|
34
34
|
expect(seed.decimalPlaces).toBe(2)
|
|
35
35
|
})
|
|
36
|
+
|
|
37
|
+
it('throws on an unparsable date instead of seeding an Invalid Date', () => {
|
|
38
|
+
expect(() => reviveSnapshotSeed({ id: 'row-1', createdAt: 'not-a-date' })).toThrow('Invalid createdAt snapshot date')
|
|
39
|
+
})
|
|
36
40
|
})
|
|
37
41
|
|
|
38
42
|
describe('serializeRowSnapshot', () => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { withAtomicFlush } from '../flush'
|
|
3
|
+
import {
|
|
4
|
+
getTransactionLifetime,
|
|
5
|
+
onTransactionLifetimeComplete,
|
|
6
|
+
type TransactionOutcome,
|
|
7
|
+
} from '../transaction-lifetime'
|
|
8
|
+
import { registerTelemetryRuntime, type TelemetryRuntime } from '../../telemetry/runtime'
|
|
9
|
+
|
|
10
|
+
function buildEntityManager(): EntityManager {
|
|
11
|
+
return {
|
|
12
|
+
begin: jest.fn(async () => undefined),
|
|
13
|
+
commit: jest.fn(async () => undefined),
|
|
14
|
+
flush: jest.fn(async () => undefined),
|
|
15
|
+
getUnitOfWork: jest.fn(() => ({
|
|
16
|
+
computeChangeSets: () => undefined,
|
|
17
|
+
getChangeSets: () => [],
|
|
18
|
+
})),
|
|
19
|
+
rollback: jest.fn(async () => undefined),
|
|
20
|
+
} as unknown as EntityManager
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('transaction lifetime', () => {
|
|
24
|
+
it('isolates concurrent transactions that reuse an EntityManager test double', async () => {
|
|
25
|
+
const em = buildEntityManager()
|
|
26
|
+
const lifetimes: object[] = []
|
|
27
|
+
const outcomes: Array<{ label: string; outcome: TransactionOutcome }> = []
|
|
28
|
+
let releaseFirst: (() => void) | undefined
|
|
29
|
+
let markFirstEntered: (() => void) | undefined
|
|
30
|
+
const firstEntered = new Promise<void>((resolve) => {
|
|
31
|
+
markFirstEntered = resolve
|
|
32
|
+
})
|
|
33
|
+
const firstRelease = new Promise<void>((resolve) => {
|
|
34
|
+
releaseFirst = resolve
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const first = withAtomicFlush(em, [async () => {
|
|
38
|
+
const lifetime = getTransactionLifetime(em)
|
|
39
|
+
expect(lifetime).not.toBeNull()
|
|
40
|
+
lifetimes.push(lifetime!)
|
|
41
|
+
onTransactionLifetimeComplete(em, (outcome) => {
|
|
42
|
+
outcomes.push({ label: 'first', outcome })
|
|
43
|
+
})
|
|
44
|
+
markFirstEntered!()
|
|
45
|
+
await firstRelease
|
|
46
|
+
}], { transaction: true })
|
|
47
|
+
|
|
48
|
+
await firstEntered
|
|
49
|
+
await withAtomicFlush(em, [async () => {
|
|
50
|
+
const lifetime = getTransactionLifetime(em)
|
|
51
|
+
expect(lifetime).not.toBeNull()
|
|
52
|
+
lifetimes.push(lifetime!)
|
|
53
|
+
onTransactionLifetimeComplete(em, (outcome) => {
|
|
54
|
+
outcomes.push({ label: 'second', outcome })
|
|
55
|
+
})
|
|
56
|
+
}], { transaction: true })
|
|
57
|
+
releaseFirst!()
|
|
58
|
+
await first
|
|
59
|
+
|
|
60
|
+
expect(lifetimes).toHaveLength(2)
|
|
61
|
+
expect(lifetimes[0]).not.toBe(lifetimes[1])
|
|
62
|
+
expect(outcomes).toEqual([
|
|
63
|
+
{ label: 'second', outcome: 'committed' },
|
|
64
|
+
{ label: 'first', outcome: 'committed' },
|
|
65
|
+
])
|
|
66
|
+
expect(getTransactionLifetime(em)).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('runs every completion callback after commit and reports failures without rejecting the committed flush', async () => {
|
|
70
|
+
const em = buildEntityManager()
|
|
71
|
+
const reportError = jest.fn()
|
|
72
|
+
const unregister = registerTelemetryRuntime({ reportError } as unknown as TelemetryRuntime)
|
|
73
|
+
const calls: string[] = []
|
|
74
|
+
const failure = new Error('post-commit callback failed')
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await expect(withAtomicFlush(em, [async () => {
|
|
78
|
+
onTransactionLifetimeComplete(em, () => {
|
|
79
|
+
calls.push('first')
|
|
80
|
+
throw failure
|
|
81
|
+
})
|
|
82
|
+
onTransactionLifetimeComplete(em, async (outcome) => {
|
|
83
|
+
calls.push(`second:${outcome}`)
|
|
84
|
+
})
|
|
85
|
+
}], { transaction: true })).resolves.toBeUndefined()
|
|
86
|
+
} finally {
|
|
87
|
+
unregister()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(em.commit).toHaveBeenCalledTimes(1)
|
|
91
|
+
expect(calls).toEqual(['first', 'second:committed'])
|
|
92
|
+
expect(reportError).toHaveBeenCalledWith(failure, expect.objectContaining({
|
|
93
|
+
code: 'shared.transaction_completion_callback_failed',
|
|
94
|
+
attributes: { outcome: 'committed' },
|
|
95
|
+
}))
|
|
96
|
+
expect(getTransactionLifetime(em)).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('never lets a rollback completion callback mask the original error', async () => {
|
|
100
|
+
const em = buildEntityManager()
|
|
101
|
+
const original = new Error('phase failed')
|
|
102
|
+
const outcomes: TransactionOutcome[] = []
|
|
103
|
+
|
|
104
|
+
await expect(withAtomicFlush(em, [async () => {
|
|
105
|
+
onTransactionLifetimeComplete(em, async () => {
|
|
106
|
+
throw new Error('rollback callback failed')
|
|
107
|
+
})
|
|
108
|
+
onTransactionLifetimeComplete(em, (outcome) => {
|
|
109
|
+
outcomes.push(outcome)
|
|
110
|
+
})
|
|
111
|
+
throw original
|
|
112
|
+
}], { transaction: true })).rejects.toBe(original)
|
|
113
|
+
|
|
114
|
+
expect(em.rollback).toHaveBeenCalledTimes(1)
|
|
115
|
+
expect(outcomes).toEqual(['rolled_back'])
|
|
116
|
+
expect(getTransactionLifetime(em)).toBeNull()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractUndoPayload } from '../undo'
|
|
1
|
+
import { extractUndoPayload, reviveSnapshotDates } from '../undo'
|
|
2
2
|
|
|
3
3
|
describe('extractUndoPayload', () => {
|
|
4
4
|
it('returns undo payload from direct property', () => {
|
|
@@ -56,3 +56,92 @@ describe('extractUndoPayload', () => {
|
|
|
56
56
|
expect(extractUndoPayload(logEntry as any)).toEqual({ before: { id: 'before' }, after: null })
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
|
+
|
|
60
|
+
describe('extractUndoPayload date revival (#6336)', () => {
|
|
61
|
+
const iso = '2026-01-02T03:04:05.000Z'
|
|
62
|
+
const jsonRoundTrip = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
|
63
|
+
|
|
64
|
+
it('returns ISO strings untouched when no options are given', () => {
|
|
65
|
+
const logEntry = jsonRoundTrip({ commandPayload: { undo: { before: { item: { occurredAt: new Date(iso) } } } } })
|
|
66
|
+
const payload = extractUndoPayload<{ before: { item: { occurredAt: unknown } } }>(logEntry)
|
|
67
|
+
expect(payload?.before.item.occurredAt).toBe(iso)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('revives dateFields at any depth, including inside arrays', () => {
|
|
71
|
+
const logEntry = jsonRoundTrip({
|
|
72
|
+
commandPayload: {
|
|
73
|
+
undo: {
|
|
74
|
+
before: { item: { occurredAt: new Date(iso), scheduledAt: null, title: iso }, lines: [{ occurredAt: new Date(iso) }] },
|
|
75
|
+
after: { item: { occurredAt: new Date(iso) } },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
const payload = extractUndoPayload<{
|
|
80
|
+
before: { item: { occurredAt: Date; scheduledAt: Date | null; title: string }; lines: Array<{ occurredAt: Date }> }
|
|
81
|
+
after: { item: { occurredAt: Date } }
|
|
82
|
+
}>(logEntry, { dateFields: ['occurredAt', 'scheduledAt'] })
|
|
83
|
+
expect(payload?.before.item.occurredAt).toBeInstanceOf(Date)
|
|
84
|
+
expect(payload?.before.item.occurredAt.toISOString()).toBe(iso)
|
|
85
|
+
expect(payload?.before.item.scheduledAt).toBeNull()
|
|
86
|
+
expect(payload?.before.item.title).toBe(iso)
|
|
87
|
+
expect(payload?.before.lines[0].occurredAt).toBeInstanceOf(Date)
|
|
88
|
+
expect(payload?.after.item.occurredAt).toBeInstanceOf(Date)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('revives only the named datePaths', () => {
|
|
92
|
+
const logEntry = jsonRoundTrip({
|
|
93
|
+
commandPayload: { undo: { before: { item: { occurredAt: iso }, lines: [{ occurredAt: iso }] }, after: { item: { occurredAt: iso } } } },
|
|
94
|
+
})
|
|
95
|
+
const payload = extractUndoPayload<{
|
|
96
|
+
before: { item: { occurredAt: unknown }; lines: Array<{ occurredAt: unknown }> }
|
|
97
|
+
after: { item: { occurredAt: unknown } }
|
|
98
|
+
}>(logEntry, { datePaths: ['before.item.occurredAt', 'before.lines.occurredAt', 'before.missing.occurredAt'] })
|
|
99
|
+
expect(payload?.before.item.occurredAt).toBeInstanceOf(Date)
|
|
100
|
+
expect(payload?.before.lines[0].occurredAt).toBeInstanceOf(Date)
|
|
101
|
+
expect(payload?.after.item.occurredAt).toBe(iso)
|
|
102
|
+
expect(payload?.before).not.toHaveProperty('missing')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('revives dates in the snapshot fallback too', () => {
|
|
106
|
+
const logEntry = { snapshotBefore: { occurredAt: iso }, snapshotAfter: null }
|
|
107
|
+
const payload = extractUndoPayload<{ before: { occurredAt: Date } }>(logEntry, { dateFields: ['occurredAt'] })
|
|
108
|
+
expect(payload?.before.occurredAt).toBeInstanceOf(Date)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not mutate the log entry payload', () => {
|
|
112
|
+
const logEntry = { commandPayload: { undo: { before: { occurredAt: iso } } } }
|
|
113
|
+
extractUndoPayload(logEntry, { dateFields: ['occurredAt'] })
|
|
114
|
+
expect(logEntry.commandPayload.undo.before.occurredAt).toBe(iso)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps a __proto__ key as plain data while walking', () => {
|
|
118
|
+
const logEntry = { commandPayload: JSON.parse('{"undo":{"before":{"__proto__":{"polluted":true},"occurredAt":"2026-01-02T03:04:05.000Z"}}}') }
|
|
119
|
+
const payload = extractUndoPayload<{ before: Record<string, unknown> }>(logEntry, { dateFields: ['occurredAt'] })
|
|
120
|
+
expect(Object.getPrototypeOf(payload?.before)).toBe(Object.prototype)
|
|
121
|
+
expect(Object.keys(payload?.before ?? {})).toContain('__proto__')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('throws on an unparsable snapshot date', () => {
|
|
125
|
+
const logEntry = { commandPayload: { undo: { before: { occurredAt: 'not-a-date' } } } }
|
|
126
|
+
expect(() => extractUndoPayload(logEntry, { dateFields: ['occurredAt'] })).toThrow('Invalid occurredAt snapshot date')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('reviveSnapshotDates', () => {
|
|
131
|
+
it('shallow-clones the record and revives only the named fields', () => {
|
|
132
|
+
const existing = new Date('2026-05-06T00:00:00.000Z')
|
|
133
|
+
const snapshot = { id: 'row-1', effectiveAt: '2026-02-03T00:00:00.000Z', deletedAt: null, updatedAt: existing, label: '2026-02-03' }
|
|
134
|
+
const revived = reviveSnapshotDates(snapshot, ['effectiveAt', 'deletedAt', 'updatedAt', 'absentAt'])
|
|
135
|
+
expect(revived).not.toBe(snapshot)
|
|
136
|
+
expect(revived.effectiveAt).toBeInstanceOf(Date)
|
|
137
|
+
expect(revived.deletedAt).toBeNull()
|
|
138
|
+
expect(revived.updatedAt).toBe(existing)
|
|
139
|
+
expect(revived.label).toBe('2026-02-03')
|
|
140
|
+
expect(revived).not.toHaveProperty('absentAt')
|
|
141
|
+
expect(snapshot.effectiveAt).toBe('2026-02-03T00:00:00.000Z')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('throws on an unparsable value', () => {
|
|
145
|
+
expect(() => reviveSnapshotDates({ effectiveAt: 'garbage' }, ['effectiveAt'])).toThrow('Invalid effectiveAt snapshot date')
|
|
146
|
+
})
|
|
147
|
+
})
|