@multiplier-labs/stepflow 0.3.1

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 (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,1662 @@
1
+ # Stepflow API Reference
2
+
3
+ A durable, type-safe workflow orchestration engine for Node.js with pluggable storage, scheduling, and real-time events.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Installation](#installation)
9
+ - [Quick Start](#quick-start)
10
+ - [WorkflowEngine](#workflowengine)
11
+ - [Workflow Definitions](#workflow-definitions)
12
+ - [Step Handlers](#step-handlers)
13
+ - [Event System](#event-system)
14
+ - [Scheduler](#scheduler)
15
+ - [Storage Adapters](#storage-adapters)
16
+ - [PostgreSQL Storage Adapter](#postgresql-storage-adapter)
17
+ - [Planning System](#planning-system)
18
+ - [Error Handling](#error-handling)
19
+ - [Utilities](#utilities)
20
+ - [TypeScript Types Reference](#typescript-types-reference)
21
+ - [Downstream Integration Patterns](#downstream-integration-patterns)
22
+
23
+ ---
24
+
25
+ ## Overview
26
+
27
+ Stepflow provides:
28
+
29
+ - **Workflow Definition & Registration** - Define multi-step workflows with typed inputs
30
+ - **Async Execution Engine** - Non-blocking, fire-and-forget run execution
31
+ - **State Management** - Run/step status tracking with checkpointing for crash recovery
32
+ - **Event System** - Real-time events via in-memory, Socket.IO, or webhooks
33
+ - **Pluggable Storage** - Memory, SQLite, or PostgreSQL backends
34
+ - **Retry & Error Handling** - Configurable per-step error strategies (fail, retry, skip)
35
+ - **Scheduling** - Cron-based and workflow-completion triggers
36
+ - **Planning** - Rule-based recipe selection and plan generation
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ npm install @multiplier-labs/stepflow
44
+ ```
45
+
46
+ ### Optional Peer Dependencies
47
+
48
+ Install based on your storage backend:
49
+
50
+ ```bash
51
+ # For SQLite storage
52
+ npm install better-sqlite3
53
+
54
+ # For PostgreSQL storage
55
+ npm install pg kysely
56
+ ```
57
+
58
+ These are loaded dynamically at runtime, so users of other backends are unaffected.
59
+
60
+ ---
61
+
62
+ ## Quick Start
63
+
64
+ ```typescript
65
+ import { WorkflowEngine, MemoryStorageAdapter } from '@multiplier-labs/stepflow';
66
+
67
+ const engine = new WorkflowEngine({
68
+ storage: new MemoryStorageAdapter(),
69
+ });
70
+
71
+ engine.registerWorkflow({
72
+ kind: 'email.send_campaign',
73
+ name: 'Send Email Campaign',
74
+ steps: [
75
+ {
76
+ key: 'load_recipients',
77
+ name: 'Load Recipients',
78
+ handler: async (ctx) => {
79
+ const recipients = await loadRecipients(ctx.input.campaignId);
80
+ return { recipients };
81
+ },
82
+ },
83
+ {
84
+ key: 'send_emails',
85
+ name: 'Send Emails',
86
+ handler: async (ctx) => {
87
+ const { recipients } = ctx.results.load_recipients;
88
+ return { sent: recipients.length };
89
+ },
90
+ onError: 'retry',
91
+ maxRetries: 3,
92
+ },
93
+ ],
94
+ });
95
+
96
+ const runId = await engine.startRun({
97
+ kind: 'email.send_campaign',
98
+ input: { campaignId: '123' },
99
+ });
100
+
101
+ engine.subscribeToRun(runId, (event) => {
102
+ console.log(`${event.eventType}: ${event.stepKey ?? 'run'}`);
103
+ });
104
+ ```
105
+
106
+ ---
107
+
108
+ ## WorkflowEngine
109
+
110
+ The primary entry point for the library.
111
+
112
+ ### Constructor
113
+
114
+ ```typescript
115
+ import { WorkflowEngine } from '@multiplier-labs/stepflow';
116
+
117
+ const engine = new WorkflowEngine(config?: WorkflowEngineConfig);
118
+ ```
119
+
120
+ ### Configuration
121
+
122
+ ```typescript
123
+ interface WorkflowEngineConfig {
124
+ storage?: StorageAdapter; // default: MemoryStorageAdapter
125
+ events?: EventTransport; // default: MemoryEventTransport
126
+ logger?: Logger; // default: ConsoleLogger
127
+ settings?: {
128
+ defaultTimeout?: number; // workflow timeout in ms
129
+ maxConcurrency?: number; // max concurrent workflows
130
+ };
131
+ }
132
+ ```
133
+
134
+ ### Methods
135
+
136
+ #### Lifecycle
137
+
138
+ ```typescript
139
+ // Initialize storage and event adapters (required before use)
140
+ await engine.initialize(): Promise<void>;
141
+
142
+ // Graceful shutdown: cancels active runs, closes transports and storage
143
+ await engine.shutdown(): Promise<void>;
144
+ ```
145
+
146
+ #### Workflow Registration
147
+
148
+ ```typescript
149
+ // Register a workflow definition
150
+ engine.registerWorkflow<TInput>(definition: WorkflowDefinition<TInput>): void;
151
+
152
+ // Unregister a workflow by kind
153
+ engine.unregisterWorkflow(kind: string): boolean;
154
+
155
+ // Get a registered workflow definition
156
+ engine.getWorkflow(kind: string): WorkflowDefinition | undefined;
157
+
158
+ // List all registered workflow kinds
159
+ engine.getRegisteredWorkflows(): string[];
160
+ ```
161
+
162
+ #### Running Workflows
163
+
164
+ ```typescript
165
+ // Start a new run (non-blocking, returns immediately)
166
+ const runId = await engine.startRun<TInput>(options: StartRunOptions<TInput>): Promise<string>;
167
+
168
+ // Cancel a running workflow
169
+ await engine.cancelRun(runId: string): Promise<void>;
170
+
171
+ // Get current run status
172
+ const run = await engine.getRunStatus(runId: string): Promise<WorkflowRunRecord | null>;
173
+
174
+ // Poll until a run completes (for testing or synchronous use cases)
175
+ const run = await engine.waitForRun(runId: string, options?: {
176
+ pollInterval?: number; // default: 100ms
177
+ timeout?: number; // default: 30000ms
178
+ }): Promise<WorkflowRunRecord>;
179
+ ```
180
+
181
+ #### Resume & Recovery
182
+
183
+ ```typescript
184
+ // Resume a run from its last checkpoint
185
+ const newRunId = await engine.resumeRun(runId: string): Promise<string>;
186
+
187
+ // Get all resumable runs (status 'queued' or 'running')
188
+ const runs = await engine.getResumableRuns(): Promise<WorkflowRunRecord[]>;
189
+
190
+ // Resume all interrupted runs
191
+ const runIds = await engine.resumeAllInterrupted(): Promise<string[]>;
192
+ ```
193
+
194
+ #### Event Subscriptions
195
+
196
+ ```typescript
197
+ // Subscribe to events for a specific run
198
+ const unsubscribe = engine.subscribeToRun(runId: string, callback: EventCallback): Unsubscribe;
199
+
200
+ // Subscribe to all workflow events
201
+ const unsubscribe = engine.subscribeToAll(callback: EventCallback): Unsubscribe;
202
+ ```
203
+
204
+ #### Introspection
205
+
206
+ ```typescript
207
+ engine.getActiveRunCount(): number;
208
+ engine.getQueuedRunCount(): number;
209
+ engine.getStorage(): StorageAdapter;
210
+ engine.getEvents(): EventTransport;
211
+ ```
212
+
213
+ ### StartRunOptions
214
+
215
+ ```typescript
216
+ interface StartRunOptions<TInput = Record<string, unknown>> {
217
+ kind: string; // workflow type to run
218
+ input?: TInput; // input parameters
219
+ metadata?: Record<string, unknown>; // optional metadata (userId, etc.)
220
+ parentRunId?: string; // parent run ID (for child workflows)
221
+ delay?: number; // delay before starting (ms)
222
+ priority?: number; // queue priority (higher = runs first, default: 0)
223
+ }
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Workflow Definitions
229
+
230
+ ### WorkflowDefinition
231
+
232
+ ```typescript
233
+ interface WorkflowDefinition<TInput = Record<string, unknown>> {
234
+ kind: string; // unique workflow type identifier
235
+ name: string; // human-readable name
236
+ description?: string;
237
+ steps: WorkflowStep<TInput>[]; // ordered list of steps
238
+ defaultOnError?: StepErrorStrategy; // default: 'fail'
239
+ timeout?: number; // workflow timeout in ms
240
+ hooks?: WorkflowHooks<TInput>; // lifecycle hooks
241
+ }
242
+ ```
243
+
244
+ ### WorkflowStep
245
+
246
+ ```typescript
247
+ interface WorkflowStep<TInput = Record<string, unknown>> {
248
+ key: string; // unique step identifier within the workflow
249
+ name: string; // human-readable name
250
+ handler: (ctx: WorkflowContext<TInput>) => Promise<unknown>;
251
+ onError?: StepErrorStrategy; // 'fail' | 'retry' | 'skip' (default: inherited)
252
+ maxRetries?: number; // default: 3
253
+ retryDelay?: number; // default: 1000ms
254
+ retryBackoff?: number; // default: 2 (exponential)
255
+ timeout?: number; // step timeout in ms
256
+ skipIf?: (ctx: WorkflowContext<TInput>) => boolean | Promise<boolean>;
257
+ }
258
+ ```
259
+
260
+ ### WorkflowHooks
261
+
262
+ ```typescript
263
+ interface WorkflowHooks<TInput = Record<string, unknown>> {
264
+ beforeRun?: (ctx: WorkflowContext<TInput>) => Promise<void>;
265
+ afterRun?: (ctx: WorkflowContext<TInput>, result: RunResult) => Promise<void>;
266
+ beforeStep?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>) => Promise<void>;
267
+ afterStep?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>, result: unknown) => Promise<void>;
268
+ onStepError?: (ctx: WorkflowContext<TInput>, step: WorkflowStep<TInput>, error: Error) => Promise<void>;
269
+ }
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Step Handlers
275
+
276
+ ### WorkflowContext
277
+
278
+ Every step handler receives a `WorkflowContext`:
279
+
280
+ ```typescript
281
+ interface WorkflowContext<TInput = Record<string, unknown>> {
282
+ runId: string; // unique run ID
283
+ stepId: string; // unique step ID (for granular tracking)
284
+ kind: string; // workflow type
285
+ input: TInput; // run input parameters
286
+ results: Record<string, unknown>; // accumulated results from prior steps (keyed by step key)
287
+ metadata: Record<string, unknown>;// optional metadata
288
+ currentStep?: string; // current step key
289
+ logger: Logger; // scoped logger
290
+ signal: AbortSignal; // for cancellation
291
+ spawnChild: (options: SpawnChildOptions) => Promise<string>;
292
+ emit: (eventType: string, payload?: unknown) => void;
293
+ }
294
+ ```
295
+
296
+ ### Spawning Child Workflows
297
+
298
+ ```typescript
299
+ interface SpawnChildOptions {
300
+ kind: string;
301
+ input?: Record<string, unknown>;
302
+ metadata?: Record<string, unknown>;
303
+ }
304
+
305
+ // Usage inside a step handler:
306
+ const childRunId = await ctx.spawnChild({
307
+ kind: 'child.workflow',
308
+ input: { parentData: ctx.results.step1 },
309
+ });
310
+ ```
311
+
312
+ ### Emitting Custom Events
313
+
314
+ ```typescript
315
+ // Inside a step handler:
316
+ ctx.emit('progress.update', { percent: 50 });
317
+ ```
318
+
319
+ ### Execution Flow
320
+
321
+ 1. Run status set to `'running'`
322
+ 2. `run.started` event emitted
323
+ 3. `beforeRun` hook called
324
+ 4. For each step (in order):
325
+ - Cancellation signal checked
326
+ - Skipped if in checkpoint (resume) or `skipIf` returns true
327
+ - `beforeStep` hook called
328
+ - Handler executed with timeout
329
+ - Result stored; `afterStep` hook called; `step.completed` event emitted
330
+ - On error: `step.failed` event, `onStepError` hook, then fail/retry/skip per strategy
331
+ - Checkpoint saved after each successful step
332
+ 5. `afterRun` hook called
333
+ 6. Run marked `'succeeded'` or `'failed'`
334
+
335
+ ---
336
+
337
+ ## Event System
338
+
339
+ ### Built-in Event Types
340
+
341
+ ```typescript
342
+ type BuiltInEventType =
343
+ | 'run.created' | 'run.queued' | 'run.dequeued'
344
+ | 'run.started' | 'run.resumed' | 'run.completed'
345
+ | 'run.failed' | 'run.canceled' | 'run.timeout'
346
+ | 'step.started' | 'step.completed'
347
+ | 'step.failed' | 'step.skipped' | 'step.retry';
348
+ ```
349
+
350
+ ### WorkflowEvent
351
+
352
+ ```typescript
353
+ interface WorkflowEvent {
354
+ runId: string;
355
+ kind: string;
356
+ eventType: string; // BuiltInEventType or custom string
357
+ stepKey?: string;
358
+ timestamp: Date;
359
+ payload?: unknown;
360
+ }
361
+ ```
362
+
363
+ ### EventTransport Interface
364
+
365
+ ```typescript
366
+ interface EventTransport {
367
+ emit(event: WorkflowEvent): void;
368
+ subscribe(runId: string, callback: EventCallback): Unsubscribe;
369
+ subscribeAll(callback: EventCallback): Unsubscribe;
370
+ subscribeToType?(eventType: string, callback: EventCallback): Unsubscribe;
371
+ persist?(event: WorkflowEvent): Promise<void>;
372
+ close?(): void;
373
+ }
374
+
375
+ type EventCallback = (event: WorkflowEvent) => void;
376
+ type Unsubscribe = () => void;
377
+ ```
378
+
379
+ ### MemoryEventTransport
380
+
381
+ Default in-memory transport. Supports `subscribeToType()`.
382
+
383
+ ```typescript
384
+ import { MemoryEventTransport } from '@multiplier-labs/stepflow';
385
+
386
+ const events = new MemoryEventTransport();
387
+ events.getListenerCount(channel?: string): number;
388
+ ```
389
+
390
+ ### SocketIOEventTransport
391
+
392
+ Real-time events via Socket.IO.
393
+
394
+ ```typescript
395
+ import { SocketIOEventTransport } from '@multiplier-labs/stepflow';
396
+
397
+ const events = new SocketIOEventTransport({
398
+ io: socketIOServer,
399
+ eventName: 'workflow:event', // default
400
+ roomPrefix: 'run:', // default
401
+ broadcastGlobal: true, // default
402
+ globalRoom: 'workflow:all', // default
403
+ });
404
+
405
+ // Set up client handlers for a socket connection
406
+ events.setupClientHandlers(socket, async (runId, socket) => {
407
+ return /* authorization check */;
408
+ });
409
+ ```
410
+
411
+ **Client events:** `workflow:subscribe`, `workflow:unsubscribe`, `workflow:subscribe:all`, `workflow:unsubscribe:all`
412
+
413
+ ### WebhookEventTransport
414
+
415
+ Delivers events to HTTP endpoints with HMAC-SHA256 signing.
416
+
417
+ ```typescript
418
+ import { WebhookEventTransport } from '@multiplier-labs/stepflow';
419
+
420
+ const events = new WebhookEventTransport({
421
+ endpoints: [
422
+ {
423
+ id: 'my-webhook',
424
+ url: 'https://example.com/webhook',
425
+ secret: 'my-secret', // for HMAC-SHA256 signing
426
+ eventTypes: ['run.completed'], // filter (optional)
427
+ workflowKinds: ['my.workflow'], // filter (optional)
428
+ headers: { 'X-Custom': 'val' },
429
+ enabled: true, // default: true
430
+ timeout: 5000, // default: 5000ms
431
+ retries: 3, // default: 3
432
+ },
433
+ ],
434
+ defaultTimeout: 5000, // default
435
+ defaultRetries: 3, // default
436
+ retryDelay: 1000, // default
437
+ fetchFn: fetch, // custom fetch (optional)
438
+ });
439
+
440
+ events.addEndpoint(endpoint);
441
+ events.removeEndpoint(id: string): boolean;
442
+ events.getEndpoints(): WebhookEndpoint[];
443
+ events.setEndpointEnabled(id: string, enabled: boolean): void;
444
+ ```
445
+
446
+ **Webhook payload:**
447
+
448
+ ```typescript
449
+ interface WebhookPayload {
450
+ event: { runId, kind, eventType, stepKey?, payload?, timestamp: string };
451
+ deliveredAt: string;
452
+ webhookId: string;
453
+ }
454
+ ```
455
+
456
+ Signed with `X-Webhook-Signature` header (HMAC-SHA256).
457
+
458
+ ---
459
+
460
+ ## Scheduler
461
+
462
+ ### CronScheduler
463
+
464
+ ```typescript
465
+ import { CronScheduler } from '@multiplier-labs/stepflow';
466
+
467
+ const scheduler = new CronScheduler({
468
+ engine: workflowEngine,
469
+ logger: myLogger, // optional
470
+ pollInterval: 1000, // default: 1000ms
471
+ persistence: myPersistence, // optional SchedulePersistence
472
+ });
473
+
474
+ await scheduler.start();
475
+
476
+ // Add a cron schedule
477
+ const schedule = await scheduler.addSchedule({
478
+ workflowKind: 'reports.generate',
479
+ triggerType: 'cron',
480
+ cronExpression: '0 6 * * *', // daily at 6 AM
481
+ timezone: 'UTC',
482
+ input: { format: 'pdf' },
483
+ enabled: true,
484
+ });
485
+
486
+ // Add a workflow-completion trigger
487
+ await scheduler.addSchedule({
488
+ workflowKind: 'cleanup.run',
489
+ triggerType: 'workflow_completed',
490
+ triggerOnWorkflowKind: 'data.import',
491
+ triggerOnStatus: ['succeeded'],
492
+ enabled: true,
493
+ });
494
+
495
+ // Manage schedules
496
+ await scheduler.removeSchedule(scheduleId);
497
+ await scheduler.updateSchedule(scheduleId, { enabled: false });
498
+ const schedules = await scheduler.getSchedules();
499
+ const schedule = scheduler.getSchedule(scheduleId);
500
+ const runId = await scheduler.triggerNow(scheduleId);
501
+
502
+ await scheduler.stop();
503
+ ```
504
+
505
+ ### WorkflowSchedule
506
+
507
+ ```typescript
508
+ interface WorkflowSchedule {
509
+ id: string;
510
+ workflowKind: string;
511
+ triggerType: 'cron' | 'workflow_completed' | 'manual';
512
+ cronExpression?: string;
513
+ timezone?: string;
514
+ triggerOnWorkflowKind?: string;
515
+ triggerOnStatus?: RunStatus[];
516
+ input?: Record<string, unknown>;
517
+ metadata?: Record<string, unknown>;
518
+ enabled: boolean;
519
+ lastRunAt?: Date;
520
+ lastRunId?: string;
521
+ nextRunAt?: Date;
522
+ }
523
+ ```
524
+
525
+ ### SchedulePersistence Interface
526
+
527
+ ```typescript
528
+ interface SchedulePersistence {
529
+ loadSchedules(): Promise<WorkflowSchedule[]>;
530
+ saveSchedule(schedule: WorkflowSchedule): Promise<void>;
531
+ updateSchedule(scheduleId: string, updates: Partial<WorkflowSchedule>): Promise<void>;
532
+ deleteSchedule(scheduleId: string): Promise<void>;
533
+ }
534
+ ```
535
+
536
+ ### SQLiteSchedulePersistence
537
+
538
+ ```typescript
539
+ import { SQLiteSchedulePersistence } from '@multiplier-labs/stepflow';
540
+
541
+ const persistence = new SQLiteSchedulePersistence({
542
+ db: sqliteDatabase,
543
+ tableName: 'workflow_schedules', // default
544
+ });
545
+ ```
546
+
547
+ ### PostgresSchedulePersistence
548
+
549
+ ```typescript
550
+ import { PostgresSchedulePersistence } from '@multiplier-labs/stepflow';
551
+
552
+ const persistence = new PostgresSchedulePersistence({
553
+ connectionString: process.env.DATABASE_URL,
554
+ // OR pool: existingPgPool,
555
+ // OR poolConfig: { ... },
556
+ schema: 'public', // default
557
+ tableName: 'workflow_schedules', // default
558
+ autoMigrate: true, // default
559
+ });
560
+
561
+ await persistence.initialize();
562
+
563
+ // Additional query methods:
564
+ await persistence.getSchedule(scheduleId): Promise<WorkflowSchedule | null>;
565
+ await persistence.getDueSchedules(): Promise<WorkflowSchedule[]>;
566
+ await persistence.getSchedulesByWorkflowKind(kind): Promise<WorkflowSchedule[]>;
567
+ await persistence.getCompletionTriggers(triggerOnKind): Promise<WorkflowSchedule[]>;
568
+
569
+ await persistence.close();
570
+ ```
571
+
572
+ ---
573
+
574
+ ## Storage Adapters
575
+
576
+ All storage adapters implement the `StorageAdapter` interface.
577
+
578
+ ### StorageAdapter Interface
579
+
580
+ ```typescript
581
+ interface StorageAdapter {
582
+ // Run operations
583
+ createRun(run: Omit<WorkflowRunRecord, 'id' | 'createdAt'>): Promise<WorkflowRunRecord>;
584
+ getRun(runId: string): Promise<WorkflowRunRecord | null>;
585
+ updateRun(runId: string, updates: Partial<WorkflowRunRecord>): Promise<void>;
586
+ listRuns(options?: ListRunsOptions): Promise<PaginatedResult<WorkflowRunRecord>>;
587
+
588
+ // Step operations
589
+ createStep(step: Omit<WorkflowRunStepRecord, 'id'>): Promise<WorkflowRunStepRecord>;
590
+ getStep(stepId: string): Promise<WorkflowRunStepRecord | null>;
591
+ updateStep(stepId: string, updates: Partial<WorkflowRunStepRecord>): Promise<void>;
592
+ getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]>;
593
+
594
+ // Event operations
595
+ saveEvent(event: Omit<WorkflowEventRecord, 'id'>): Promise<void>;
596
+ getEventsForRun(runId: string, options?: ListEventsOptions): Promise<WorkflowEventRecord[]>;
597
+
598
+ // Optional
599
+ transaction?<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T>;
600
+ deleteOldRuns?(olderThan: Date): Promise<number>;
601
+ initialize?(): Promise<void>;
602
+ close?(): void | Promise<void>;
603
+ }
604
+ ```
605
+
606
+ ### WorkflowStorage Interface
607
+
608
+ A newer storage interface with additional methods for queue management and step results. The `PostgresStorageAdapter` implements both `StorageAdapter` and `WorkflowStorage`.
609
+
610
+ ```typescript
611
+ interface WorkflowStorage {
612
+ // Run operations
613
+ createRun(run: CreateRunInput): Promise<ExtendedWorkflowRunRecord>;
614
+ getRun(id: string): Promise<ExtendedWorkflowRunRecord | null>;
615
+ updateRun(id: string, updates: UpdateRunInput): Promise<void>;
616
+ listRuns(options?: ExtendedListRunsOptions): Promise<PaginatedResult<ExtendedWorkflowRunRecord>>;
617
+ deleteRun(id: string): Promise<void>;
618
+
619
+ // Atomic dequeue
620
+ dequeueRun(workflowKinds: string[]): Promise<ExtendedWorkflowRunRecord | null>;
621
+
622
+ // Stale run management
623
+ cleanupStaleRuns(defaultTimeoutMs?: number): Promise<number>;
624
+ markRunsAsFailed(runIds: string[], reason: string): Promise<void>;
625
+
626
+ // Step results (uses stepflow_step_results table)
627
+ getStepResult(runId: string, stepName: string): Promise<StepResult | undefined>;
628
+ getStepResults(runId: string): Promise<StepResult[]>;
629
+ getStepsForRun(runId: string): Promise<StepRecord[]>;
630
+ saveStepResult(result: Omit<StepResult, 'id'> & { id?: string }): Promise<void>;
631
+
632
+ // Lifecycle
633
+ initialize(): Promise<void>;
634
+ close(): Promise<void>;
635
+ }
636
+ ```
637
+
638
+ ### Core Record Types
639
+
640
+ ```typescript
641
+ interface WorkflowRunRecord {
642
+ id: string;
643
+ kind: string;
644
+ status: RunStatus; // 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled'
645
+ parentRunId?: string;
646
+ input: Record<string, unknown>;
647
+ context: Record<string, unknown>; // checkpoint: accumulated step results
648
+ output?: Record<string, unknown>;
649
+ error?: { code: string; message: string };
650
+ metadata?: Record<string, unknown>;
651
+ priority?: number;
652
+ timeoutMs?: number;
653
+ createdAt: Date;
654
+ startedAt?: Date;
655
+ finishedAt?: Date;
656
+ }
657
+
658
+ interface WorkflowRunStepRecord {
659
+ id: string;
660
+ runId: string;
661
+ stepKey: string;
662
+ stepName: string;
663
+ status: StepStatus; // 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'canceled'
664
+ attempt: number;
665
+ result?: unknown;
666
+ error?: WorkflowError;
667
+ startedAt?: Date;
668
+ finishedAt?: Date;
669
+ }
670
+
671
+ interface WorkflowEventRecord {
672
+ id: string;
673
+ runId: string;
674
+ stepKey?: string;
675
+ eventType: string;
676
+ level: 'info' | 'warn' | 'error';
677
+ payload?: unknown;
678
+ timestamp: Date;
679
+ }
680
+ ```
681
+
682
+ ### Extended Types
683
+
684
+ Used by the `WorkflowStorage` interface and `PostgresStorageAdapter`:
685
+
686
+ ```typescript
687
+ // Adds 'pending' and 'timeout' to core RunStatus
688
+ type ExtendedRunStatus = 'pending' | 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'timeout';
689
+
690
+ // Uses 'completed' instead of 'succeeded'
691
+ type ExtendedStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
692
+
693
+ interface StepResult {
694
+ id: string;
695
+ runId: string;
696
+ stepName: string;
697
+ status: ExtendedStepStatus;
698
+ output?: Record<string, unknown>;
699
+ error?: Record<string, unknown>;
700
+ attempt: number;
701
+ startedAt?: Date;
702
+ completedAt?: Date;
703
+ }
704
+
705
+ interface StepRecord {
706
+ stepKey: string;
707
+ stepName: string;
708
+ status: ExtendedStepStatus;
709
+ result?: Record<string, unknown>;
710
+ error?: Record<string, unknown>;
711
+ attempt: number;
712
+ startedAt?: Date;
713
+ finishedAt?: Date;
714
+ }
715
+ ```
716
+
717
+ ### Query Options
718
+
719
+ ```typescript
720
+ interface ListRunsOptions {
721
+ kind?: string;
722
+ status?: RunStatus | RunStatus[];
723
+ parentRunId?: string;
724
+ limit?: number; // default: 50
725
+ offset?: number; // default: 0
726
+ orderBy?: 'createdAt' | 'startedAt' | 'finishedAt';
727
+ orderDirection?: 'asc' | 'desc'; // default: 'desc'
728
+ }
729
+
730
+ interface ExtendedListRunsOptions {
731
+ kind?: string;
732
+ status?: ExtendedRunStatus | ExtendedRunStatus[];
733
+ limit?: number;
734
+ offset?: number;
735
+ orderBy?: 'createdAt' | 'startedAt' | 'finishedAt';
736
+ orderDir?: 'asc' | 'desc';
737
+ }
738
+
739
+ interface ListEventsOptions {
740
+ stepKey?: string;
741
+ level?: 'info' | 'warn' | 'error';
742
+ limit?: number; // default: 1000
743
+ offset?: number;
744
+ }
745
+
746
+ interface PaginatedResult<T> {
747
+ items: T[];
748
+ total: number;
749
+ limit?: number;
750
+ offset?: number;
751
+ }
752
+ ```
753
+
754
+ ### MemoryStorageAdapter
755
+
756
+ In-memory storage. Data is lost on process exit.
757
+
758
+ ```typescript
759
+ import { MemoryStorageAdapter } from '@multiplier-labs/stepflow';
760
+
761
+ const storage = new MemoryStorageAdapter();
762
+
763
+ // Testing utilities
764
+ storage.clear(): void;
765
+ storage.getStats(): { runs: number; steps: number; events: number };
766
+ ```
767
+
768
+ ### SQLiteStorageAdapter
769
+
770
+ File-based or in-memory SQLite storage.
771
+
772
+ ```typescript
773
+ import { SQLiteStorageAdapter } from '@multiplier-labs/stepflow';
774
+ import Database from 'better-sqlite3';
775
+
776
+ const db = new Database('./workflows.db');
777
+ const storage = new SQLiteStorageAdapter({
778
+ db,
779
+ autoCreateTables: true, // default: true
780
+ tablePrefix: 'workflow', // default: 'workflow'
781
+ });
782
+
783
+ // Additional methods
784
+ storage.transactionSync<T>(fn: () => T): T;
785
+ await storage.getInterruptedRuns(): Promise<WorkflowRunRecord[]>;
786
+ await storage.getLastCompletedStep(runId): Promise<WorkflowRunStepRecord | null>;
787
+ storage.close(): void;
788
+ storage.getStats(): { runs: number; steps: number; events: number };
789
+ ```
790
+
791
+ **Tables created:** `workflow_runs`, `workflow_run_steps`, `workflow_events`
792
+
793
+ ---
794
+
795
+ ## PostgreSQL Storage Adapter
796
+
797
+ Production-ready PostgreSQL backend with connection pooling, distributed worker support, and atomic dequeue.
798
+
799
+ ### Configuration
800
+
801
+ ```typescript
802
+ import { PostgresStorageAdapter } from '@multiplier-labs/stepflow';
803
+
804
+ interface PostgresStorageConfig {
805
+ connectionString?: string; // e.g., "postgresql://user:pass@localhost:5432/dbname"
806
+ pool?: pg.Pool; // existing pool for connection sharing
807
+ poolConfig?: pg.PoolConfig; // pool configuration options
808
+ schema?: string; // schema name (default: 'public')
809
+ autoMigrate?: boolean; // auto-create tables (default: true)
810
+ }
811
+ ```
812
+
813
+ ### Usage
814
+
815
+ ```typescript
816
+ // Option 1: Connection string
817
+ const storage = new PostgresStorageAdapter({
818
+ connectionString: process.env.DATABASE_URL,
819
+ schema: 'stepflow',
820
+ });
821
+ await storage.initialize();
822
+
823
+ // Option 2: Existing pool (for connection sharing)
824
+ import pg from 'pg';
825
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });
826
+ const storage = new PostgresStorageAdapter({ pool, schema: 'stepflow' });
827
+ await storage.initialize();
828
+
829
+ // Use with engine
830
+ const engine = new WorkflowEngine({ storage });
831
+ await engine.initialize();
832
+ ```
833
+
834
+ The adapter only closes the pool on `close()` if it created it. Externally-provided pools are left open.
835
+
836
+ ### All Methods
837
+
838
+ The adapter implements both `StorageAdapter` and `WorkflowStorage`, providing:
839
+
840
+ **StorageAdapter methods (legacy, used by WorkflowEngine):**
841
+
842
+ ```typescript
843
+ await storage.createRun(run): Promise<WorkflowRunRecord>;
844
+ await storage.getRun(runId): Promise<WorkflowRunRecord | null>;
845
+ await storage.updateRun(runId, updates): Promise<void>;
846
+ await storage.listRuns(options?): Promise<PaginatedResult<WorkflowRunRecord>>;
847
+ await storage.createStep(step): Promise<WorkflowRunStepRecord>;
848
+ await storage.getStep(stepId): Promise<WorkflowRunStepRecord | null>;
849
+ await storage.updateStep(stepId, updates): Promise<void>;
850
+ await storage.getStepsForRun(runId): Promise<WorkflowRunStepRecord[]>;
851
+ await storage.saveEvent(event): Promise<void>;
852
+ await storage.getEventsForRun(runId, options?): Promise<WorkflowEventRecord[]>;
853
+ await storage.transaction<T>(fn): Promise<T>;
854
+ await storage.deleteOldRuns(olderThan): Promise<number>;
855
+ await storage.initialize(): Promise<void>;
856
+ await storage.close(): Promise<void>;
857
+ ```
858
+
859
+ **WorkflowStorage methods (used for queue operations and step results):**
860
+
861
+ ```typescript
862
+ await storage.deleteRun(id): Promise<void>;
863
+ await storage.dequeueRun(workflowKinds?): Promise<WorkflowRunRecord | null>;
864
+ await storage.cleanupStaleRuns(defaultTimeoutMs?): Promise<number>;
865
+ await storage.markRunsAsFailed(runIds, reason): Promise<void>;
866
+ await storage.getStepResult(runId, stepName): Promise<StepResult | undefined>;
867
+ await storage.getStepResults(runId): Promise<StepResult[]>;
868
+ await storage.saveStepResult(result): Promise<void>;
869
+ ```
870
+
871
+ **Resume support:**
872
+
873
+ ```typescript
874
+ await storage.getInterruptedRuns(): Promise<WorkflowRunRecord[]>;
875
+ await storage.getLastCompletedStep(runId): Promise<WorkflowRunStepRecord | null>;
876
+ ```
877
+
878
+ **Diagnostics:**
879
+
880
+ ```typescript
881
+ await storage.getStats(): Promise<{ runs: number; steps: number; events: number }>;
882
+ ```
883
+
884
+ ### Atomic Dequeue
885
+
886
+ Safe concurrent processing by multiple workers using `FOR UPDATE SKIP LOCKED`:
887
+
888
+ ```typescript
889
+ const run = await storage.dequeueRun(['sync.xero', 'sync.directo']);
890
+ if (run) {
891
+ // run.status is now 'running', startedAt is set
892
+ console.log(`Processing ${run.kind}: ${run.id}`);
893
+ }
894
+ ```
895
+
896
+ The dequeue query:
897
+
898
+ ```sql
899
+ UPDATE {schema}.runs
900
+ SET status = 'running', started_at = NOW()
901
+ WHERE id = (
902
+ SELECT id FROM {schema}.runs
903
+ WHERE status = 'queued' AND kind = ANY($1::text[])
904
+ ORDER BY priority DESC, created_at ASC
905
+ LIMIT 1
906
+ FOR UPDATE SKIP LOCKED
907
+ )
908
+ RETURNING *;
909
+ ```
910
+
911
+ This ensures: one worker per run, priority ordering, FIFO within same priority, no blocking between workers.
912
+
913
+ ### Stale Run Cleanup
914
+
915
+ ```typescript
916
+ // Mark runs as timed out if they exceed their timeout_ms (or a default of 10 minutes)
917
+ const count = await storage.cleanupStaleRuns(600000);
918
+
919
+ // Mark specific runs as failed (e.g., during graceful shutdown)
920
+ await storage.markRunsAsFailed([runId1, runId2], 'Worker process was terminated');
921
+ ```
922
+
923
+ ### Step Results (WorkflowStorage Interface)
924
+
925
+ ```typescript
926
+ // Save or upsert a step result (uses ON CONFLICT DO UPDATE on run_id + step_name)
927
+ await storage.saveStepResult({
928
+ runId: run.id,
929
+ stepName: 'xero.accounts',
930
+ status: 'running',
931
+ attempt: 1,
932
+ startedAt: new Date(),
933
+ });
934
+
935
+ // Get a single step result
936
+ const step = await storage.getStepResult(runId, 'xero.accounts');
937
+
938
+ // Get all step results for a run
939
+ const steps = await storage.getStepResults(runId);
940
+ ```
941
+
942
+ ### Database Schema
943
+
944
+ When `autoMigrate` is enabled (default), the adapter creates the following tables. All table names are prefixed with the configured `schema` (default: `'public'`).
945
+
946
+ #### Table: `{schema}.runs`
947
+
948
+ ```sql
949
+ CREATE TABLE IF NOT EXISTS {schema}.runs (
950
+ id TEXT PRIMARY KEY,
951
+ kind TEXT NOT NULL,
952
+ status TEXT NOT NULL,
953
+ parent_run_id TEXT,
954
+ input_json JSONB NOT NULL DEFAULT '{}',
955
+ metadata_json JSONB NOT NULL DEFAULT '{}',
956
+ context_json JSONB NOT NULL DEFAULT '{}',
957
+ output_json JSONB,
958
+ error_json JSONB,
959
+ priority INTEGER NOT NULL DEFAULT 0,
960
+ timeout_ms INTEGER,
961
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
962
+ started_at TIMESTAMPTZ,
963
+ finished_at TIMESTAMPTZ,
964
+ CONSTRAINT runs_status_check CHECK (
965
+ status IN ('pending', 'queued', 'running', 'succeeded', 'failed', 'canceled', 'timeout')
966
+ )
967
+ );
968
+
969
+ CREATE INDEX idx_runs_kind_status ON {schema}.runs (kind, status);
970
+ CREATE INDEX idx_runs_parent ON {schema}.runs (parent_run_id);
971
+ CREATE INDEX idx_runs_created ON {schema}.runs (created_at DESC);
972
+ CREATE INDEX idx_runs_status ON {schema}.runs (status);
973
+ CREATE INDEX idx_runs_priority ON {schema}.runs (priority DESC, created_at ASC);
974
+ ```
975
+
976
+ #### Table: `{schema}.workflow_run_steps`
977
+
978
+ Used by the `StorageAdapter` interface (legacy step tracking by the engine).
979
+
980
+ ```sql
981
+ CREATE TABLE IF NOT EXISTS {schema}.workflow_run_steps (
982
+ id TEXT PRIMARY KEY,
983
+ run_id TEXT NOT NULL REFERENCES {schema}.runs (id) ON DELETE CASCADE,
984
+ step_key TEXT NOT NULL,
985
+ step_name TEXT,
986
+ status TEXT NOT NULL,
987
+ attempt INTEGER NOT NULL DEFAULT 1,
988
+ result_json JSONB,
989
+ error_json JSONB,
990
+ started_at TIMESTAMPTZ,
991
+ finished_at TIMESTAMPTZ,
992
+ CONSTRAINT workflow_run_steps_status_check CHECK (
993
+ status IN ('pending', 'running', 'succeeded', 'failed', 'skipped', 'completed')
994
+ )
995
+ );
996
+
997
+ CREATE INDEX idx_workflow_run_steps_run ON {schema}.workflow_run_steps (run_id);
998
+ CREATE INDEX idx_workflow_run_steps_run_key ON {schema}.workflow_run_steps (run_id, step_key);
999
+ ```
1000
+
1001
+ #### Table: `{schema}.stepflow_step_results`
1002
+
1003
+ Used by the `WorkflowStorage` interface (step result tracking for external consumers).
1004
+
1005
+ ```sql
1006
+ CREATE TABLE IF NOT EXISTS {schema}.stepflow_step_results (
1007
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
1008
+ run_id TEXT NOT NULL REFERENCES {schema}.runs (id) ON DELETE CASCADE,
1009
+ step_name TEXT NOT NULL,
1010
+ status TEXT NOT NULL,
1011
+ output_json JSONB,
1012
+ error_json JSONB,
1013
+ attempt INTEGER NOT NULL DEFAULT 1,
1014
+ started_at TIMESTAMPTZ,
1015
+ completed_at TIMESTAMPTZ,
1016
+ CONSTRAINT stepflow_step_results_status_check CHECK (
1017
+ status IN ('pending', 'running', 'completed', 'failed', 'skipped')
1018
+ ),
1019
+ UNIQUE (run_id, step_name)
1020
+ );
1021
+
1022
+ CREATE INDEX idx_stepflow_step_results_run ON {schema}.stepflow_step_results (run_id);
1023
+ CREATE INDEX idx_stepflow_step_results_run_name ON {schema}.stepflow_step_results (run_id, step_name);
1024
+ ```
1025
+
1026
+ #### Table: `{schema}.workflow_events`
1027
+
1028
+ ```sql
1029
+ CREATE TABLE IF NOT EXISTS {schema}.workflow_events (
1030
+ id TEXT PRIMARY KEY,
1031
+ run_id TEXT NOT NULL REFERENCES {schema}.runs (id) ON DELETE CASCADE,
1032
+ step_key TEXT,
1033
+ event_type TEXT NOT NULL,
1034
+ level TEXT NOT NULL,
1035
+ payload_json JSONB,
1036
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1037
+ CONSTRAINT workflow_events_level_check CHECK (
1038
+ level IN ('info', 'warn', 'error')
1039
+ )
1040
+ );
1041
+
1042
+ CREATE INDEX idx_workflow_events_run ON {schema}.workflow_events (run_id);
1043
+ CREATE INDEX idx_workflow_events_run_ts ON {schema}.workflow_events (run_id, timestamp);
1044
+ ```
1045
+
1046
+ ### Implementation Notes
1047
+
1048
+ - **IDs are TEXT, not UUID.** The adapter uses `generateId()` which produces ULID-like base36 strings (~16 characters) for time-ordering and collision resistance.
1049
+ - **JSONB columns use `_json` suffix** (e.g., `input_json`, `output_json`, `error_json`) to distinguish from the camelCase TypeScript field names.
1050
+ - **`finished_at` (not `completed_at`)** is used on the `runs` table. The `stepflow_step_results` table uses `completed_at`.
1051
+ - **Lazy dependency loading.** `pg` and `kysely` are imported dynamically in `initialize()`, so they're only needed if you actually use the PostgreSQL adapter.
1052
+ - **Schema isolation.** All queries use `db.withSchema(schema)` to support custom schemas and multi-tenant deployments.
1053
+ - **Transaction support.** `storage.transaction(fn)` wraps operations in a PostgreSQL transaction via Kysely.
1054
+
1055
+ ### SQL Queries for Debugging
1056
+
1057
+ ```sql
1058
+ -- Recent workflow runs
1059
+ SELECT id, kind, status, created_at, started_at, finished_at,
1060
+ error_json->>'message' as error_message
1061
+ FROM {schema}.runs
1062
+ ORDER BY created_at DESC
1063
+ LIMIT 20;
1064
+
1065
+ -- Step results for a run
1066
+ SELECT step_name, status, output_json, error_json, started_at, completed_at
1067
+ FROM {schema}.stepflow_step_results
1068
+ WHERE run_id = 'your-run-id'
1069
+ ORDER BY started_at;
1070
+
1071
+ -- Count by status
1072
+ SELECT status, COUNT(*) FROM {schema}.runs GROUP BY status;
1073
+
1074
+ -- Find stale running workflows
1075
+ SELECT id, kind, started_at, timeout_ms
1076
+ FROM {schema}.runs
1077
+ WHERE status = 'running'
1078
+ AND started_at < NOW() - INTERVAL '10 minutes';
1079
+
1080
+ -- Queue depth
1081
+ SELECT kind, COUNT(*) FROM {schema}.runs
1082
+ WHERE status = 'queued' GROUP BY kind;
1083
+ ```
1084
+
1085
+ ---
1086
+
1087
+ ## Planning System
1088
+
1089
+ The planning system enables dynamic workflow construction from recipes and step handler registries.
1090
+
1091
+ ### Recipes
1092
+
1093
+ A recipe is a template for building a workflow plan:
1094
+
1095
+ ```typescript
1096
+ interface Recipe {
1097
+ id: string;
1098
+ name: string;
1099
+ description?: string;
1100
+ workflowKind: string;
1101
+ variant: string; // e.g., 'default', 'fast', 'thorough'
1102
+ steps: RecipeStep[];
1103
+ defaults?: RecipeDefaults;
1104
+ conditions?: RecipeCondition[]; // conditions for recipe selection
1105
+ priority?: number; // higher = preferred
1106
+ tags?: string[];
1107
+ }
1108
+
1109
+ interface RecipeStep {
1110
+ key: string;
1111
+ name: string;
1112
+ handlerRef: string; // reference to a registered step handler
1113
+ config?: Record<string, unknown>;
1114
+ onError?: StepErrorStrategy;
1115
+ maxRetries?: number;
1116
+ retryDelay?: number;
1117
+ retryBackoff?: number;
1118
+ timeout?: number;
1119
+ skipCondition?: string; // field reference (dot notation)
1120
+ }
1121
+
1122
+ interface RecipeDefaults {
1123
+ timeout?: number;
1124
+ maxRetries?: number;
1125
+ retryDelay?: number;
1126
+ retryBackoff?: number;
1127
+ onError?: StepErrorStrategy;
1128
+ }
1129
+ ```
1130
+
1131
+ ### Conditions
1132
+
1133
+ ```typescript
1134
+ type ConditionOperator =
1135
+ | 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
1136
+ | 'contains' | 'matches' | 'exists' | 'notExists';
1137
+
1138
+ interface RecipeCondition {
1139
+ field: string; // supports dot notation
1140
+ operator: ConditionOperator;
1141
+ value?: unknown;
1142
+ }
1143
+ ```
1144
+
1145
+ ### Registries
1146
+
1147
+ ```typescript
1148
+ import { MemoryRecipeRegistry, MemoryStepHandlerRegistry, createRegistry } from '@multiplier-labs/stepflow';
1149
+
1150
+ // Create both registries at once
1151
+ const { recipes, handlers } = createRegistry();
1152
+
1153
+ // Register step handlers
1154
+ handlers.register({
1155
+ id: 'fetch-data',
1156
+ name: 'Fetch Data',
1157
+ description: 'Fetches data from API',
1158
+ tags: ['io', 'api'],
1159
+ handler: async (ctx) => { /* ... */ },
1160
+ });
1161
+
1162
+ // Register recipes
1163
+ recipes.register({
1164
+ id: 'data-pipeline-default',
1165
+ name: 'Data Pipeline',
1166
+ workflowKind: 'data.pipeline',
1167
+ variant: 'default',
1168
+ steps: [
1169
+ { key: 'fetch', name: 'Fetch', handlerRef: 'fetch-data' },
1170
+ ],
1171
+ });
1172
+
1173
+ // Query recipes
1174
+ recipes.getByKind('data.pipeline'): Recipe[];
1175
+ recipes.getVariant('data.pipeline', 'fast'): Recipe | undefined;
1176
+ recipes.getDefault('data.pipeline'): Recipe | undefined;
1177
+ recipes.listVariants('data.pipeline'): string[];
1178
+ recipes.query({ workflowKind: 'data.pipeline', tags: ['fast'] }): Recipe[];
1179
+ ```
1180
+
1181
+ ### RuleBasedPlanner
1182
+
1183
+ Selects recipes based on conditions and generates execution plans:
1184
+
1185
+ ```typescript
1186
+ import { RuleBasedPlanner } from '@multiplier-labs/stepflow';
1187
+
1188
+ const planner = new RuleBasedPlanner({
1189
+ recipeRegistry: recipes,
1190
+ handlerRegistry: handlers, // optional
1191
+ validateHandlers: true, // validate handler refs exist
1192
+ });
1193
+
1194
+ // Select best recipe for given input
1195
+ const selection = await planner.selectRecipe('data.pipeline', { size: 'large' });
1196
+
1197
+ // Generate a full plan
1198
+ const plan = await planner.plan('data.pipeline', { size: 'large' }, {
1199
+ constraints: { maxDuration: 60000, priority: 'speed' },
1200
+ hints: { preferredVariant: 'fast', skipSteps: ['optional-step'] },
1201
+ });
1202
+
1203
+ // Validate and estimate
1204
+ const validation = planner.validatePlan(plan);
1205
+ const estimate = planner.estimateResources(plan);
1206
+ ```
1207
+
1208
+ ### Plan Output
1209
+
1210
+ ```typescript
1211
+ interface Plan {
1212
+ id: string;
1213
+ recipeId: string;
1214
+ variant: string;
1215
+ modifications: PlanModification[];
1216
+ steps: PlannedStep[];
1217
+ childWorkflows?: ChildWorkflowPlan[];
1218
+ defaults: RecipeDefaults;
1219
+ reasoning?: string;
1220
+ resourceEstimate?: ResourceEstimate;
1221
+ createdAt: Date;
1222
+ }
1223
+
1224
+ interface ResourceEstimate {
1225
+ apiCalls?: number;
1226
+ tokens?: number;
1227
+ duration?: number;
1228
+ memory?: number;
1229
+ }
1230
+ ```
1231
+
1232
+ ---
1233
+
1234
+ ## Error Handling
1235
+
1236
+ ### Error Classes
1237
+
1238
+ ```typescript
1239
+ import {
1240
+ WorkflowEngineError,
1241
+ WorkflowNotFoundError,
1242
+ WorkflowAlreadyRegisteredError,
1243
+ RunNotFoundError,
1244
+ StepError,
1245
+ StepTimeoutError,
1246
+ WorkflowCanceledError,
1247
+ WorkflowTimeoutError,
1248
+ } from '@multiplier-labs/stepflow';
1249
+ ```
1250
+
1251
+ | Class | Code | Constructor |
1252
+ |-------|------|-------------|
1253
+ | `WorkflowEngineError` | (custom) | `(code, message, details?)` |
1254
+ | `WorkflowNotFoundError` | `WORKFLOW_NOT_FOUND` | `(kind)` |
1255
+ | `WorkflowAlreadyRegisteredError` | `WORKFLOW_ALREADY_REGISTERED` | `(kind)` |
1256
+ | `RunNotFoundError` | `RUN_NOT_FOUND` | `(runId)` |
1257
+ | `StepError` | `STEP_ERROR` | `(stepKey, message, attempt, cause?)` |
1258
+ | `StepTimeoutError` | `STEP_TIMEOUT` | `(stepKey, timeoutMs)` |
1259
+ | `WorkflowCanceledError` | `WORKFLOW_CANCELED` | `(runId)` |
1260
+ | `WorkflowTimeoutError` | `WORKFLOW_TIMEOUT` | `(runId, timeoutMs)` |
1261
+
1262
+ ### WorkflowError Record
1263
+
1264
+ ```typescript
1265
+ interface WorkflowError {
1266
+ code: string;
1267
+ message: string;
1268
+ stack?: string;
1269
+ details?: Record<string, unknown>;
1270
+ }
1271
+
1272
+ // Convert error instances
1273
+ const record = engineError.toRecord();
1274
+ const record = WorkflowEngineError.fromError(unknownError, 'DEFAULT_CODE');
1275
+ ```
1276
+
1277
+ ### Step Error Strategies
1278
+
1279
+ | Strategy | Behavior |
1280
+ |----------|----------|
1281
+ | `'fail'` | Stop the workflow immediately (default) |
1282
+ | `'retry'` | Retry up to `maxRetries` times with exponential backoff |
1283
+ | `'skip'` | Mark step as skipped and continue to next step |
1284
+
1285
+ ### RunResult
1286
+
1287
+ ```typescript
1288
+ interface RunResult {
1289
+ status: 'succeeded' | 'failed' | 'canceled';
1290
+ results: Record<string, unknown>;
1291
+ error?: WorkflowError;
1292
+ duration: number; // ms
1293
+ }
1294
+ ```
1295
+
1296
+ ---
1297
+
1298
+ ## Utilities
1299
+
1300
+ ### ID Generation
1301
+
1302
+ ```typescript
1303
+ import { generateId } from '@multiplier-labs/stepflow';
1304
+
1305
+ const id = generateId();
1306
+ // ULID-like format: base36 timestamp + random suffix (~16 characters)
1307
+ // Time-ordered, collision-resistant, URL-safe
1308
+ ```
1309
+
1310
+ ### Logger
1311
+
1312
+ ```typescript
1313
+ import { ConsoleLogger, SilentLogger, createScopedLogger } from '@multiplier-labs/stepflow';
1314
+
1315
+ // Logs to console with prefix
1316
+ const logger = new ConsoleLogger('[my-app]'); // default prefix: '[workflow]'
1317
+
1318
+ // No-op logger
1319
+ const silent = new SilentLogger();
1320
+
1321
+ // Create a scoped logger (prefixes messages with runId/stepKey)
1322
+ const scoped = createScopedLogger(logger, runId, stepKey?);
1323
+
1324
+ // Logger interface (compatible with pino, winston, etc.)
1325
+ interface Logger {
1326
+ debug(message: string, ...args: unknown[]): void;
1327
+ info(message: string, ...args: unknown[]): void;
1328
+ warn(message: string, ...args: unknown[]): void;
1329
+ error(message: string, ...args: unknown[]): void;
1330
+ }
1331
+ ```
1332
+
1333
+ ### Retry Utilities
1334
+
1335
+ ```typescript
1336
+ import { withRetry, sleep, calculateRetryDelay, DEFAULT_RETRY_OPTIONS } from '@multiplier-labs/stepflow';
1337
+
1338
+ // Retry a function with exponential backoff
1339
+ const result = await withRetry(
1340
+ () => fetchFromApi(),
1341
+ {
1342
+ maxRetries: 3, // default: 3
1343
+ delay: 1000, // default: 1000ms
1344
+ backoff: 2, // default: 2 (exponential)
1345
+ signal: abortSignal, // optional
1346
+ onRetry: (attempt, error, nextDelay) => {
1347
+ console.log(`Retry ${attempt}, next delay: ${nextDelay}ms`);
1348
+ },
1349
+ }
1350
+ );
1351
+
1352
+ // Sleep with abort support
1353
+ await sleep(1000, abortSignal?);
1354
+
1355
+ // Calculate delay for a given attempt
1356
+ const delay = calculateRetryDelay(attempt, baseDelay, backoff);
1357
+ ```
1358
+
1359
+ ---
1360
+
1361
+ ## TypeScript Types Reference
1362
+
1363
+ All types are exported from the main entry point:
1364
+
1365
+ ```typescript
1366
+ import type {
1367
+ // Core
1368
+ WorkflowKind, RunStatus, StepStatus, StepErrorStrategy,
1369
+ WorkflowError, RunResult, Logger, SpawnChildOptions,
1370
+ WorkflowContext, WorkflowStep, WorkflowHooks, WorkflowDefinition,
1371
+
1372
+ // Engine
1373
+ WorkflowEngineConfig, StartRunOptions,
1374
+
1375
+ // Storage (legacy)
1376
+ StorageAdapter, WorkflowRunRecord, WorkflowRunStepRecord,
1377
+ WorkflowEventRecord, ListRunsOptions, ListEventsOptions,
1378
+ PaginatedResult,
1379
+
1380
+ // Storage (extended)
1381
+ WorkflowStorage, ExtendedWorkflowRunRecord, ExtendedRunStatus,
1382
+ ExtendedStepStatus, ExtendedListRunsOptions,
1383
+ CreateRunInput, UpdateRunInput, StepResult, StepRecord,
1384
+ StepflowRunsTable, StepflowStepResultsTable, StepflowDatabase,
1385
+
1386
+ // Events
1387
+ EventTransport, WorkflowEvent, WorkflowEventType,
1388
+ BuiltInEventType, EventCallback, Unsubscribe,
1389
+ SocketIOEventTransportConfig, WebhookEventTransportConfig,
1390
+ WebhookEndpoint, WebhookPayload,
1391
+
1392
+ // Scheduler
1393
+ TriggerType, WorkflowSchedule, Scheduler,
1394
+ CronSchedulerConfig, SchedulePersistence,
1395
+ PostgresSchedulePersistenceConfig,
1396
+
1397
+ // Planning
1398
+ Recipe, RecipeStep, RecipeCondition, RecipeDefaults,
1399
+ Plan, PlannedStep, PlanModification, PlanModificationType,
1400
+ Planner, PlanningContext, PlanningConstraints, PlanningHints,
1401
+ ResourceEstimate, RecipeSelectionResult, PlanValidationResult,
1402
+ StepHandlerRegistry, RecipeRegistry, RegisteredStepHandler,
1403
+ RecipeQueryOptions, ConditionOperator, StepHandlerRef,
1404
+ ChildWorkflowPlan, PlanningPriority,
1405
+
1406
+ // Config
1407
+ PostgresStorageConfig, SQLiteStorageConfig,
1408
+ SQLiteSchedulePersistenceConfig,
1409
+
1410
+ // Retry
1411
+ RetryOptions,
1412
+ } from '@multiplier-labs/stepflow';
1413
+
1414
+ // Classes and values
1415
+ import {
1416
+ WorkflowEngine,
1417
+ MemoryStorageAdapter, SQLiteStorageAdapter,
1418
+ PostgresStorageAdapter, PostgresStorageAdapter as PostgresStorage,
1419
+ MemoryEventTransport, SocketIOEventTransport, WebhookEventTransport,
1420
+ CronScheduler, SQLiteSchedulePersistence, PostgresSchedulePersistence,
1421
+ MemoryStepHandlerRegistry, MemoryRecipeRegistry,
1422
+ createRegistry, RuleBasedPlanner,
1423
+ WorkflowEngineError, WorkflowNotFoundError,
1424
+ WorkflowAlreadyRegisteredError, RunNotFoundError,
1425
+ StepError, StepTimeoutError,
1426
+ WorkflowCanceledError, WorkflowTimeoutError,
1427
+ ConsoleLogger, SilentLogger, createScopedLogger,
1428
+ generateId, sleep, withRetry, calculateRetryDelay,
1429
+ DEFAULT_RETRY_OPTIONS,
1430
+ } from '@multiplier-labs/stepflow';
1431
+ ```
1432
+
1433
+ Subpath exports are also available:
1434
+
1435
+ ```typescript
1436
+ import { ... } from '@multiplier-labs/stepflow/storage';
1437
+ import { ... } from '@multiplier-labs/stepflow/events';
1438
+ import { ... } from '@multiplier-labs/stepflow/scheduler';
1439
+ ```
1440
+
1441
+ ---
1442
+
1443
+ ## Downstream Integration Patterns
1444
+
1445
+ This section documents patterns for integrating Stepflow into application services. The examples below are based on a data synchronization use case but apply to any domain.
1446
+
1447
+ ### Fastify Plugin Pattern
1448
+
1449
+ Wrap Stepflow storage as a Fastify plugin for API-level workflow management:
1450
+
1451
+ ```typescript
1452
+ // plugins/stepflow.ts
1453
+ import fp from 'fastify-plugin';
1454
+ import { PostgresStorageAdapter, type WorkflowRunRecord, type PaginatedResult } from '@multiplier-labs/stepflow';
1455
+
1456
+ export interface StepflowService {
1457
+ storage: PostgresStorageAdapter;
1458
+
1459
+ queueWorkflow(
1460
+ kind: string,
1461
+ input: Record<string, unknown>,
1462
+ options?: { priority?: number; timeoutMs?: number }
1463
+ ): Promise<WorkflowRunRecord>;
1464
+
1465
+ getRun(runId: string): Promise<WorkflowRunRecord | undefined>;
1466
+
1467
+ listRuns(options?: {
1468
+ kind?: string;
1469
+ status?: string | string[];
1470
+ limit?: number;
1471
+ offset?: number;
1472
+ }): Promise<PaginatedResult<WorkflowRunRecord>>;
1473
+
1474
+ cancelRun(runId: string): Promise<void>;
1475
+ }
1476
+
1477
+ export default fp(async (fastify) => {
1478
+ const storage = new PostgresStorageAdapter({
1479
+ pool: fastify.pg.pool, // share the app's pool
1480
+ schema: 'stepflow',
1481
+ });
1482
+ await storage.initialize();
1483
+
1484
+ const service: StepflowService = {
1485
+ storage,
1486
+
1487
+ async queueWorkflow(kind, input, options = {}) {
1488
+ return storage.createRun({
1489
+ kind,
1490
+ status: 'queued',
1491
+ input,
1492
+ context: {},
1493
+ priority: options.priority ?? 0,
1494
+ timeoutMs: options.timeoutMs,
1495
+ });
1496
+ },
1497
+
1498
+ async getRun(runId) {
1499
+ return (await storage.getRun(runId)) ?? undefined;
1500
+ },
1501
+
1502
+ async listRuns(options = {}) {
1503
+ return storage.listRuns(options);
1504
+ },
1505
+
1506
+ async cancelRun(runId) {
1507
+ await storage.updateRun(runId, {
1508
+ status: 'canceled',
1509
+ finishedAt: new Date(),
1510
+ });
1511
+ },
1512
+ };
1513
+
1514
+ fastify.decorate('stepflow', service);
1515
+
1516
+ fastify.addHook('onClose', async () => {
1517
+ await storage.close();
1518
+ });
1519
+ });
1520
+ ```
1521
+
1522
+ ### Worker Service Pattern
1523
+
1524
+ A background worker that polls for queued workflows:
1525
+
1526
+ ```typescript
1527
+ import { PostgresStorageAdapter } from '@multiplier-labs/stepflow';
1528
+
1529
+ const storage = new PostgresStorageAdapter({
1530
+ connectionString: process.env.DATABASE_URL,
1531
+ schema: 'stepflow',
1532
+ });
1533
+ await storage.initialize();
1534
+
1535
+ let currentRunId: string | undefined;
1536
+ let running = true;
1537
+
1538
+ // Poll loop
1539
+ async function poll() {
1540
+ while (running) {
1541
+ const run = await storage.dequeueRun(['sync.xero', 'sync.directo']);
1542
+
1543
+ if (run) {
1544
+ currentRunId = run.id;
1545
+ try {
1546
+ await processRun(run);
1547
+ await storage.updateRun(run.id, {
1548
+ status: 'succeeded',
1549
+ output: { /* results */ },
1550
+ finishedAt: new Date(),
1551
+ });
1552
+ } catch (err) {
1553
+ await storage.updateRun(run.id, {
1554
+ status: 'failed',
1555
+ error: { code: 'WORKFLOW_ERROR', message: err.message },
1556
+ finishedAt: new Date(),
1557
+ });
1558
+ } finally {
1559
+ currentRunId = undefined;
1560
+ }
1561
+ }
1562
+
1563
+ await new Promise(r => setTimeout(r, 5000));
1564
+
1565
+ // Periodic stale run cleanup
1566
+ await storage.cleanupStaleRuns(600000);
1567
+ }
1568
+ }
1569
+
1570
+ // Graceful shutdown
1571
+ process.on('SIGTERM', async () => {
1572
+ running = false;
1573
+ if (currentRunId) {
1574
+ await storage.markRunsAsFailed(
1575
+ [currentRunId],
1576
+ 'Worker process was terminated during workflow execution'
1577
+ );
1578
+ }
1579
+ await storage.close();
1580
+ process.exit(0);
1581
+ });
1582
+ ```
1583
+
1584
+ ### Step Tracking with saveStepResult
1585
+
1586
+ Track granular step progress within a workflow run:
1587
+
1588
+ ```typescript
1589
+ async function processRun(run: WorkflowRunRecord) {
1590
+ const steps = ['fetch_accounts', 'fetch_contacts', 'fetch_invoices'];
1591
+
1592
+ for (const stepName of steps) {
1593
+ const startedAt = new Date();
1594
+
1595
+ await storage.saveStepResult({
1596
+ runId: run.id,
1597
+ stepName,
1598
+ status: 'running',
1599
+ attempt: 1,
1600
+ startedAt,
1601
+ });
1602
+
1603
+ try {
1604
+ const result = await executeStep(stepName, run.input);
1605
+
1606
+ await storage.saveStepResult({
1607
+ runId: run.id,
1608
+ stepName,
1609
+ status: 'completed',
1610
+ output: { rowsRead: result.read, rowsWritten: result.written },
1611
+ attempt: 1,
1612
+ startedAt,
1613
+ completedAt: new Date(),
1614
+ });
1615
+ } catch (err) {
1616
+ await storage.saveStepResult({
1617
+ runId: run.id,
1618
+ stepName,
1619
+ status: 'failed',
1620
+ error: { code: 'STEP_FAILED', message: err.message },
1621
+ attempt: 1,
1622
+ startedAt,
1623
+ completedAt: new Date(),
1624
+ });
1625
+ throw err;
1626
+ }
1627
+ }
1628
+ }
1629
+ ```
1630
+
1631
+ ### API Routes Pattern
1632
+
1633
+ ```typescript
1634
+ // List runs
1635
+ fastify.get('/api/workflows', async (request) => {
1636
+ const { items, total } = await fastify.stepflow.listRuns({ limit: 100 });
1637
+ return { data: items, total };
1638
+ });
1639
+
1640
+ // Get run with steps
1641
+ fastify.get('/api/workflows/:runId', async (request) => {
1642
+ const { runId } = request.params;
1643
+ const run = await fastify.stepflow.getRun(runId);
1644
+ if (!run) return reply.code(404).send({ error: 'Not found' });
1645
+
1646
+ const steps = await fastify.stepflow.storage.getStepResults(runId);
1647
+ return { data: { ...run, steps } };
1648
+ });
1649
+
1650
+ // Queue a workflow
1651
+ fastify.post('/api/workflows/trigger', async (request) => {
1652
+ const { kind, input, priority } = request.body;
1653
+ const run = await fastify.stepflow.queueWorkflow(kind, input, { priority });
1654
+ return { data: { runId: run.id, status: run.status } };
1655
+ });
1656
+
1657
+ // Cancel a workflow
1658
+ fastify.post('/api/workflows/:runId/cancel', async (request) => {
1659
+ await fastify.stepflow.cancelRun(request.params.runId);
1660
+ return { data: { message: 'Workflow canceled' } };
1661
+ });
1662
+ ```