ai-workflows 2.1.1 → 2.3.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/CHANGELOG.md +17 -1
- package/README.md +305 -184
- package/dist/barrier.d.ts +159 -0
- package/dist/barrier.d.ts.map +1 -0
- package/dist/barrier.js +377 -0
- package/dist/barrier.js.map +1 -0
- package/dist/cascade-context.d.ts +149 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +324 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/cascade-executor.d.ts +196 -0
- package/dist/cascade-executor.d.ts.map +1 -0
- package/dist/cascade-executor.js +384 -0
- package/dist/cascade-executor.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +27 -8
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/dependency-graph.d.ts +157 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +382 -0
- package/dist/dependency-graph.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/every.d.ts +31 -2
- package/dist/every.d.ts.map +1 -1
- package/dist/every.js +63 -32
- package/dist/every.js.map +1 -1
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +8 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/topological-sort.d.ts +121 -0
- package/dist/graph/topological-sort.d.ts.map +1 -0
- package/dist/graph/topological-sort.js +292 -0
- package/dist/graph/topological-sort.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts +35 -10
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +53 -19
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +77 -0
- package/dist/timer-registry.d.ts.map +1 -0
- package/dist/timer-registry.js +154 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/types.d.ts +105 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +136 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +21 -4
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +507 -0
- package/src/cascade-context.ts +495 -0
- package/src/cascade-executor.ts +588 -0
- package/src/context.ts +51 -17
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/dependency-graph.ts +518 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/every.ts +104 -35
- package/src/graph/index.ts +19 -0
- package/src/graph/topological-sort.ts +412 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +81 -26
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +179 -0
- package/src/types.ts +146 -10
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +199 -355
- package/test/barrier-join.test.ts +442 -0
- package/test/barrier-unhandled-rejections.test.ts +359 -0
- package/test/cascade-context.test.ts +390 -0
- package/test/cascade-executor.test.ts +852 -0
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/dependency-graph.test.ts +512 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/graph/topological-sort.test.ts +586 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +353 -0
- package/test/send-race-conditions.test.ts +400 -0
- package/test/type-safety-every.test.ts +303 -0
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -7
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DatabaseContext Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the persistence layer using ai-database integration.
|
|
5
|
+
*
|
|
6
|
+
* ## Test Categories
|
|
7
|
+
* 1. Event sourcing (recordEvent, getEvents, replay)
|
|
8
|
+
* 2. Action management (createAction, completeAction)
|
|
9
|
+
* 3. Artifact storage (storeArtifact, getArtifact)
|
|
10
|
+
* 4. Snapshot management (createSnapshot, restoreSnapshot, getSnapshots)
|
|
11
|
+
* 5. Integration with ai-database
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
createDatabaseContext,
|
|
17
|
+
createMemoryDatabaseContext,
|
|
18
|
+
type DatabaseProvider,
|
|
19
|
+
type EventsAPI,
|
|
20
|
+
type EventSourcingContext,
|
|
21
|
+
} from '../src/database-context.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* In-memory database provider for testing
|
|
25
|
+
*/
|
|
26
|
+
class MemoryDatabaseProvider implements DatabaseProvider {
|
|
27
|
+
private stores = new Map<string, Map<string, Record<string, unknown>>>()
|
|
28
|
+
private emittedEvents: Array<{ event: string; data: unknown }> = []
|
|
29
|
+
|
|
30
|
+
private getStore(type: string): Map<string, Record<string, unknown>> {
|
|
31
|
+
if (!this.stores.has(type)) {
|
|
32
|
+
this.stores.set(type, new Map())
|
|
33
|
+
}
|
|
34
|
+
return this.stores.get(type)!
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async get(type: string, id: string): Promise<Record<string, unknown> | null> {
|
|
38
|
+
const store = this.getStore(type)
|
|
39
|
+
return store.get(id) ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async create(
|
|
43
|
+
type: string,
|
|
44
|
+
data: Record<string, unknown>,
|
|
45
|
+
id?: string
|
|
46
|
+
): Promise<Record<string, unknown>> {
|
|
47
|
+
const store = this.getStore(type)
|
|
48
|
+
const entityId = id ?? `id-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
49
|
+
const record = { ...data, $id: entityId, $type: type }
|
|
50
|
+
store.set(entityId, record)
|
|
51
|
+
return record
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async update(
|
|
55
|
+
type: string,
|
|
56
|
+
id: string,
|
|
57
|
+
data: Record<string, unknown>
|
|
58
|
+
): Promise<Record<string, unknown>> {
|
|
59
|
+
const store = this.getStore(type)
|
|
60
|
+
const existing = store.get(id)
|
|
61
|
+
const updated = { ...(existing ?? {}), ...data, $id: id, $type: type }
|
|
62
|
+
store.set(id, updated)
|
|
63
|
+
return updated
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delete(type: string, id: string): Promise<boolean> {
|
|
67
|
+
const store = this.getStore(type)
|
|
68
|
+
return store.delete(id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async list(
|
|
72
|
+
type: string,
|
|
73
|
+
options?: { limit?: number; offset?: number; where?: Record<string, unknown> }
|
|
74
|
+
): Promise<Record<string, unknown>[]> {
|
|
75
|
+
const store = this.getStore(type)
|
|
76
|
+
let results = Array.from(store.values())
|
|
77
|
+
|
|
78
|
+
if (options?.where) {
|
|
79
|
+
results = results.filter((record) => {
|
|
80
|
+
for (const [key, value] of Object.entries(options.where!)) {
|
|
81
|
+
if (record[key] !== value) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options?.offset) {
|
|
90
|
+
results = results.slice(options.offset)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options?.limit) {
|
|
94
|
+
results = results.slice(0, options.limit)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async emit(event: string, data: unknown): Promise<{ id: string }> {
|
|
101
|
+
this.emittedEvents.push({ event, data })
|
|
102
|
+
return { id: `event-${Date.now()}` }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getEmittedEvents(): Array<{ event: string; data: unknown }> {
|
|
106
|
+
return [...this.emittedEvents]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
clear(): void {
|
|
110
|
+
this.stores.clear()
|
|
111
|
+
this.emittedEvents = []
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mock Events API for testing
|
|
117
|
+
*/
|
|
118
|
+
class MockEventsAPI implements EventsAPI {
|
|
119
|
+
private handlers = new Map<string, Set<(data: unknown) => void>>()
|
|
120
|
+
private emittedEvents: Array<{ event: string; data: unknown }> = []
|
|
121
|
+
|
|
122
|
+
on(event: string, handler: (data: unknown) => void): () => void {
|
|
123
|
+
if (!this.handlers.has(event)) {
|
|
124
|
+
this.handlers.set(event, new Set())
|
|
125
|
+
}
|
|
126
|
+
this.handlers.get(event)!.add(handler)
|
|
127
|
+
return () => {
|
|
128
|
+
this.handlers.get(event)?.delete(handler)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async emit(
|
|
133
|
+
eventOrData: string | { event: string; [key: string]: unknown },
|
|
134
|
+
data?: unknown
|
|
135
|
+
): Promise<{ id: string }> {
|
|
136
|
+
const eventName = typeof eventOrData === 'string' ? eventOrData : eventOrData.event
|
|
137
|
+
const eventData = typeof eventOrData === 'string' ? data : eventOrData
|
|
138
|
+
this.emittedEvents.push({ event: eventName, data: eventData })
|
|
139
|
+
|
|
140
|
+
// Trigger handlers
|
|
141
|
+
const handlers = this.handlers.get(eventName)
|
|
142
|
+
if (handlers) {
|
|
143
|
+
for (const handler of handlers) {
|
|
144
|
+
handler(eventData)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { id: `event-${Date.now()}` }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getEmittedEvents(): Array<{ event: string; data: unknown }> {
|
|
152
|
+
return [...this.emittedEvents]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clear(): void {
|
|
156
|
+
this.handlers.clear()
|
|
157
|
+
this.emittedEvents = []
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('DatabaseContext', () => {
|
|
162
|
+
let db: MemoryDatabaseProvider
|
|
163
|
+
let events: MockEventsAPI
|
|
164
|
+
let ctx: EventSourcingContext
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
db = new MemoryDatabaseProvider()
|
|
168
|
+
events = new MockEventsAPI()
|
|
169
|
+
ctx = createDatabaseContext({
|
|
170
|
+
db,
|
|
171
|
+
events,
|
|
172
|
+
workflowId: 'test-workflow',
|
|
173
|
+
source: 'test',
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('construction', () => {
|
|
178
|
+
it('creates context with database provider', () => {
|
|
179
|
+
const context = createDatabaseContext({ db })
|
|
180
|
+
expect(context).toBeDefined()
|
|
181
|
+
expect(context.recordEvent).toBeDefined()
|
|
182
|
+
expect(context.createAction).toBeDefined()
|
|
183
|
+
expect(context.storeArtifact).toBeDefined()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('creates context with events API', () => {
|
|
187
|
+
const context = createDatabaseContext({ db, events })
|
|
188
|
+
expect(context).toBeDefined()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('creates context with workflow ID', () => {
|
|
192
|
+
const context = createDatabaseContext({
|
|
193
|
+
db,
|
|
194
|
+
workflowId: 'my-workflow',
|
|
195
|
+
})
|
|
196
|
+
expect(context).toBeDefined()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('recordEvent() - event sourcing', () => {
|
|
201
|
+
it('records an event to database', async () => {
|
|
202
|
+
await ctx.recordEvent('Customer.created', { id: '123', name: 'John' })
|
|
203
|
+
|
|
204
|
+
const storedEvents = await ctx.getEvents()
|
|
205
|
+
expect(storedEvents).toHaveLength(1)
|
|
206
|
+
expect(storedEvents[0].eventType).toBe('Customer.created')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('stores event data as JSON', async () => {
|
|
210
|
+
await ctx.recordEvent('Order.completed', { orderId: 'order-1', total: 99.99 })
|
|
211
|
+
|
|
212
|
+
const storedEvents = await ctx.getEvents()
|
|
213
|
+
const data = JSON.parse(storedEvents[0].data)
|
|
214
|
+
expect(data.orderId).toBe('order-1')
|
|
215
|
+
expect(data.total).toBe(99.99)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('records timestamp on events', async () => {
|
|
219
|
+
const before = Date.now()
|
|
220
|
+
await ctx.recordEvent('Test.event', { value: 1 })
|
|
221
|
+
const after = Date.now()
|
|
222
|
+
|
|
223
|
+
const storedEvents = await ctx.getEvents()
|
|
224
|
+
expect(storedEvents[0].timestamp).toBeGreaterThanOrEqual(before)
|
|
225
|
+
expect(storedEvents[0].timestamp).toBeLessThanOrEqual(after)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('includes workflow ID on events', async () => {
|
|
229
|
+
await ctx.recordEvent('Test.event', { value: 1 })
|
|
230
|
+
|
|
231
|
+
const storedEvents = await ctx.getEvents()
|
|
232
|
+
expect(storedEvents[0].workflowId).toBe('test-workflow')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('emits event to events API', async () => {
|
|
236
|
+
await ctx.recordEvent('Customer.created', { id: '123' })
|
|
237
|
+
|
|
238
|
+
const emitted = events.getEmittedEvents()
|
|
239
|
+
expect(emitted.some((e) => e.event === 'WorkflowEvent.created')).toBe(true)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('records multiple events in sequence', async () => {
|
|
243
|
+
await ctx.recordEvent('Step1.started', { step: 1 })
|
|
244
|
+
await ctx.recordEvent('Step1.completed', { step: 1, result: 'ok' })
|
|
245
|
+
await ctx.recordEvent('Step2.started', { step: 2 })
|
|
246
|
+
|
|
247
|
+
const storedEvents = await ctx.getEvents()
|
|
248
|
+
expect(storedEvents).toHaveLength(3)
|
|
249
|
+
expect(storedEvents.map((e) => e.eventType)).toEqual([
|
|
250
|
+
'Step1.started',
|
|
251
|
+
'Step1.completed',
|
|
252
|
+
'Step2.started',
|
|
253
|
+
])
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('getEvents() - event retrieval', () => {
|
|
258
|
+
beforeEach(async () => {
|
|
259
|
+
await ctx.recordEvent('Event1', { seq: 1 })
|
|
260
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
261
|
+
await ctx.recordEvent('Event2', { seq: 2 })
|
|
262
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
263
|
+
await ctx.recordEvent('Event3', { seq: 3 })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns all events', async () => {
|
|
267
|
+
const storedEvents = await ctx.getEvents()
|
|
268
|
+
expect(storedEvents).toHaveLength(3)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('returns events in timestamp order', async () => {
|
|
272
|
+
const storedEvents = await ctx.getEvents()
|
|
273
|
+
for (let i = 1; i < storedEvents.length; i++) {
|
|
274
|
+
expect(storedEvents[i].timestamp).toBeGreaterThanOrEqual(storedEvents[i - 1].timestamp)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('filters events by since timestamp', async () => {
|
|
279
|
+
const storedEvents = await ctx.getEvents()
|
|
280
|
+
const midTimestamp = storedEvents[1].timestamp
|
|
281
|
+
|
|
282
|
+
const filtered = await ctx.getEvents({ since: new Date(midTimestamp) })
|
|
283
|
+
expect(filtered.length).toBeGreaterThanOrEqual(1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('limits number of events returned', async () => {
|
|
287
|
+
const storedEvents = await ctx.getEvents({ limit: 2 })
|
|
288
|
+
expect(storedEvents).toHaveLength(2)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('replay() - event replay', () => {
|
|
293
|
+
beforeEach(async () => {
|
|
294
|
+
await ctx.recordEvent('Step1.completed', { result: 'a' })
|
|
295
|
+
await ctx.recordEvent('Step2.completed', { result: 'b' })
|
|
296
|
+
await ctx.recordEvent('Step3.completed', { result: 'c' })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('replays all events through handler', async () => {
|
|
300
|
+
const replayed: Array<{ event: string; data: unknown }> = []
|
|
301
|
+
|
|
302
|
+
await ctx.replay(async (event, data) => {
|
|
303
|
+
replayed.push({ event, data })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
expect(replayed).toHaveLength(3)
|
|
307
|
+
expect(replayed.map((r) => r.event)).toEqual([
|
|
308
|
+
'Step1.completed',
|
|
309
|
+
'Step2.completed',
|
|
310
|
+
'Step3.completed',
|
|
311
|
+
])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('replays events in order', async () => {
|
|
315
|
+
const results: string[] = []
|
|
316
|
+
|
|
317
|
+
await ctx.replay(async (event, data) => {
|
|
318
|
+
results.push((data as { result: string }).result)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(results).toEqual(['a', 'b', 'c'])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('can reconstruct state from events', async () => {
|
|
325
|
+
const state: Record<string, unknown> = {}
|
|
326
|
+
|
|
327
|
+
await ctx.replay(async (event, data) => {
|
|
328
|
+
const stepNumber = event.match(/Step(\d+)/)?.[1]
|
|
329
|
+
if (stepNumber) {
|
|
330
|
+
state[`step${stepNumber}`] = (data as { result: string }).result
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
expect(state).toEqual({ step1: 'a', step2: 'b', step3: 'c' })
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('replays events since a given time', async () => {
|
|
338
|
+
const storedEvents = await ctx.getEvents()
|
|
339
|
+
const midTimestamp = storedEvents[1].timestamp
|
|
340
|
+
|
|
341
|
+
const replayed: string[] = []
|
|
342
|
+
await ctx.replay(
|
|
343
|
+
async (event) => {
|
|
344
|
+
replayed.push(event)
|
|
345
|
+
},
|
|
346
|
+
{ since: new Date(midTimestamp) }
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
expect(replayed.length).toBeGreaterThanOrEqual(1)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('createAction() - action management', () => {
|
|
354
|
+
it('creates a pending action', async () => {
|
|
355
|
+
await ctx.createAction({
|
|
356
|
+
actor: 'user:john',
|
|
357
|
+
object: 'Order/order-123',
|
|
358
|
+
action: 'approve',
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
const actions = await db.list('WorkflowAction')
|
|
362
|
+
expect(actions).toHaveLength(1)
|
|
363
|
+
expect(actions[0].actor).toBe('user:john')
|
|
364
|
+
expect(actions[0].status).toBe('pending')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('stores action metadata', async () => {
|
|
368
|
+
await ctx.createAction({
|
|
369
|
+
actor: 'system',
|
|
370
|
+
object: 'Report/report-1',
|
|
371
|
+
action: 'generate',
|
|
372
|
+
metadata: { format: 'pdf', pages: 10 },
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const actions = await db.list('WorkflowAction')
|
|
376
|
+
const metadata = JSON.parse(actions[0].metadata as string)
|
|
377
|
+
expect(metadata.format).toBe('pdf')
|
|
378
|
+
expect(metadata.pages).toBe(10)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('emits action created event', async () => {
|
|
382
|
+
await ctx.createAction({
|
|
383
|
+
actor: 'user:alice',
|
|
384
|
+
object: 'Document/doc-1',
|
|
385
|
+
action: 'review',
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const emitted = events.getEmittedEvents()
|
|
389
|
+
expect(emitted.some((e) => e.event === 'WorkflowAction.created')).toBe(true)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
describe('completeAction() - action completion', () => {
|
|
394
|
+
it('marks action as completed', async () => {
|
|
395
|
+
await ctx.createAction({
|
|
396
|
+
actor: 'user:john',
|
|
397
|
+
object: 'Order/order-123',
|
|
398
|
+
action: 'approve',
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const actions = await db.list('WorkflowAction')
|
|
402
|
+
const actionId = actions[0].$id as string
|
|
403
|
+
|
|
404
|
+
await ctx.completeAction(actionId, { approved: true })
|
|
405
|
+
|
|
406
|
+
const updated = await db.get('WorkflowAction', actionId)
|
|
407
|
+
expect(updated?.status).toBe('completed')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('stores action result', async () => {
|
|
411
|
+
await ctx.createAction({
|
|
412
|
+
actor: 'system',
|
|
413
|
+
object: 'Task/task-1',
|
|
414
|
+
action: 'process',
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
const actions = await db.list('WorkflowAction')
|
|
418
|
+
const actionId = actions[0].$id as string
|
|
419
|
+
|
|
420
|
+
await ctx.completeAction(actionId, { output: 'processed', items: 42 })
|
|
421
|
+
|
|
422
|
+
const updated = await db.get('WorkflowAction', actionId)
|
|
423
|
+
const result = JSON.parse(updated?.result as string)
|
|
424
|
+
expect(result.output).toBe('processed')
|
|
425
|
+
expect(result.items).toBe(42)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('throws error for non-existent action', async () => {
|
|
429
|
+
await expect(ctx.completeAction('non-existent', {})).rejects.toThrow('Action not found')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('emits action completed event', async () => {
|
|
433
|
+
await ctx.createAction({
|
|
434
|
+
actor: 'user:bob',
|
|
435
|
+
object: 'Request/req-1',
|
|
436
|
+
action: 'approve',
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const actions = await db.list('WorkflowAction')
|
|
440
|
+
const actionId = actions[0].$id as string
|
|
441
|
+
|
|
442
|
+
await ctx.completeAction(actionId, { approved: true })
|
|
443
|
+
|
|
444
|
+
const emitted = events.getEmittedEvents()
|
|
445
|
+
expect(emitted.some((e) => e.event === 'WorkflowAction.completed')).toBe(true)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
describe('storeArtifact() - artifact storage', () => {
|
|
450
|
+
it('stores an artifact', async () => {
|
|
451
|
+
await ctx.storeArtifact({
|
|
452
|
+
key: 'compiled/workflow-1/code.esm',
|
|
453
|
+
type: 'esm',
|
|
454
|
+
sourceHash: 'abc123',
|
|
455
|
+
content: 'export function handler() {}',
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const stored = await ctx.getArtifact('compiled/workflow-1/code.esm')
|
|
459
|
+
expect(stored).toBe('export function handler() {}')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('stores complex artifact content', async () => {
|
|
463
|
+
const ast = {
|
|
464
|
+
type: 'Program',
|
|
465
|
+
body: [{ type: 'ExportDeclaration' }],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await ctx.storeArtifact({
|
|
469
|
+
key: 'parsed/module.ast',
|
|
470
|
+
type: 'ast',
|
|
471
|
+
sourceHash: 'def456',
|
|
472
|
+
content: ast,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const stored = await ctx.getArtifact('parsed/module.ast')
|
|
476
|
+
expect(stored).toEqual(ast)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('stores artifact metadata', async () => {
|
|
480
|
+
await ctx.storeArtifact({
|
|
481
|
+
key: 'bundle/app.js',
|
|
482
|
+
type: 'bundle',
|
|
483
|
+
sourceHash: 'ghi789',
|
|
484
|
+
content: 'bundled code',
|
|
485
|
+
metadata: { size: 1024, modules: 5 },
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// Metadata is stored but getArtifact only returns content
|
|
489
|
+
const stored = await ctx.getArtifact('bundle/app.js')
|
|
490
|
+
expect(stored).toBe('bundled code')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('returns null for non-existent artifact', async () => {
|
|
494
|
+
const stored = await ctx.getArtifact('non-existent')
|
|
495
|
+
expect(stored).toBeNull()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('overwrites existing artifact with same key', async () => {
|
|
499
|
+
await ctx.storeArtifact({
|
|
500
|
+
key: 'cache/data',
|
|
501
|
+
type: 'bundle',
|
|
502
|
+
sourceHash: 'v1',
|
|
503
|
+
content: 'version 1',
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
await ctx.storeArtifact({
|
|
507
|
+
key: 'cache/data',
|
|
508
|
+
type: 'bundle',
|
|
509
|
+
sourceHash: 'v2',
|
|
510
|
+
content: 'version 2',
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
const stored = await ctx.getArtifact('cache/data')
|
|
514
|
+
expect(stored).toBe('version 2')
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
describe('createSnapshot() - state snapshots', () => {
|
|
519
|
+
it('creates a snapshot of current state', async () => {
|
|
520
|
+
const state = { step: 3, context: { userId: '123' } }
|
|
521
|
+
const snapshotId = await ctx.createSnapshot(state)
|
|
522
|
+
|
|
523
|
+
expect(snapshotId).toBeDefined()
|
|
524
|
+
expect(snapshotId).toContain('snap-')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('creates snapshot with label', async () => {
|
|
528
|
+
const snapshotId = await ctx.createSnapshot({ data: 'important' }, 'before-risky-operation')
|
|
529
|
+
|
|
530
|
+
const snapshots = await ctx.getSnapshots()
|
|
531
|
+
const snapshot = snapshots.find((s) => s.id === snapshotId)
|
|
532
|
+
expect(snapshot?.label).toBe('before-risky-operation')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('records event sequence in snapshot', async () => {
|
|
536
|
+
await ctx.recordEvent('Event1', { seq: 1 })
|
|
537
|
+
await ctx.recordEvent('Event2', { seq: 2 })
|
|
538
|
+
|
|
539
|
+
const snapshotId = await ctx.createSnapshot({ step: 2 })
|
|
540
|
+
|
|
541
|
+
// Verify sequence is tracked
|
|
542
|
+
expect(ctx.getEventSequence()).toBe(2)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('emits snapshot created event', async () => {
|
|
546
|
+
await ctx.createSnapshot({ state: 'test' })
|
|
547
|
+
|
|
548
|
+
const emitted = events.getEmittedEvents()
|
|
549
|
+
expect(emitted.some((e) => e.event === 'WorkflowSnapshot.created')).toBe(true)
|
|
550
|
+
})
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
describe('restoreSnapshot() - state restoration', () => {
|
|
554
|
+
it('restores state from snapshot', async () => {
|
|
555
|
+
const originalState = { step: 5, data: { processed: true } }
|
|
556
|
+
const snapshotId = await ctx.createSnapshot(originalState)
|
|
557
|
+
|
|
558
|
+
const restored = await ctx.restoreSnapshot(snapshotId)
|
|
559
|
+
expect(restored).toEqual(originalState)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('throws error for non-existent snapshot', async () => {
|
|
563
|
+
await expect(ctx.restoreSnapshot('non-existent')).rejects.toThrow('Snapshot not found')
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('restores event sequence from snapshot', async () => {
|
|
567
|
+
await ctx.recordEvent('Event1', {})
|
|
568
|
+
await ctx.recordEvent('Event2', {})
|
|
569
|
+
const snapshotId = await ctx.createSnapshot({ at: 2 })
|
|
570
|
+
|
|
571
|
+
// Record more events
|
|
572
|
+
await ctx.recordEvent('Event3', {})
|
|
573
|
+
await ctx.recordEvent('Event4', {})
|
|
574
|
+
|
|
575
|
+
// Restore should reset sequence
|
|
576
|
+
await ctx.restoreSnapshot(snapshotId)
|
|
577
|
+
expect(ctx.getEventSequence()).toBe(2)
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('emits snapshot restored event', async () => {
|
|
581
|
+
const snapshotId = await ctx.createSnapshot({ data: 'test' })
|
|
582
|
+
await ctx.restoreSnapshot(snapshotId)
|
|
583
|
+
|
|
584
|
+
const emitted = events.getEmittedEvents()
|
|
585
|
+
expect(emitted.some((e) => e.event === 'WorkflowSnapshot.restored')).toBe(true)
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
describe('getSnapshots() - snapshot listing', () => {
|
|
590
|
+
it('returns all snapshots for workflow', async () => {
|
|
591
|
+
await ctx.createSnapshot({ v: 1 }, 'checkpoint-1')
|
|
592
|
+
await ctx.createSnapshot({ v: 2 }, 'checkpoint-2')
|
|
593
|
+
await ctx.createSnapshot({ v: 3 }, 'checkpoint-3')
|
|
594
|
+
|
|
595
|
+
const snapshots = await ctx.getSnapshots()
|
|
596
|
+
expect(snapshots).toHaveLength(3)
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('returns snapshots in reverse chronological order', async () => {
|
|
600
|
+
await ctx.createSnapshot({ v: 1 }, 'first')
|
|
601
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
602
|
+
await ctx.createSnapshot({ v: 2 }, 'second')
|
|
603
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
604
|
+
await ctx.createSnapshot({ v: 3 }, 'third')
|
|
605
|
+
|
|
606
|
+
const snapshots = await ctx.getSnapshots()
|
|
607
|
+
expect(snapshots[0].label).toBe('third')
|
|
608
|
+
expect(snapshots[2].label).toBe('first')
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('returns empty array when no snapshots exist', async () => {
|
|
612
|
+
const snapshots = await ctx.getSnapshots()
|
|
613
|
+
expect(snapshots).toEqual([])
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
describe('getEventSequence() - sequence tracking', () => {
|
|
618
|
+
it('starts at 0', () => {
|
|
619
|
+
const newCtx = createDatabaseContext({ db })
|
|
620
|
+
expect(newCtx.getEventSequence()).toBe(0)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('increments with each event', async () => {
|
|
624
|
+
expect(ctx.getEventSequence()).toBe(0)
|
|
625
|
+
|
|
626
|
+
await ctx.recordEvent('Event1', {})
|
|
627
|
+
expect(ctx.getEventSequence()).toBe(1)
|
|
628
|
+
|
|
629
|
+
await ctx.recordEvent('Event2', {})
|
|
630
|
+
expect(ctx.getEventSequence()).toBe(2)
|
|
631
|
+
|
|
632
|
+
await ctx.recordEvent('Event3', {})
|
|
633
|
+
expect(ctx.getEventSequence()).toBe(3)
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
describe('createMemoryDatabaseContext', () => {
|
|
639
|
+
let ctx: EventSourcingContext
|
|
640
|
+
|
|
641
|
+
beforeEach(() => {
|
|
642
|
+
ctx = createMemoryDatabaseContext()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
describe('in-memory implementation', () => {
|
|
646
|
+
it('provides all DatabaseContext methods', () => {
|
|
647
|
+
expect(ctx.recordEvent).toBeDefined()
|
|
648
|
+
expect(ctx.createAction).toBeDefined()
|
|
649
|
+
expect(ctx.completeAction).toBeDefined()
|
|
650
|
+
expect(ctx.storeArtifact).toBeDefined()
|
|
651
|
+
expect(ctx.getArtifact).toBeDefined()
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('provides event sourcing methods', () => {
|
|
655
|
+
expect(ctx.getEvents).toBeDefined()
|
|
656
|
+
expect(ctx.replay).toBeDefined()
|
|
657
|
+
expect(ctx.createSnapshot).toBeDefined()
|
|
658
|
+
expect(ctx.restoreSnapshot).toBeDefined()
|
|
659
|
+
expect(ctx.getSnapshots).toBeDefined()
|
|
660
|
+
expect(ctx.getEventSequence).toBeDefined()
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('records and retrieves events', async () => {
|
|
664
|
+
await ctx.recordEvent('Test.event', { value: 42 })
|
|
665
|
+
|
|
666
|
+
const storedEvents = await ctx.getEvents()
|
|
667
|
+
expect(storedEvents).toHaveLength(1)
|
|
668
|
+
|
|
669
|
+
const data = JSON.parse(storedEvents[0].data)
|
|
670
|
+
expect(data.value).toBe(42)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('manages actions', async () => {
|
|
674
|
+
await ctx.createAction({
|
|
675
|
+
actor: 'test',
|
|
676
|
+
object: 'Test/1',
|
|
677
|
+
action: 'process',
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
// Action was created (no direct query, but completeAction would fail if not)
|
|
681
|
+
await expect(ctx.completeAction('non-existent', {})).rejects.toThrow()
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it('stores and retrieves artifacts', async () => {
|
|
685
|
+
await ctx.storeArtifact({
|
|
686
|
+
key: 'test-key',
|
|
687
|
+
type: 'bundle',
|
|
688
|
+
sourceHash: 'hash123',
|
|
689
|
+
content: { data: 'test' },
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const artifact = await ctx.getArtifact('test-key')
|
|
693
|
+
expect(artifact).toEqual({ data: 'test' })
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('creates and restores snapshots', async () => {
|
|
697
|
+
const state = { step: 5, data: { items: [1, 2, 3] } }
|
|
698
|
+
const snapshotId = await ctx.createSnapshot(state, 'test-snapshot')
|
|
699
|
+
|
|
700
|
+
const restored = await ctx.restoreSnapshot(snapshotId)
|
|
701
|
+
expect(restored).toEqual(state)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('replays events', async () => {
|
|
705
|
+
await ctx.recordEvent('A', { seq: 1 })
|
|
706
|
+
await ctx.recordEvent('B', { seq: 2 })
|
|
707
|
+
await ctx.recordEvent('C', { seq: 3 })
|
|
708
|
+
|
|
709
|
+
const replayed: number[] = []
|
|
710
|
+
await ctx.replay(async (event, data) => {
|
|
711
|
+
replayed.push((data as { seq: number }).seq)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
expect(replayed).toEqual([1, 2, 3])
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('tracks event sequence', async () => {
|
|
718
|
+
expect(ctx.getEventSequence()).toBe(0)
|
|
719
|
+
|
|
720
|
+
await ctx.recordEvent('Event', {})
|
|
721
|
+
expect(ctx.getEventSequence()).toBe(1)
|
|
722
|
+
})
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
describe('Workflow integration', () => {
|
|
727
|
+
it('DatabaseContext can be used with Workflow options', async () => {
|
|
728
|
+
const ctx = createMemoryDatabaseContext()
|
|
729
|
+
|
|
730
|
+
// This test verifies the type compatibility
|
|
731
|
+
// In real usage:
|
|
732
|
+
// const workflow = Workflow($ => { ... }, { db: ctx })
|
|
733
|
+
|
|
734
|
+
expect(ctx.recordEvent).toBeDefined()
|
|
735
|
+
expect(ctx.createAction).toBeDefined()
|
|
736
|
+
expect(ctx.completeAction).toBeDefined()
|
|
737
|
+
expect(ctx.storeArtifact).toBeDefined()
|
|
738
|
+
expect(ctx.getArtifact).toBeDefined()
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('supports full event sourcing workflow', async () => {
|
|
742
|
+
const ctx = createMemoryDatabaseContext()
|
|
743
|
+
|
|
744
|
+
// Simulate workflow execution with event sourcing
|
|
745
|
+
await ctx.recordEvent('Workflow.started', { input: { orderId: 'order-1' } })
|
|
746
|
+
await ctx.recordEvent('Step.validate.completed', { valid: true })
|
|
747
|
+
await ctx.recordEvent('Step.process.completed', { processed: true })
|
|
748
|
+
await ctx.recordEvent('Workflow.completed', { output: { success: true } })
|
|
749
|
+
|
|
750
|
+
// Create snapshot for recovery
|
|
751
|
+
const snapshotId = await ctx.createSnapshot({ status: 'completed', output: { success: true } })
|
|
752
|
+
|
|
753
|
+
// Replay events to reconstruct state
|
|
754
|
+
const reconstructed: Record<string, unknown> = {}
|
|
755
|
+
await ctx.replay(async (event, data) => {
|
|
756
|
+
if (event === 'Workflow.started') {
|
|
757
|
+
reconstructed.input = (data as { input: unknown }).input
|
|
758
|
+
} else if (event === 'Workflow.completed') {
|
|
759
|
+
reconstructed.output = (data as { output: unknown }).output
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
expect(reconstructed.input).toEqual({ orderId: 'order-1' })
|
|
764
|
+
expect(reconstructed.output).toEqual({ success: true })
|
|
765
|
+
|
|
766
|
+
// Restore from snapshot
|
|
767
|
+
const restored = await ctx.restoreSnapshot(snapshotId)
|
|
768
|
+
expect(restored).toEqual({ status: 'completed', output: { success: true } })
|
|
769
|
+
})
|
|
770
|
+
})
|