ai-workflows 2.1.3 → 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 +8 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- 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/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/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -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.map +1 -1
- package/dist/on.js +3 -3
- 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 +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -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 +132 -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 +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -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/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- 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 +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- 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/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/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 +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- 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 -169
- package/LICENSE +0 -21
- 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,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowStateAdapter Tests (GREEN Phase)
|
|
3
|
+
*
|
|
4
|
+
* Tests for state persistence using ai-database integration.
|
|
5
|
+
*
|
|
6
|
+
* ## Test Categories
|
|
7
|
+
* 1. Basic state persistence (save/load)
|
|
8
|
+
* 2. Optimistic locking (version control)
|
|
9
|
+
* 3. Step checkpoints
|
|
10
|
+
* 4. State queries (by ID, by status)
|
|
11
|
+
* 5. Concurrent state updates
|
|
12
|
+
* 6. WorkflowService integration
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
16
|
+
import { WorkflowStateAdapter } from '../src/worker/state-adapter.js'
|
|
17
|
+
import type {
|
|
18
|
+
PersistedWorkflowState,
|
|
19
|
+
StepCheckpoint,
|
|
20
|
+
WorkflowHistoryEntry,
|
|
21
|
+
DatabaseConnection,
|
|
22
|
+
} from '../src/worker/state-adapter.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* In-memory database implementation for testing
|
|
26
|
+
*/
|
|
27
|
+
class MemoryDatabase implements DatabaseConnection {
|
|
28
|
+
private stores = new Map<string, Map<string, Record<string, 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
|
+
// Apply where filter
|
|
79
|
+
if (options?.where) {
|
|
80
|
+
results = results.filter((record) => {
|
|
81
|
+
for (const [key, value] of Object.entries(options.where!)) {
|
|
82
|
+
if (record[key] !== value) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Apply offset
|
|
91
|
+
if (options?.offset) {
|
|
92
|
+
results = results.slice(options.offset)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Apply limit
|
|
96
|
+
if (options?.limit) {
|
|
97
|
+
results = results.slice(0, options.limit)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return results
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async emit(event: string, data: unknown): Promise<{ id: string }> {
|
|
104
|
+
// Just return a mock event ID for testing
|
|
105
|
+
return { id: `event-${Date.now()}` }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
clear(): void {
|
|
109
|
+
this.stores.clear()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('WorkflowStateAdapter', () => {
|
|
114
|
+
let db: MemoryDatabase
|
|
115
|
+
let adapter: WorkflowStateAdapter
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
db = new MemoryDatabase()
|
|
119
|
+
adapter = new WorkflowStateAdapter(db)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('construction', () => {
|
|
123
|
+
it('creates adapter with database connection', () => {
|
|
124
|
+
const adapter = new WorkflowStateAdapter(db)
|
|
125
|
+
expect(adapter).toBeDefined()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('requires a database connection', () => {
|
|
129
|
+
expect(() => {
|
|
130
|
+
new WorkflowStateAdapter(null as unknown as DatabaseConnection)
|
|
131
|
+
}).toThrow('Database connection is required')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('save() - persists workflow state', () => {
|
|
136
|
+
it('saves new workflow state to database', async () => {
|
|
137
|
+
const state: Partial<PersistedWorkflowState> = {
|
|
138
|
+
workflowId: 'wf-123',
|
|
139
|
+
status: 'pending',
|
|
140
|
+
currentStep: 'start',
|
|
141
|
+
context: { userId: '456' },
|
|
142
|
+
checkpoints: new Map(),
|
|
143
|
+
history: [],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await adapter.save('wf-123', state)
|
|
147
|
+
|
|
148
|
+
const loaded = await adapter.load('wf-123')
|
|
149
|
+
expect(loaded).toBeDefined()
|
|
150
|
+
expect(loaded?.workflowId).toBe('wf-123')
|
|
151
|
+
expect(loaded?.status).toBe('pending')
|
|
152
|
+
expect(loaded?.context).toEqual({ userId: '456' })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('updates existing workflow state', async () => {
|
|
156
|
+
// First save
|
|
157
|
+
await adapter.save('wf-123', {
|
|
158
|
+
workflowId: 'wf-123',
|
|
159
|
+
status: 'pending',
|
|
160
|
+
currentStep: 'start',
|
|
161
|
+
context: {},
|
|
162
|
+
checkpoints: new Map(),
|
|
163
|
+
history: [],
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Update
|
|
167
|
+
await adapter.save('wf-123', {
|
|
168
|
+
status: 'running',
|
|
169
|
+
currentStep: 'step-1',
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const loaded = await adapter.load('wf-123')
|
|
173
|
+
expect(loaded?.status).toBe('running')
|
|
174
|
+
expect(loaded?.currentStep).toBe('step-1')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('persists input and output data', async () => {
|
|
178
|
+
await adapter.save('wf-123', {
|
|
179
|
+
workflowId: 'wf-123',
|
|
180
|
+
status: 'completed',
|
|
181
|
+
input: { orderId: 'order-1' },
|
|
182
|
+
output: { success: true, total: 100 },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const loaded = await adapter.load('wf-123')
|
|
186
|
+
expect(loaded?.input).toEqual({ orderId: 'order-1' })
|
|
187
|
+
expect(loaded?.output).toEqual({ success: true, total: 100 })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('persists error information', async () => {
|
|
191
|
+
await adapter.save('wf-123', {
|
|
192
|
+
workflowId: 'wf-123',
|
|
193
|
+
status: 'failed',
|
|
194
|
+
error: 'Step execution failed: timeout exceeded',
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const loaded = await adapter.load('wf-123')
|
|
198
|
+
expect(loaded?.status).toBe('failed')
|
|
199
|
+
expect(loaded?.error).toBe('Step execution failed: timeout exceeded')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('automatically increments version on save', async () => {
|
|
203
|
+
await adapter.save('wf-123', {
|
|
204
|
+
workflowId: 'wf-123',
|
|
205
|
+
version: 1,
|
|
206
|
+
status: 'pending',
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
await adapter.save('wf-123', {
|
|
210
|
+
status: 'running',
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const loaded = await adapter.load('wf-123')
|
|
214
|
+
// Version should be incremented
|
|
215
|
+
expect(loaded?.version).toBe(2)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('sets createdAt on first save', async () => {
|
|
219
|
+
const beforeSave = new Date()
|
|
220
|
+
await adapter.save('wf-123', {
|
|
221
|
+
workflowId: 'wf-123',
|
|
222
|
+
status: 'pending',
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const loaded = await adapter.load('wf-123')
|
|
226
|
+
expect(loaded?.createdAt).toBeDefined()
|
|
227
|
+
expect(loaded?.createdAt.getTime()).toBeGreaterThanOrEqual(beforeSave.getTime())
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('updates updatedAt on every save', async () => {
|
|
231
|
+
await adapter.save('wf-123', {
|
|
232
|
+
workflowId: 'wf-123',
|
|
233
|
+
status: 'pending',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const firstLoad = await adapter.load('wf-123')
|
|
237
|
+
const firstUpdatedAt = firstLoad?.updatedAt
|
|
238
|
+
|
|
239
|
+
// Wait a bit and update
|
|
240
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
241
|
+
|
|
242
|
+
await adapter.save('wf-123', {
|
|
243
|
+
status: 'running',
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const secondLoad = await adapter.load('wf-123')
|
|
247
|
+
expect(secondLoad?.updatedAt.getTime()).toBeGreaterThan(firstUpdatedAt!.getTime())
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('load() - retrieves workflow state', () => {
|
|
252
|
+
it('loads existing workflow state', async () => {
|
|
253
|
+
await adapter.save('wf-123', {
|
|
254
|
+
workflowId: 'wf-123',
|
|
255
|
+
status: 'running',
|
|
256
|
+
currentStep: 'step-1',
|
|
257
|
+
context: { count: 42 },
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const loaded = await adapter.load('wf-123')
|
|
261
|
+
expect(loaded).toBeDefined()
|
|
262
|
+
expect(loaded?.workflowId).toBe('wf-123')
|
|
263
|
+
expect(loaded?.status).toBe('running')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns null for non-existent workflow', async () => {
|
|
267
|
+
const loaded = await adapter.load('non-existent')
|
|
268
|
+
expect(loaded).toBeNull()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('deserializes checkpoints map correctly', async () => {
|
|
272
|
+
// Save state with checkpoints
|
|
273
|
+
await adapter.save('wf-123', {
|
|
274
|
+
workflowId: 'wf-123',
|
|
275
|
+
status: 'running',
|
|
276
|
+
checkpoints: new Map([
|
|
277
|
+
['step-1', { stepId: 'step-1', status: 'completed', result: { done: true }, attempt: 1 }],
|
|
278
|
+
['step-2', { stepId: 'step-2', status: 'running', attempt: 1 }],
|
|
279
|
+
]),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const loaded = await adapter.load('wf-123')
|
|
283
|
+
expect(loaded?.checkpoints).toBeInstanceOf(Map)
|
|
284
|
+
expect(loaded?.checkpoints.get('step-1')?.status).toBe('completed')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('deserializes history array correctly', async () => {
|
|
288
|
+
const history: WorkflowHistoryEntry[] = [
|
|
289
|
+
{ timestamp: Date.now(), type: 'event', name: 'Customer.created', data: { id: '1' } },
|
|
290
|
+
{ timestamp: Date.now(), type: 'transition', name: 'pending -> running' },
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
await adapter.save('wf-123', {
|
|
294
|
+
workflowId: 'wf-123',
|
|
295
|
+
status: 'running',
|
|
296
|
+
history,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const loaded = await adapter.load('wf-123')
|
|
300
|
+
expect(loaded?.history).toHaveLength(2)
|
|
301
|
+
expect(loaded?.history[0].type).toBe('event')
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('checkpoint() - saves step execution state', () => {
|
|
306
|
+
it('saves step checkpoint', async () => {
|
|
307
|
+
const checkpoint: StepCheckpoint = {
|
|
308
|
+
stepId: 'process-payment',
|
|
309
|
+
status: 'completed',
|
|
310
|
+
result: { transactionId: 'tx-123' },
|
|
311
|
+
attempt: 1,
|
|
312
|
+
startedAt: new Date(),
|
|
313
|
+
completedAt: new Date(),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await adapter.checkpoint('wf-123', 'process-payment', checkpoint)
|
|
317
|
+
|
|
318
|
+
const loaded = await adapter.getCheckpoint('wf-123', 'process-payment')
|
|
319
|
+
expect(loaded).toBeDefined()
|
|
320
|
+
expect(loaded?.status).toBe('completed')
|
|
321
|
+
expect(loaded?.result).toEqual({ transactionId: 'tx-123' })
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('updates existing checkpoint', async () => {
|
|
325
|
+
// Initial checkpoint (started)
|
|
326
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
327
|
+
stepId: 'step-1',
|
|
328
|
+
status: 'running',
|
|
329
|
+
attempt: 1,
|
|
330
|
+
startedAt: new Date(),
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// Update checkpoint (completed)
|
|
334
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
335
|
+
stepId: 'step-1',
|
|
336
|
+
status: 'completed',
|
|
337
|
+
result: { success: true },
|
|
338
|
+
attempt: 1,
|
|
339
|
+
completedAt: new Date(),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const loaded = await adapter.getCheckpoint('wf-123', 'step-1')
|
|
343
|
+
expect(loaded?.status).toBe('completed')
|
|
344
|
+
expect(loaded?.result).toEqual({ success: true })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('tracks retry attempts in checkpoint', async () => {
|
|
348
|
+
// First attempt (failed)
|
|
349
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
350
|
+
stepId: 'step-1',
|
|
351
|
+
status: 'failed',
|
|
352
|
+
error: 'Network error',
|
|
353
|
+
attempt: 1,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// Second attempt
|
|
357
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
358
|
+
stepId: 'step-1',
|
|
359
|
+
status: 'running',
|
|
360
|
+
attempt: 2,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'step-1')
|
|
364
|
+
expect(checkpoint?.attempt).toBe(2)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('getCheckpoint() - retrieves step checkpoint', () => {
|
|
369
|
+
it('retrieves existing checkpoint', async () => {
|
|
370
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
371
|
+
stepId: 'step-1',
|
|
372
|
+
status: 'completed',
|
|
373
|
+
result: { data: 'test' },
|
|
374
|
+
attempt: 1,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'step-1')
|
|
378
|
+
expect(checkpoint).toBeDefined()
|
|
379
|
+
expect(checkpoint?.result).toEqual({ data: 'test' })
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('returns null for non-existent checkpoint', async () => {
|
|
383
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'non-existent')
|
|
384
|
+
expect(checkpoint).toBeNull()
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('updateWithVersion() - optimistic locking', () => {
|
|
389
|
+
it('updates state when version matches', async () => {
|
|
390
|
+
await adapter.save('wf-123', {
|
|
391
|
+
workflowId: 'wf-123',
|
|
392
|
+
status: 'pending',
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
const result = await adapter.updateWithVersion('wf-123', 1, {
|
|
396
|
+
status: 'running',
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
expect(result).toBe(true)
|
|
400
|
+
|
|
401
|
+
const loaded = await adapter.load('wf-123')
|
|
402
|
+
expect(loaded?.status).toBe('running')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('returns false when version does not match (optimistic lock failure)', async () => {
|
|
406
|
+
// First save creates version 1
|
|
407
|
+
await adapter.save('wf-123', {
|
|
408
|
+
workflowId: 'wf-123',
|
|
409
|
+
status: 'pending',
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Concurrent update with wrong version
|
|
413
|
+
const result = await adapter.updateWithVersion('wf-123', 99, {
|
|
414
|
+
status: 'running',
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
expect(result).toBe(false)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('increments version on successful update', async () => {
|
|
421
|
+
await adapter.save('wf-123', {
|
|
422
|
+
workflowId: 'wf-123',
|
|
423
|
+
version: 1,
|
|
424
|
+
status: 'pending',
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
await adapter.updateWithVersion('wf-123', 1, {
|
|
428
|
+
status: 'running',
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
const loaded = await adapter.load('wf-123')
|
|
432
|
+
expect(loaded?.version).toBe(2)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('does not update state when version mismatch', async () => {
|
|
436
|
+
await adapter.save('wf-123', {
|
|
437
|
+
workflowId: 'wf-123',
|
|
438
|
+
version: 1,
|
|
439
|
+
status: 'pending',
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
await adapter.updateWithVersion('wf-123', 99, {
|
|
443
|
+
status: 'failed',
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const loaded = await adapter.load('wf-123')
|
|
447
|
+
expect(loaded?.status).toBe('pending') // Should not change
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
describe('queryByStatus() - state queries', () => {
|
|
452
|
+
it('returns workflows matching status', async () => {
|
|
453
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'running' })
|
|
454
|
+
await adapter.save('wf-2', { workflowId: 'wf-2', status: 'pending' })
|
|
455
|
+
|
|
456
|
+
const running = await adapter.queryByStatus('running')
|
|
457
|
+
expect(running).toHaveLength(1)
|
|
458
|
+
expect(running[0].workflowId).toBe('wf-1')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('returns empty array when no workflows match', async () => {
|
|
462
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'running' })
|
|
463
|
+
|
|
464
|
+
const results = await adapter.queryByStatus('failed')
|
|
465
|
+
expect(results).toEqual([])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('returns all workflows with pending status', async () => {
|
|
469
|
+
// Create multiple workflows
|
|
470
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'pending' })
|
|
471
|
+
await adapter.save('wf-2', { workflowId: 'wf-2', status: 'running' })
|
|
472
|
+
await adapter.save('wf-3', { workflowId: 'wf-3', status: 'pending' })
|
|
473
|
+
|
|
474
|
+
const pending = await adapter.queryByStatus('pending')
|
|
475
|
+
expect(pending).toHaveLength(2)
|
|
476
|
+
expect(pending.map((w) => w.workflowId)).toContain('wf-1')
|
|
477
|
+
expect(pending.map((w) => w.workflowId)).toContain('wf-3')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('returns workflows with completed status', async () => {
|
|
481
|
+
await adapter.save('wf-1', {
|
|
482
|
+
workflowId: 'wf-1',
|
|
483
|
+
status: 'completed',
|
|
484
|
+
output: { result: 'done' },
|
|
485
|
+
})
|
|
486
|
+
await adapter.save('wf-2', { workflowId: 'wf-2', status: 'running' })
|
|
487
|
+
|
|
488
|
+
const completed = await adapter.queryByStatus('completed')
|
|
489
|
+
expect(completed).toHaveLength(1)
|
|
490
|
+
expect(completed[0].output).toEqual({ result: 'done' })
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
describe('queryByIds() - batch queries', () => {
|
|
495
|
+
it('returns workflows matching IDs', async () => {
|
|
496
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'pending' })
|
|
497
|
+
await adapter.save('wf-2', { workflowId: 'wf-2', status: 'running' })
|
|
498
|
+
|
|
499
|
+
const results = await adapter.queryByIds(['wf-1', 'wf-2'])
|
|
500
|
+
expect(results).toHaveLength(2)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('returns only existing workflows', async () => {
|
|
504
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'pending' })
|
|
505
|
+
await adapter.save('wf-3', { workflowId: 'wf-3', status: 'running' })
|
|
506
|
+
|
|
507
|
+
const results = await adapter.queryByIds(['wf-1', 'wf-2', 'wf-3'])
|
|
508
|
+
expect(results).toHaveLength(2)
|
|
509
|
+
expect(results.map((w) => w.workflowId)).toContain('wf-1')
|
|
510
|
+
expect(results.map((w) => w.workflowId)).toContain('wf-3')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('returns empty array for non-existent IDs', async () => {
|
|
514
|
+
const results = await adapter.queryByIds(['non-1', 'non-2'])
|
|
515
|
+
expect(results).toEqual([])
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
describe('delete() - removes workflow state', () => {
|
|
520
|
+
it('deletes existing workflow state', async () => {
|
|
521
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'completed' })
|
|
522
|
+
|
|
523
|
+
const result = await adapter.delete('wf-123')
|
|
524
|
+
expect(result).toBe(true)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('returns true when workflow is deleted', async () => {
|
|
528
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'completed' })
|
|
529
|
+
|
|
530
|
+
const result = await adapter.delete('wf-123')
|
|
531
|
+
expect(result).toBe(true)
|
|
532
|
+
|
|
533
|
+
const loaded = await adapter.load('wf-123')
|
|
534
|
+
expect(loaded).toBeNull()
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('returns false when workflow does not exist', async () => {
|
|
538
|
+
const result = await adapter.delete('non-existent')
|
|
539
|
+
expect(result).toBe(false)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('deletes associated checkpoints', async () => {
|
|
543
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'running' })
|
|
544
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
545
|
+
stepId: 'step-1',
|
|
546
|
+
status: 'completed',
|
|
547
|
+
attempt: 1,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
await adapter.delete('wf-123')
|
|
551
|
+
|
|
552
|
+
// The checkpoint is stored within the workflow state, so deleting workflow removes checkpoints
|
|
553
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'step-1')
|
|
554
|
+
expect(checkpoint).toBeNull()
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
describe('listAll() - pagination', () => {
|
|
559
|
+
it('lists all workflows with pagination', async () => {
|
|
560
|
+
for (let i = 1; i <= 5; i++) {
|
|
561
|
+
await adapter.save(`wf-${i}`, { workflowId: `wf-${i}`, status: 'pending' })
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const results = await adapter.listAll({ limit: 10, offset: 0 })
|
|
565
|
+
expect(results).toHaveLength(5)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('respects limit parameter', async () => {
|
|
569
|
+
// Create 5 workflows
|
|
570
|
+
for (let i = 1; i <= 5; i++) {
|
|
571
|
+
await adapter.save(`wf-${i}`, { workflowId: `wf-${i}`, status: 'pending' })
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const results = await adapter.listAll({ limit: 3 })
|
|
575
|
+
expect(results).toHaveLength(3)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('respects offset parameter', async () => {
|
|
579
|
+
// Create 5 workflows
|
|
580
|
+
for (let i = 1; i <= 5; i++) {
|
|
581
|
+
await adapter.save(`wf-${i}`, { workflowId: `wf-${i}`, status: 'pending' })
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const results = await adapter.listAll({ limit: 3, offset: 2 })
|
|
585
|
+
expect(results).toHaveLength(3)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it('returns empty array when offset exceeds count', async () => {
|
|
589
|
+
await adapter.save('wf-1', { workflowId: 'wf-1', status: 'pending' })
|
|
590
|
+
|
|
591
|
+
const results = await adapter.listAll({ offset: 100 })
|
|
592
|
+
expect(results).toEqual([])
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe('concurrent state updates', () => {
|
|
597
|
+
it('handles concurrent saves with optimistic locking', async () => {
|
|
598
|
+
await adapter.save('wf-123', {
|
|
599
|
+
workflowId: 'wf-123',
|
|
600
|
+
version: 1,
|
|
601
|
+
status: 'pending',
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
// Simulate sequential concurrent updates (one after the other)
|
|
605
|
+
// First update should succeed
|
|
606
|
+
const result1 = await adapter.updateWithVersion('wf-123', 1, { status: 'running' })
|
|
607
|
+
expect(result1).toBe(true)
|
|
608
|
+
|
|
609
|
+
// Second update with stale version should fail
|
|
610
|
+
const result2 = await adapter.updateWithVersion('wf-123', 1, { status: 'paused' })
|
|
611
|
+
expect(result2).toBe(false)
|
|
612
|
+
|
|
613
|
+
// Verify state is 'running' from first update
|
|
614
|
+
const state = await adapter.load('wf-123')
|
|
615
|
+
expect(state?.status).toBe('running')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('concurrent checkpoints for different steps succeed', async () => {
|
|
619
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'running' })
|
|
620
|
+
|
|
621
|
+
// Sequential checkpoints (in-memory db doesn't support true concurrency)
|
|
622
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
623
|
+
stepId: 'step-1',
|
|
624
|
+
status: 'completed',
|
|
625
|
+
attempt: 1,
|
|
626
|
+
})
|
|
627
|
+
await adapter.checkpoint('wf-123', 'step-2', {
|
|
628
|
+
stepId: 'step-2',
|
|
629
|
+
status: 'completed',
|
|
630
|
+
attempt: 1,
|
|
631
|
+
})
|
|
632
|
+
await adapter.checkpoint('wf-123', 'step-3', {
|
|
633
|
+
stepId: 'step-3',
|
|
634
|
+
status: 'completed',
|
|
635
|
+
attempt: 1,
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
const state = await adapter.load('wf-123')
|
|
639
|
+
expect(state?.checkpoints.size).toBe(3)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('maintains consistency under concurrent operations', async () => {
|
|
643
|
+
await adapter.save('wf-123', {
|
|
644
|
+
workflowId: 'wf-123',
|
|
645
|
+
version: 1,
|
|
646
|
+
status: 'pending',
|
|
647
|
+
context: { counter: 0 },
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
// Multiple concurrent increments (simulated)
|
|
651
|
+
const operations = Array.from({ length: 10 }, async (_, i) => {
|
|
652
|
+
const state = await adapter.load('wf-123')
|
|
653
|
+
if (state) {
|
|
654
|
+
const newCounter = ((state.context.counter as number) || 0) + 1
|
|
655
|
+
return adapter.updateWithVersion('wf-123', state.version, {
|
|
656
|
+
context: { counter: newCounter },
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
return false
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
const results = await Promise.all(operations)
|
|
663
|
+
|
|
664
|
+
// Only one should succeed per version
|
|
665
|
+
const successCount = results.filter((r) => r === true).length
|
|
666
|
+
expect(successCount).toBeGreaterThanOrEqual(1)
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
describe('snapshots/checkpoints for recovery', () => {
|
|
671
|
+
it('creates a snapshot of current state', async () => {
|
|
672
|
+
await adapter.save('wf-123', {
|
|
673
|
+
workflowId: 'wf-123',
|
|
674
|
+
status: 'running',
|
|
675
|
+
context: { step: 3, data: { processed: true } },
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
const snapshotId = await adapter.createSnapshot('wf-123', 'before-risky-step')
|
|
679
|
+
expect(snapshotId).toBeDefined()
|
|
680
|
+
expect(snapshotId).toContain('snap-wf-123')
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('restores state from snapshot', async () => {
|
|
684
|
+
await adapter.save('wf-123', {
|
|
685
|
+
workflowId: 'wf-123',
|
|
686
|
+
status: 'running',
|
|
687
|
+
context: { step: 3 },
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
const snapshotId = await adapter.createSnapshot('wf-123', 'checkpoint-1')
|
|
691
|
+
|
|
692
|
+
// Modify state
|
|
693
|
+
await adapter.save('wf-123', {
|
|
694
|
+
status: 'failed',
|
|
695
|
+
context: { step: 5, error: true },
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
// Restore from snapshot
|
|
699
|
+
await adapter.restoreSnapshot('wf-123', snapshotId)
|
|
700
|
+
|
|
701
|
+
const restored = await adapter.load('wf-123')
|
|
702
|
+
expect(restored?.status).toBe('running')
|
|
703
|
+
expect(restored?.context.step).toBe(3)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it('lists available snapshots', async () => {
|
|
707
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'running' })
|
|
708
|
+
|
|
709
|
+
await adapter.createSnapshot('wf-123', 'snapshot-1')
|
|
710
|
+
await adapter.createSnapshot('wf-123', 'snapshot-2')
|
|
711
|
+
|
|
712
|
+
const snapshots = await adapter.getSnapshots('wf-123')
|
|
713
|
+
expect(snapshots).toHaveLength(2)
|
|
714
|
+
expect(snapshots.some((s) => s.label === 'snapshot-1')).toBe(true)
|
|
715
|
+
expect(snapshots.some((s) => s.label === 'snapshot-2')).toBe(true)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('snapshot preserves checkpoints', async () => {
|
|
719
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'running' })
|
|
720
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
721
|
+
stepId: 'step-1',
|
|
722
|
+
status: 'completed',
|
|
723
|
+
result: { data: 'important' },
|
|
724
|
+
attempt: 1,
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
const snapshotId = await adapter.createSnapshot('wf-123')
|
|
728
|
+
|
|
729
|
+
// Clear checkpoints
|
|
730
|
+
await adapter.save('wf-123', {
|
|
731
|
+
checkpoints: new Map(),
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
await adapter.restoreSnapshot('wf-123', snapshotId)
|
|
735
|
+
|
|
736
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'step-1')
|
|
737
|
+
expect(checkpoint?.result).toEqual({ data: 'important' })
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
describe('state survives workflow restart', () => {
|
|
742
|
+
it('persisted state is recoverable after restart', async () => {
|
|
743
|
+
// Simulate workflow execution
|
|
744
|
+
await adapter.save('wf-123', {
|
|
745
|
+
workflowId: 'wf-123',
|
|
746
|
+
status: 'running',
|
|
747
|
+
currentStep: 'step-2',
|
|
748
|
+
context: { processedItems: 50 },
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
await adapter.checkpoint('wf-123', 'step-1', {
|
|
752
|
+
stepId: 'step-1',
|
|
753
|
+
status: 'completed',
|
|
754
|
+
result: { items: 100 },
|
|
755
|
+
attempt: 1,
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
// Simulate restart - create new adapter instance (same db)
|
|
759
|
+
const newAdapter = new WorkflowStateAdapter(db)
|
|
760
|
+
|
|
761
|
+
// Load state should work with new instance
|
|
762
|
+
const state = await newAdapter.load('wf-123')
|
|
763
|
+
expect(state?.currentStep).toBe('step-2')
|
|
764
|
+
expect(state?.context.processedItems).toBe(50)
|
|
765
|
+
|
|
766
|
+
const checkpoint = await newAdapter.getCheckpoint('wf-123', 'step-1')
|
|
767
|
+
expect(checkpoint?.status).toBe('completed')
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('history is preserved across restarts', async () => {
|
|
771
|
+
const history: WorkflowHistoryEntry[] = [
|
|
772
|
+
{ timestamp: Date.now() - 1000, type: 'event', name: 'Order.created' },
|
|
773
|
+
{ timestamp: Date.now() - 500, type: 'transition', name: 'pending -> processing' },
|
|
774
|
+
{ timestamp: Date.now(), type: 'checkpoint', name: 'step-1-completed' },
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
await adapter.save('wf-123', {
|
|
778
|
+
workflowId: 'wf-123',
|
|
779
|
+
status: 'running',
|
|
780
|
+
history,
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
// New adapter instance
|
|
784
|
+
const newAdapter = new WorkflowStateAdapter(db)
|
|
785
|
+
|
|
786
|
+
const state = await newAdapter.load('wf-123')
|
|
787
|
+
expect(state?.history).toHaveLength(3)
|
|
788
|
+
expect(state?.history.map((h) => h.type)).toEqual(['event', 'transition', 'checkpoint'])
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
describe('WorkflowService integration', () => {
|
|
793
|
+
it('adapter can be used with WorkflowServiceCore', async () => {
|
|
794
|
+
// Verify adapter can be instantiated with a database connection
|
|
795
|
+
const adapter = new WorkflowStateAdapter(db)
|
|
796
|
+
expect(adapter).toBeDefined()
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('state changes are persisted during workflow execution', async () => {
|
|
800
|
+
// Simulate workflow lifecycle
|
|
801
|
+
// 1. Create workflow
|
|
802
|
+
await adapter.save('wf-123', {
|
|
803
|
+
workflowId: 'wf-123',
|
|
804
|
+
status: 'pending',
|
|
805
|
+
input: { orderId: 'order-1' },
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
// 2. Start workflow
|
|
809
|
+
await adapter.save('wf-123', {
|
|
810
|
+
status: 'running',
|
|
811
|
+
currentStep: 'validate',
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
// 3. Checkpoint step completion
|
|
815
|
+
await adapter.checkpoint('wf-123', 'validate', {
|
|
816
|
+
stepId: 'validate',
|
|
817
|
+
status: 'completed',
|
|
818
|
+
result: { valid: true },
|
|
819
|
+
attempt: 1,
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
// 4. Complete workflow
|
|
823
|
+
await adapter.save('wf-123', {
|
|
824
|
+
status: 'completed',
|
|
825
|
+
output: { success: true },
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
// Verify final state
|
|
829
|
+
const state = await adapter.load('wf-123')
|
|
830
|
+
expect(state?.status).toBe('completed')
|
|
831
|
+
expect(state?.input).toEqual({ orderId: 'order-1' })
|
|
832
|
+
expect(state?.output).toEqual({ success: true })
|
|
833
|
+
|
|
834
|
+
const checkpoint = await adapter.getCheckpoint('wf-123', 'validate')
|
|
835
|
+
expect(checkpoint?.status).toBe('completed')
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it('events are recorded in history', async () => {
|
|
839
|
+
await adapter.save('wf-123', {
|
|
840
|
+
workflowId: 'wf-123',
|
|
841
|
+
status: 'running',
|
|
842
|
+
history: [],
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
// Record events in history
|
|
846
|
+
const state = await adapter.load('wf-123')
|
|
847
|
+
const history = state?.history || []
|
|
848
|
+
history.push({
|
|
849
|
+
timestamp: Date.now(),
|
|
850
|
+
type: 'event',
|
|
851
|
+
name: 'Customer.created',
|
|
852
|
+
data: { customerId: 'cust-1' },
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
await adapter.save('wf-123', { history })
|
|
856
|
+
|
|
857
|
+
const updated = await adapter.load('wf-123')
|
|
858
|
+
expect(updated?.history).toHaveLength(1)
|
|
859
|
+
expect(updated?.history[0].name).toBe('Customer.created')
|
|
860
|
+
})
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
describe('ai-database event sourcing integration', () => {
|
|
864
|
+
it('emits events on state changes', async () => {
|
|
865
|
+
// The adapter should emit events to ai-database when state changes
|
|
866
|
+
await adapter.save('wf-123', {
|
|
867
|
+
workflowId: 'wf-123',
|
|
868
|
+
status: 'running',
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
// Verify event was emitted (implementation detail)
|
|
872
|
+
// In real implementation, we'd verify:
|
|
873
|
+
// - WorkflowState.created event on first save
|
|
874
|
+
// - WorkflowState.updated event on subsequent saves
|
|
875
|
+
// - WorkflowState.statusChanged event on status changes
|
|
876
|
+
const loaded = await adapter.load('wf-123')
|
|
877
|
+
expect(loaded?.status).toBe('running')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it('records state changes as immutable events', async () => {
|
|
881
|
+
// Save multiple state changes
|
|
882
|
+
await adapter.save('wf-123', { workflowId: 'wf-123', status: 'pending' })
|
|
883
|
+
await adapter.save('wf-123', { status: 'running' })
|
|
884
|
+
await adapter.save('wf-123', { status: 'completed' })
|
|
885
|
+
|
|
886
|
+
// The adapter should have recorded these as events in ai-database
|
|
887
|
+
// Events are immutable and can be replayed to reconstruct state
|
|
888
|
+
const loaded = await adapter.load('wf-123')
|
|
889
|
+
expect(loaded?.status).toBe('completed')
|
|
890
|
+
expect(loaded?.version).toBe(3) // 3 saves = version 3
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('supports event replay for state reconstruction', async () => {
|
|
894
|
+
// This is a conceptual test - the adapter should support
|
|
895
|
+
// reconstructing state from event history in ai-database
|
|
896
|
+
|
|
897
|
+
await adapter.save('wf-123', {
|
|
898
|
+
workflowId: 'wf-123',
|
|
899
|
+
status: 'running',
|
|
900
|
+
context: { step: 1 },
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
await adapter.save('wf-123', {
|
|
904
|
+
context: { step: 2 },
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
await adapter.save('wf-123', {
|
|
908
|
+
context: { step: 3 },
|
|
909
|
+
status: 'completed',
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
// Events recorded would be:
|
|
913
|
+
// 1. WorkflowState.created { status: 'running', context: { step: 1 } }
|
|
914
|
+
// 2. WorkflowState.updated { context: { step: 2 } }
|
|
915
|
+
// 3. WorkflowState.completed { context: { step: 3 } }
|
|
916
|
+
|
|
917
|
+
// Replaying these events should reconstruct the final state
|
|
918
|
+
const state = await adapter.load('wf-123')
|
|
919
|
+
expect(state?.context.step).toBe(3)
|
|
920
|
+
expect(state?.status).toBe('completed')
|
|
921
|
+
})
|
|
922
|
+
})
|
|
923
|
+
})
|