ai-workflows 2.1.3 → 2.4.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 (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. 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
+ })