@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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/lib/auth/jwt.js +4 -0
  4. package/dist/lib/auth/jwt.js.map +2 -2
  5. package/dist/lib/auth/organizationScope.js +15 -1
  6. package/dist/lib/auth/organizationScope.js.map +2 -2
  7. package/dist/lib/availability/catalogOnlyProvider.js +77 -0
  8. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  9. package/dist/lib/availability/index.js +8 -0
  10. package/dist/lib/availability/index.js.map +7 -0
  11. package/dist/lib/availability/registry.js +47 -0
  12. package/dist/lib/availability/registry.js.map +7 -0
  13. package/dist/lib/availability/types.js +7 -0
  14. package/dist/lib/availability/types.js.map +7 -0
  15. package/dist/lib/catalog-visibility/index.js +11 -0
  16. package/dist/lib/catalog-visibility/index.js.map +7 -0
  17. package/dist/lib/catalog-visibility/intersectScopes.js +49 -0
  18. package/dist/lib/catalog-visibility/intersectScopes.js.map +7 -0
  19. package/dist/lib/catalog-visibility/matchesOne.js +27 -0
  20. package/dist/lib/catalog-visibility/matchesOne.js.map +7 -0
  21. package/dist/lib/catalog-visibility/matchesScope.js +9 -0
  22. package/dist/lib/catalog-visibility/matchesScope.js.map +7 -0
  23. package/dist/lib/catalog-visibility/types.js +1 -0
  24. package/dist/lib/catalog-visibility/types.js.map +7 -0
  25. package/dist/lib/catalog-visibility/unionScopes.js +9 -0
  26. package/dist/lib/catalog-visibility/unionScopes.js.map +7 -0
  27. package/dist/lib/commands/command-bus.js +383 -125
  28. package/dist/lib/commands/command-bus.js.map +3 -3
  29. package/dist/lib/commands/flush.js +16 -0
  30. package/dist/lib/commands/flush.js.map +2 -2
  31. package/dist/lib/commands/index.js +8 -0
  32. package/dist/lib/commands/index.js.map +2 -2
  33. package/dist/lib/commands/redo.js +2 -7
  34. package/dist/lib/commands/redo.js.map +2 -2
  35. package/dist/lib/commands/transaction-lifetime.js +67 -0
  36. package/dist/lib/commands/transaction-lifetime.js.map +7 -0
  37. package/dist/lib/commands/types.js.map +2 -2
  38. package/dist/lib/commands/undo.js +54 -2
  39. package/dist/lib/commands/undo.js.map +2 -2
  40. package/dist/lib/crud/enricher-runner.js +64 -9
  41. package/dist/lib/crud/enricher-runner.js.map +2 -2
  42. package/dist/lib/crud/factory.js +42 -18
  43. package/dist/lib/crud/factory.js.map +2 -2
  44. package/dist/lib/data/engine.js +12 -3
  45. package/dist/lib/data/engine.js.map +2 -2
  46. package/dist/lib/db/accentInsensitiveSearch.js +21 -0
  47. package/dist/lib/db/accentInsensitiveSearch.js.map +7 -0
  48. package/dist/lib/encryption/kms.js +1 -5
  49. package/dist/lib/encryption/kms.js.map +2 -2
  50. package/dist/lib/encryption/likeFilterWarning.js +3 -1
  51. package/dist/lib/encryption/likeFilterWarning.js.map +2 -2
  52. package/dist/lib/encryption/migration-backfill.js +69 -0
  53. package/dist/lib/encryption/migration-backfill.js.map +7 -0
  54. package/dist/lib/encryption/policyMemo.js +28 -0
  55. package/dist/lib/encryption/policyMemo.js.map +7 -0
  56. package/dist/lib/encryption/subscriber.js +30 -3
  57. package/dist/lib/encryption/subscriber.js.map +2 -2
  58. package/dist/lib/encryption/tenantDataEncryptionService.js +113 -200
  59. package/dist/lib/encryption/tenantDataEncryptionService.js.map +3 -3
  60. package/dist/lib/modules/api-route-identity.js +102 -0
  61. package/dist/lib/modules/api-route-identity.js.map +7 -0
  62. package/dist/lib/telemetry/runtime.js.map +2 -2
  63. package/dist/lib/version.js +1 -1
  64. package/dist/lib/version.js.map +1 -1
  65. package/dist/modules/events/factory.js +20 -0
  66. package/dist/modules/events/factory.js.map +2 -2
  67. package/dist/modules/registry.js +3 -1
  68. package/dist/modules/registry.js.map +2 -2
  69. package/package.json +12 -5
  70. package/src/lib/auth/__tests__/jwt.test.ts +12 -0
  71. package/src/lib/auth/__tests__/organizationScope.test.ts +27 -0
  72. package/src/lib/auth/jwt.ts +9 -0
  73. package/src/lib/auth/organizationScope.ts +39 -0
  74. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +166 -0
  75. package/src/lib/availability/__tests__/registry.test.ts +130 -0
  76. package/src/lib/availability/catalogOnlyProvider.ts +152 -0
  77. package/src/lib/availability/index.ts +18 -0
  78. package/src/lib/availability/registry.ts +131 -0
  79. package/src/lib/availability/types.ts +86 -0
  80. package/src/lib/catalog-visibility/__tests__/intersectScopes.test.ts +168 -0
  81. package/src/lib/catalog-visibility/__tests__/matchesOne.test.ts +116 -0
  82. package/src/lib/catalog-visibility/__tests__/matchesScope.test.ts +35 -0
  83. package/src/lib/catalog-visibility/__tests__/testFixtures.ts +66 -0
  84. package/src/lib/catalog-visibility/__tests__/unionScopes.test.ts +62 -0
  85. package/src/lib/catalog-visibility/index.ts +5 -0
  86. package/src/lib/catalog-visibility/intersectScopes.ts +93 -0
  87. package/src/lib/catalog-visibility/matchesOne.ts +28 -0
  88. package/src/lib/catalog-visibility/matchesScope.ts +8 -0
  89. package/src/lib/catalog-visibility/types.ts +31 -0
  90. package/src/lib/catalog-visibility/unionScopes.ts +16 -0
  91. package/src/lib/commands/__tests__/command-bus.atomic-replay.test.ts +650 -0
  92. package/src/lib/commands/__tests__/command-bus.test.ts +175 -0
  93. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +1 -1
  94. package/src/lib/commands/__tests__/redo.test.ts +4 -0
  95. package/src/lib/commands/__tests__/transaction-lifetime.test.ts +118 -0
  96. package/src/lib/commands/__tests__/undo.test.ts +90 -1
  97. package/src/lib/commands/command-bus.ts +463 -148
  98. package/src/lib/commands/flush.ts +16 -0
  99. package/src/lib/commands/index.ts +7 -0
  100. package/src/lib/commands/redo.ts +4 -8
  101. package/src/lib/commands/transaction-lifetime.ts +88 -0
  102. package/src/lib/commands/types.ts +91 -0
  103. package/src/lib/commands/undo.ts +79 -1
  104. package/src/lib/crud/__tests__/crud-factory.cache-tenant-level.test.ts +149 -0
  105. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +48 -6
  106. package/src/lib/crud/__tests__/crud-factory.test.ts +224 -27
  107. package/src/lib/crud/__tests__/enricher-runner.test.ts +270 -5
  108. package/src/lib/crud/enricher-runner.ts +83 -9
  109. package/src/lib/crud/factory.ts +46 -19
  110. package/src/lib/data/__tests__/engine.pin-organization-id.test.ts +102 -0
  111. package/src/lib/data/engine.ts +12 -0
  112. package/src/lib/db/__tests__/accentInsensitiveSearch.test.ts +51 -0
  113. package/src/lib/db/accentInsensitiveSearch.ts +70 -0
  114. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +11 -9
  115. package/src/lib/encryption/__tests__/fixtures/policy-process-worker.ts +96 -0
  116. package/src/lib/encryption/__tests__/kms.test.ts +45 -3
  117. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +43 -0
  118. package/src/lib/encryption/__tests__/migration-backfill.test.ts +57 -0
  119. package/src/lib/encryption/__tests__/process-policy-coherence.test.ts +128 -0
  120. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +66 -1
  121. package/src/lib/encryption/__tests__/subscriber.test.ts +3 -0
  122. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +412 -51
  123. package/src/lib/encryption/kms.ts +1 -6
  124. package/src/lib/encryption/likeFilterWarning.ts +10 -2
  125. package/src/lib/encryption/migration-backfill.ts +90 -0
  126. package/src/lib/encryption/policyMemo.ts +51 -0
  127. package/src/lib/encryption/subscriber.ts +41 -4
  128. package/src/lib/encryption/tenantDataEncryptionService.ts +153 -219
  129. package/src/lib/i18n/__tests__/detect-locale-narrowed.test.ts +20 -10
  130. package/src/lib/modules/api-route-identity.ts +116 -0
  131. package/src/lib/telemetry/runtime.ts +8 -0
  132. package/src/modules/__tests__/registry.test.ts +92 -0
  133. package/src/modules/events/__tests__/broadcast-coalescing-declaration.test.ts +53 -0
  134. package/src/modules/events/factory.ts +31 -0
  135. package/src/modules/events/types.ts +13 -0
  136. package/src/modules/notifications/types.ts +1 -0
  137. package/src/modules/registry.ts +4 -1
@@ -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
+ })