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,589 @@
1
+ /**
2
+ * WorkflowStateAdapter - Persistent workflow state using ai-database
3
+ *
4
+ * Provides durable storage for workflow state with:
5
+ * - Optimistic locking via version control
6
+ * - Step checkpoints for recovery
7
+ * - Query by status and IDs
8
+ * - Snapshots for point-in-time recovery
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { DB } from 'ai-database'
13
+ * import { WorkflowStateAdapter } from 'ai-workflows/worker'
14
+ *
15
+ * const { db } = DB({ WorkflowState: { status: 'string' } })
16
+ * const adapter = new WorkflowStateAdapter(db)
17
+ *
18
+ * await adapter.save('wf-123', {
19
+ * workflowId: 'wf-123',
20
+ * status: 'running',
21
+ * currentStep: 'process-payment',
22
+ * context: { orderId: 'order-1' },
23
+ * })
24
+ *
25
+ * const state = await adapter.load('wf-123')
26
+ * ```
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+
31
+ /**
32
+ * Types for persisted workflow state
33
+ */
34
+ export interface PersistedWorkflowState {
35
+ workflowId: string
36
+ version: number
37
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'paused'
38
+ currentStep: string
39
+ context: Record<string, unknown>
40
+ checkpoints: Map<string, StepCheckpoint>
41
+ history: WorkflowHistoryEntry[]
42
+ input?: unknown
43
+ output?: unknown
44
+ error?: string
45
+ createdAt: Date
46
+ updatedAt: Date
47
+ }
48
+
49
+ export interface StepCheckpoint {
50
+ stepId: string
51
+ status: 'pending' | 'running' | 'completed' | 'failed'
52
+ result?: unknown
53
+ error?: string
54
+ attempt: number
55
+ startedAt?: Date
56
+ completedAt?: Date
57
+ }
58
+
59
+ export interface WorkflowHistoryEntry {
60
+ timestamp: number
61
+ type: 'event' | 'schedule' | 'transition' | 'action' | 'checkpoint'
62
+ name: string
63
+ data?: unknown
64
+ }
65
+
66
+ /**
67
+ * Snapshot metadata
68
+ */
69
+ export interface SnapshotInfo {
70
+ id: string
71
+ label?: string
72
+ createdAt: Date
73
+ }
74
+
75
+ /**
76
+ * Database connection interface
77
+ * Compatible with ai-database DB() result or any similar provider
78
+ */
79
+ export interface DatabaseConnection {
80
+ get: (type: string, id: string) => Promise<Record<string, unknown> | null>
81
+ create: (
82
+ type: string,
83
+ data: Record<string, unknown>,
84
+ id?: string
85
+ ) => Promise<Record<string, unknown>>
86
+ update: (
87
+ type: string,
88
+ id: string,
89
+ data: Record<string, unknown>
90
+ ) => Promise<Record<string, unknown>>
91
+ delete: (type: string, id: string) => Promise<boolean>
92
+ list: (
93
+ type: string,
94
+ options?: { limit?: number; offset?: number; where?: Record<string, unknown> }
95
+ ) => Promise<Record<string, unknown>[]>
96
+ search?: (
97
+ type: string,
98
+ query: string,
99
+ options?: Record<string, unknown>
100
+ ) => Promise<Record<string, unknown>[]>
101
+ emit?: (event: string, data: unknown) => Promise<{ id: string }>
102
+ clear?: () => void
103
+ }
104
+
105
+ /**
106
+ * Serialized state format for storage
107
+ * Includes index signature for compatibility with Record<string, unknown>
108
+ */
109
+ interface SerializedState {
110
+ [key: string]: unknown
111
+ $id?: string
112
+ $type?: string
113
+ workflowId: string
114
+ version: number
115
+ status: string
116
+ currentStep: string
117
+ context: string // JSON serialized
118
+ checkpoints: string // JSON serialized Map entries
119
+ history: string // JSON serialized
120
+ input?: string // JSON serialized
121
+ output?: string // JSON serialized
122
+ error?: string
123
+ createdAt: string // ISO string
124
+ updatedAt: string // ISO string
125
+ }
126
+
127
+ /**
128
+ * Serialized snapshot format
129
+ * Includes index signature for compatibility with Record<string, unknown>
130
+ */
131
+ interface SerializedSnapshot {
132
+ [key: string]: unknown
133
+ $id?: string
134
+ $type?: string
135
+ snapshotId: string
136
+ workflowId: string
137
+ label?: string
138
+ state: string // JSON serialized full state
139
+ createdAt: string
140
+ }
141
+
142
+ /**
143
+ * WorkflowStateAdapter - Persists workflow state using ai-database
144
+ *
145
+ * Provides optimistic locking, checkpoints, and snapshot recovery.
146
+ */
147
+ export class WorkflowStateAdapter {
148
+ private db: DatabaseConnection
149
+ private readonly STATE_TYPE = 'WorkflowState'
150
+ private readonly CHECKPOINT_TYPE = 'WorkflowCheckpoint'
151
+ private readonly SNAPSHOT_TYPE = 'WorkflowSnapshot'
152
+
153
+ /**
154
+ * Create a new WorkflowStateAdapter
155
+ *
156
+ * @param database - Database connection (from ai-database DB() or compatible provider)
157
+ * @throws Error if database connection is null/undefined
158
+ */
159
+ constructor(database: DatabaseConnection) {
160
+ if (!database) {
161
+ throw new Error('Database connection is required')
162
+ }
163
+ this.db = database
164
+ }
165
+
166
+ /**
167
+ * Save workflow state to database
168
+ *
169
+ * Creates a new record if not exists, updates existing if found.
170
+ * Automatically increments version and updates timestamps.
171
+ *
172
+ * @param workflowId - Unique workflow identifier
173
+ * @param state - Partial state to save (merged with existing)
174
+ */
175
+ async save(workflowId: string, state: Partial<PersistedWorkflowState>): Promise<void> {
176
+ const existing = await this.loadRaw(workflowId)
177
+ const now = new Date()
178
+
179
+ if (existing) {
180
+ // Update existing state
181
+ const updated = this.mergeState(existing, state)
182
+ updated.version = (existing.version || 0) + 1
183
+ updated.updatedAt = now
184
+
185
+ const serialized = this.serialize(updated)
186
+ await this.db.update(this.STATE_TYPE, workflowId, serialized)
187
+
188
+ // Emit state updated event
189
+ await this.emitEvent('WorkflowState.updated', {
190
+ workflowId,
191
+ version: updated.version,
192
+ status: updated.status,
193
+ })
194
+ } else {
195
+ // Create new state
196
+ const newState: PersistedWorkflowState = {
197
+ workflowId,
198
+ version: state.version ?? 1,
199
+ status: state.status ?? 'pending',
200
+ currentStep: state.currentStep ?? '',
201
+ context: state.context ?? {},
202
+ checkpoints: state.checkpoints ?? new Map(),
203
+ history: state.history ?? [],
204
+ ...(state.input !== undefined && { input: state.input }),
205
+ ...(state.output !== undefined && { output: state.output }),
206
+ ...(state.error !== undefined && { error: state.error }),
207
+ createdAt: now,
208
+ updatedAt: now,
209
+ }
210
+
211
+ const serialized = this.serialize(newState)
212
+ await this.db.create(this.STATE_TYPE, serialized, workflowId)
213
+
214
+ // Emit state created event
215
+ await this.emitEvent('WorkflowState.created', {
216
+ workflowId,
217
+ version: newState.version,
218
+ status: newState.status,
219
+ })
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Load workflow state from database
225
+ *
226
+ * @param workflowId - Workflow identifier to load
227
+ * @returns The persisted state or null if not found
228
+ */
229
+ async load(workflowId: string): Promise<PersistedWorkflowState | null> {
230
+ return this.loadRaw(workflowId)
231
+ }
232
+
233
+ /**
234
+ * Save a step checkpoint
235
+ *
236
+ * Checkpoints are stored as part of the workflow state.
237
+ *
238
+ * @param workflowId - Workflow identifier
239
+ * @param stepId - Step identifier
240
+ * @param checkpoint - Checkpoint data
241
+ */
242
+ async checkpoint(workflowId: string, stepId: string, checkpoint: StepCheckpoint): Promise<void> {
243
+ const state = await this.loadRaw(workflowId)
244
+
245
+ if (!state) {
246
+ // Create minimal state with checkpoint
247
+ await this.save(workflowId, {
248
+ workflowId,
249
+ status: 'running',
250
+ checkpoints: new Map([[stepId, checkpoint]]),
251
+ })
252
+ return
253
+ }
254
+
255
+ // Update checkpoint in existing state
256
+ const checkpoints = state.checkpoints ?? new Map()
257
+ checkpoints.set(stepId, checkpoint)
258
+
259
+ await this.save(workflowId, { checkpoints })
260
+ }
261
+
262
+ /**
263
+ * Get a step checkpoint
264
+ *
265
+ * @param workflowId - Workflow identifier
266
+ * @param stepId - Step identifier
267
+ * @returns The checkpoint or null if not found
268
+ */
269
+ async getCheckpoint(workflowId: string, stepId: string): Promise<StepCheckpoint | null> {
270
+ const state = await this.loadRaw(workflowId)
271
+ if (!state || !state.checkpoints) {
272
+ return null
273
+ }
274
+ return state.checkpoints.get(stepId) ?? null
275
+ }
276
+
277
+ /**
278
+ * Update state with optimistic locking
279
+ *
280
+ * Only updates if the current version matches expectedVersion.
281
+ * Returns false if version mismatch (concurrent modification detected).
282
+ *
283
+ * @param workflowId - Workflow identifier
284
+ * @param expectedVersion - Expected current version
285
+ * @param state - State updates to apply
286
+ * @returns true if updated, false if version mismatch
287
+ */
288
+ async updateWithVersion(
289
+ workflowId: string,
290
+ expectedVersion: number,
291
+ state: Partial<PersistedWorkflowState>
292
+ ): Promise<boolean> {
293
+ const existing = await this.loadRaw(workflowId)
294
+
295
+ if (!existing) {
296
+ return false
297
+ }
298
+
299
+ if (existing.version !== expectedVersion) {
300
+ return false
301
+ }
302
+
303
+ // Version matches, perform update
304
+ await this.save(workflowId, state)
305
+ return true
306
+ }
307
+
308
+ /**
309
+ * Query workflows by status
310
+ *
311
+ * @param status - Status to filter by
312
+ * @returns Array of workflows matching the status
313
+ */
314
+ async queryByStatus(status: PersistedWorkflowState['status']): Promise<PersistedWorkflowState[]> {
315
+ try {
316
+ const results = await this.db.list(this.STATE_TYPE, {
317
+ where: { status },
318
+ })
319
+
320
+ return results.map((r) => this.deserialize(r as unknown as SerializedState))
321
+ } catch {
322
+ // Fallback: list all and filter
323
+ const all = await this.db.list(this.STATE_TYPE)
324
+ return all
325
+ .map((r) => this.deserialize(r as unknown as SerializedState))
326
+ .filter((s) => s.status === status)
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Query workflows by IDs (batch query)
332
+ *
333
+ * @param workflowIds - Array of workflow IDs to query
334
+ * @returns Array of found workflows (non-existent IDs are excluded)
335
+ */
336
+ async queryByIds(workflowIds: string[]): Promise<PersistedWorkflowState[]> {
337
+ const results: PersistedWorkflowState[] = []
338
+
339
+ for (const id of workflowIds) {
340
+ const state = await this.loadRaw(id)
341
+ if (state) {
342
+ results.push(state)
343
+ }
344
+ }
345
+
346
+ return results
347
+ }
348
+
349
+ /**
350
+ * Delete a workflow and its checkpoints
351
+ *
352
+ * @param workflowId - Workflow identifier to delete
353
+ * @returns true if deleted, false if not found
354
+ */
355
+ async delete(workflowId: string): Promise<boolean> {
356
+ const existing = await this.loadRaw(workflowId)
357
+
358
+ if (!existing) {
359
+ return false
360
+ }
361
+
362
+ await this.db.delete(this.STATE_TYPE, workflowId)
363
+
364
+ // Emit deletion event
365
+ await this.emitEvent('WorkflowState.deleted', { workflowId })
366
+
367
+ return true
368
+ }
369
+
370
+ /**
371
+ * List all workflows with pagination
372
+ *
373
+ * @param options - Pagination options (limit, offset)
374
+ * @returns Array of workflows
375
+ */
376
+ async listAll(options?: { limit?: number; offset?: number }): Promise<PersistedWorkflowState[]> {
377
+ const results = await this.db.list(this.STATE_TYPE, {
378
+ ...(options?.limit !== undefined && { limit: options.limit }),
379
+ ...(options?.offset !== undefined && { offset: options.offset }),
380
+ })
381
+
382
+ return results.map((r) => this.deserialize(r as unknown as SerializedState))
383
+ }
384
+
385
+ /**
386
+ * Create a snapshot of current workflow state
387
+ *
388
+ * Snapshots allow point-in-time recovery of workflow state.
389
+ *
390
+ * @param workflowId - Workflow identifier
391
+ * @param label - Optional human-readable label
392
+ * @returns Snapshot ID
393
+ */
394
+ async createSnapshot(workflowId: string, label?: string): Promise<string> {
395
+ const state = await this.loadRaw(workflowId)
396
+
397
+ if (!state) {
398
+ throw new Error(`Workflow "${workflowId}" not found`)
399
+ }
400
+
401
+ const snapshotId = `snap-${workflowId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
402
+
403
+ const snapshot: SerializedSnapshot = {
404
+ snapshotId,
405
+ workflowId,
406
+ ...(label !== undefined && { label }),
407
+ state: JSON.stringify(this.serialize(state)),
408
+ createdAt: new Date().toISOString(),
409
+ }
410
+
411
+ await this.db.create(
412
+ this.SNAPSHOT_TYPE,
413
+ snapshot as unknown as Record<string, unknown>,
414
+ snapshotId
415
+ )
416
+
417
+ return snapshotId
418
+ }
419
+
420
+ /**
421
+ * Restore workflow state from a snapshot
422
+ *
423
+ * @param workflowId - Workflow identifier
424
+ * @param snapshotId - Snapshot ID to restore from
425
+ */
426
+ async restoreSnapshot(workflowId: string, snapshotId: string): Promise<void> {
427
+ const snapshot = (await this.db.get(
428
+ this.SNAPSHOT_TYPE,
429
+ snapshotId
430
+ )) as unknown as SerializedSnapshot | null
431
+
432
+ if (!snapshot) {
433
+ throw new Error(`Snapshot "${snapshotId}" not found`)
434
+ }
435
+
436
+ if (snapshot.workflowId !== workflowId) {
437
+ throw new Error(`Snapshot "${snapshotId}" does not belong to workflow "${workflowId}"`)
438
+ }
439
+
440
+ const restoredState = JSON.parse(snapshot.state) as SerializedState
441
+ const state = this.deserialize(restoredState)
442
+
443
+ // Save restored state with incremented version
444
+ await this.db.update(
445
+ this.STATE_TYPE,
446
+ workflowId,
447
+ this.serialize({
448
+ ...state,
449
+ version: state.version + 1,
450
+ updatedAt: new Date(),
451
+ })
452
+ )
453
+
454
+ // Emit restoration event
455
+ await this.emitEvent('WorkflowState.restored', {
456
+ workflowId,
457
+ snapshotId,
458
+ })
459
+ }
460
+
461
+ /**
462
+ * Get all snapshots for a workflow
463
+ *
464
+ * @param workflowId - Workflow identifier
465
+ * @returns Array of snapshot metadata
466
+ */
467
+ async getSnapshots(workflowId: string): Promise<SnapshotInfo[]> {
468
+ const all = await this.db.list(this.SNAPSHOT_TYPE)
469
+
470
+ return all
471
+ .map((r) => r as unknown as SerializedSnapshot)
472
+ .filter((s) => s.workflowId === workflowId)
473
+ .map((s) => ({
474
+ id: s.snapshotId,
475
+ ...(s.label !== undefined && { label: s.label }),
476
+ createdAt: new Date(s.createdAt),
477
+ }))
478
+ }
479
+
480
+ // ==========================================================================
481
+ // Private helpers
482
+ // ==========================================================================
483
+
484
+ /**
485
+ * Load raw state from database
486
+ */
487
+ private async loadRaw(workflowId: string): Promise<PersistedWorkflowState | null> {
488
+ const data = await this.db.get(this.STATE_TYPE, workflowId)
489
+
490
+ if (!data) {
491
+ return null
492
+ }
493
+
494
+ return this.deserialize(data as unknown as SerializedState)
495
+ }
496
+
497
+ /**
498
+ * Merge partial state into existing state
499
+ */
500
+ private mergeState(
501
+ existing: PersistedWorkflowState,
502
+ updates: Partial<PersistedWorkflowState>
503
+ ): PersistedWorkflowState {
504
+ return {
505
+ ...existing,
506
+ ...updates,
507
+ // Deep merge context
508
+ context: {
509
+ ...existing.context,
510
+ ...(updates.context ?? {}),
511
+ },
512
+ // Merge checkpoints (updates override)
513
+ checkpoints: updates.checkpoints ?? existing.checkpoints,
514
+ // Append history if provided
515
+ history: updates.history ?? existing.history,
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Serialize state for database storage
521
+ */
522
+ private serialize(state: PersistedWorkflowState): SerializedState {
523
+ return {
524
+ workflowId: state.workflowId,
525
+ version: state.version,
526
+ status: state.status,
527
+ currentStep: state.currentStep,
528
+ context: JSON.stringify(state.context),
529
+ checkpoints: JSON.stringify(state.checkpoints ? Array.from(state.checkpoints.entries()) : []),
530
+ history: JSON.stringify(state.history),
531
+ ...(state.input !== undefined && { input: JSON.stringify(state.input) }),
532
+ ...(state.output !== undefined && { output: JSON.stringify(state.output) }),
533
+ ...(state.error !== undefined && { error: state.error }),
534
+ createdAt: state.createdAt.toISOString(),
535
+ updatedAt: state.updatedAt.toISOString(),
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Deserialize state from database format
541
+ */
542
+ private deserialize(data: SerializedState): PersistedWorkflowState {
543
+ let checkpointsMap: Map<string, StepCheckpoint>
544
+
545
+ try {
546
+ const entries = JSON.parse(data.checkpoints || '[]') as Array<[string, StepCheckpoint]>
547
+ checkpointsMap = new Map(
548
+ entries.map(([key, cp]) => [
549
+ key,
550
+ {
551
+ ...cp,
552
+ ...(cp.startedAt !== undefined && { startedAt: new Date(cp.startedAt) }),
553
+ ...(cp.completedAt !== undefined && { completedAt: new Date(cp.completedAt) }),
554
+ },
555
+ ])
556
+ )
557
+ } catch {
558
+ checkpointsMap = new Map()
559
+ }
560
+
561
+ return {
562
+ workflowId: data.workflowId,
563
+ version: data.version,
564
+ status: data.status as PersistedWorkflowState['status'],
565
+ currentStep: data.currentStep,
566
+ context: JSON.parse(data.context || '{}'),
567
+ checkpoints: checkpointsMap,
568
+ history: JSON.parse(data.history || '[]'),
569
+ ...(data.input !== undefined && { input: JSON.parse(data.input) }),
570
+ ...(data.output !== undefined && { output: JSON.parse(data.output) }),
571
+ ...(data.error !== undefined && { error: data.error }),
572
+ createdAt: new Date(data.createdAt),
573
+ updatedAt: new Date(data.updatedAt),
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Emit an event to the database (if supported)
579
+ */
580
+ private async emitEvent(event: string, data: unknown): Promise<void> {
581
+ if (this.db.emit) {
582
+ try {
583
+ await this.db.emit(event, data)
584
+ } catch {
585
+ // Event emission is best-effort
586
+ }
587
+ }
588
+ }
589
+ }