@jagreehal/workflow 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,36 +2,474 @@
2
2
 
3
3
  Typed async workflows with automatic error inference. Build type-safe workflows with Result types, step caching, resume state, and human-in-the-loop support.
4
4
 
5
+ **You've been here before:** You're debugging a production issue at 2am. The error says "Failed to load user data." But *why* did it fail? Was it the database? The cache? The API? TypeScript can't help you - it just sees `unknown` in every catch block.
6
+
7
+ This library fixes that. Your errors become **first-class citizens** with full type inference, so TypeScript knows exactly what can go wrong before your code even runs.
8
+
5
9
  ```bash
6
10
  npm install @jagreehal/workflow
7
11
  ```
8
12
 
9
- ## The Problem
13
+ **What you get:**
14
+
15
+ - **Automatic error inference** - Error types flow from your dependencies. Add a step? The union updates. Remove one? It updates. Zero manual tracking.
16
+ - **Built-in reliability** - Retries, timeouts, caching, and circuit breakers when you need them. Not before.
17
+ - **Resume & approvals** - Pause workflows for human review, persist state, pick up where you left off.
18
+ - **Full visibility** - Event streams, ASCII timelines, Mermaid diagrams. See what ran, what failed, and why.
19
+
20
+ ## Quickstart (60 Seconds)
21
+
22
+ ### 1. Define your operations
23
+
24
+ Return `ok(value)` or `err(errorCode)` instead of throwing.
25
+
26
+ ```typescript
27
+ import { ok, err, type AsyncResult } from '@jagreehal/workflow';
28
+
29
+ const fetchOrder = async (id: string): AsyncResult<Order, 'ORDER_NOT_FOUND'> =>
30
+ id ? ok({ id, total: 99.99, email: 'user@example.com' }) : err('ORDER_NOT_FOUND');
31
+
32
+ const chargeCard = async (amount: number): AsyncResult<Payment, 'CARD_DECLINED'> =>
33
+ amount < 10000 ? ok({ id: 'pay_123', amount }) : err('CARD_DECLINED');
34
+ ```
35
+
36
+ ### 2. Create and run
37
+
38
+ `createWorkflow` handles the type magic. `step()` unwraps results or exits early on failure.
39
+
40
+ ```typescript
41
+ import { createWorkflow } from '@jagreehal/workflow';
42
+
43
+ const checkout = createWorkflow({ fetchOrder, chargeCard });
44
+
45
+ const result = await checkout(async (step) => {
46
+ const order = await step(fetchOrder('order_456'));
47
+ const payment = await step(chargeCard(order.total));
48
+ return { order, payment };
49
+ });
50
+ // result.error is: 'ORDER_NOT_FOUND' | 'CARD_DECLINED' | UnexpectedError
51
+ ```
52
+
53
+ That's it! TypeScript knows exactly what can fail. Now let's see the full power.
54
+
55
+ ## See It In Action
56
+
57
+ **The async/await trap:**
10
58
 
11
59
  ```typescript
12
- // try/catch loses error attribution
13
- async function loadUserData(id: string) {
60
+ // TypeScript sees: Promise<{ user, posts }> - errors are invisible
61
+ async function loadUserData(userId: string) {
14
62
  try {
15
- const user = await fetchUser(id);
16
- const posts = await fetchPosts(user.id);
63
+ const user = await fetchUser(userId); // might throw 'NOT_FOUND'
64
+ const posts = await fetchPosts(user.id); // might throw 'FETCH_ERROR'
17
65
  return { user, posts };
18
- } catch {
19
- return null; // What failed? Who knows.
66
+ } catch (e) {
67
+ // What went wrong? TypeScript has no idea.
68
+ // Was it NOT_FOUND? FETCH_ERROR? A network timeout?
69
+ throw new Error('Failed'); // All context lost
20
70
  }
21
71
  }
22
72
  ```
23
73
 
24
- ## The Solution
74
+ **With workflow:**
75
+
76
+ ```typescript
77
+ // ✅ TypeScript knows: Result<{ user, posts }, 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError>
78
+ const loadUserData = createWorkflow({ fetchUser, fetchPosts });
79
+
80
+ const userId = '123';
81
+ const result = await loadUserData(async (step) => {
82
+ const user = await step(() => fetchUser(userId), {
83
+ retry: { attempts: 3, backoff: 'exponential' }
84
+ });
85
+ const posts = await step(() => fetchPosts(user.id));
86
+ return { user, posts };
87
+ });
88
+
89
+ if (result.ok) {
90
+ console.log(result.value.user.name); // Fully typed
91
+ } else {
92
+ switch (result.error) {
93
+ case 'NOT_FOUND': // handle missing user
94
+ case 'FETCH_ERROR': // handle posts failure
95
+ }
96
+ }
97
+ ```
98
+
99
+ The magic: error types are **inferred from your dependencies**. Add `fetchComments`? The error union updates automatically. You'll never `switch` on an error that can't happen, or miss one that can.
100
+
101
+ ## How It Works
102
+
103
+ ```mermaid
104
+ flowchart TD
105
+ subgraph "step() unwraps Results, exits early on error"
106
+ S1["step(fetchUser)"] -->|ok| S2["step(fetchPosts)"]
107
+ S2 -->|ok| S3["step(sendEmail)"]
108
+ S3 -->|ok| S4["✓ Success"]
109
+
110
+ S1 -.->|error| EXIT["Return error"]
111
+ S2 -.->|error| EXIT
112
+ S3 -.->|error| EXIT
113
+ end
114
+ ```
115
+
116
+ Each `step()` unwraps a `Result`. If it's `ok`, you get the value and continue. If it's an error, the workflow exits immediately, no manual `if (result.isErr())` checks needed. The happy path stays clean.
117
+
118
+ ---
119
+
120
+ ## Key Features
121
+
122
+ ### 🛡️ Built-in Reliability
123
+
124
+ Add resilience exactly where you need it - no nested try/catch or custom retry loops.
125
+
126
+ ```typescript
127
+ const result = await workflow(async (step) => {
128
+ // Retry 3 times with exponential backoff, timeout after 5 seconds
129
+ const user = await step.retry(
130
+ () => fetchUser('1'),
131
+ { attempts: 3, backoff: 'exponential', timeout: { ms: 5000 } }
132
+ );
133
+ return user;
134
+ });
135
+ ```
136
+
137
+ ### 💾 Smart Caching (Never Double-Charge a Customer)
138
+
139
+ Use stable keys to ensure a step only runs once, even if the workflow crashes and restarts.
140
+
141
+ ```typescript
142
+ const result = await processPayment(async (step) => {
143
+ // If the workflow crashes after charging but before saving,
144
+ // the next run skips the charge - it's already cached.
145
+ const charge = await step(() => chargeCard(amount), {
146
+ key: `charge:${order.idempotencyKey}`,
147
+ });
148
+
149
+ await step(() => saveToDatabase(charge), {
150
+ key: `save:${charge.id}`,
151
+ });
152
+
153
+ return charge;
154
+ });
155
+ ```
156
+
157
+ ### 💾 Save & Resume (Persist Workflows Across Restarts)
158
+
159
+ Save workflow state to a database and resume later from exactly where you left off. Perfect for long-running workflows, crash recovery, or pausing for approvals.
160
+
161
+ **Step 1: Collect state during execution**
162
+
163
+ ```typescript
164
+ import { createWorkflow, createStepCollector } from '@jagreehal/workflow';
165
+
166
+ // Create a collector to automatically capture step results
167
+ const collector = createStepCollector();
168
+
169
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
170
+ onEvent: collector.handleEvent, // Automatically collects step_complete events
171
+ });
172
+
173
+ await workflow(async (step) => {
174
+ // Only steps with keys are saved
175
+ const user = await step(() => fetchUser("1"), { key: "user:1" });
176
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
177
+ return { user, posts };
178
+ });
179
+
180
+ // Get the collected state
181
+ const state = collector.getState(); // Returns ResumeState
182
+ ```
183
+
184
+ **Step 2: Save to database**
25
185
 
26
186
  ```typescript
27
- import { createWorkflow, ok, err, type AsyncResult } from '@jagreehal/workflow';
187
+ import { stringifyState, parseState } from '@jagreehal/workflow';
188
+
189
+ // Serialize to JSON
190
+ const json = stringifyState(state, { workflowId: "123", timestamp: Date.now() });
191
+
192
+ // Save to your database
193
+ await db.workflowStates.create({
194
+ id: workflowId,
195
+ state: json,
196
+ createdAt: new Date(),
197
+ });
198
+ ```
199
+
200
+ **Step 3: Resume from saved state**
201
+
202
+ ```typescript
203
+ // Load from database
204
+ const saved = await db.workflowStates.findUnique({ where: { id: workflowId } });
205
+ const savedState = parseState(saved.state);
206
+
207
+ // Resume workflow - cached steps skip execution
208
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
209
+ resumeState: savedState, // Pre-populates cache from saved state
210
+ });
211
+
212
+ await workflow(async (step) => {
213
+ const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit - no fetchUser call
214
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
215
+ return { user, posts };
216
+ });
217
+ ```
218
+
219
+ **With database adapter (Redis, DynamoDB, etc.)**
220
+
221
+ ```typescript
222
+ import { createStatePersistence } from '@jagreehal/workflow';
223
+ import { createClient } from 'redis';
224
+
225
+ const redis = createClient();
226
+ await redis.connect();
227
+
228
+ // Create persistence adapter
229
+ const persistence = createStatePersistence({
230
+ get: (key) => redis.get(key),
231
+ set: (key, value) => redis.set(key, value),
232
+ delete: (key) => redis.del(key).then(n => n > 0),
233
+ exists: (key) => redis.exists(key).then(n => n > 0),
234
+ keys: (pattern) => redis.keys(pattern),
235
+ }, 'workflow:state:');
236
+
237
+ // Save
238
+ await persistence.save(runId, state, { metadata: { userId: 'user-1' } });
239
+
240
+ // Load
241
+ const savedState = await persistence.load(runId);
242
+
243
+ // Resume
244
+ const workflow = createWorkflow(deps, { resumeState: savedState });
245
+ ```
246
+
247
+ **Key points:**
248
+ - Only steps with `key` options are saved (unkeyed steps execute fresh on resume)
249
+ - Error results are preserved with metadata for proper replay
250
+ - You can also pass an async function: `resumeState: async () => await loadFromDB()`
251
+ - Works seamlessly with HITL approvals and crash recovery
252
+
253
+ ### 🧑‍💻 Human-in-the-Loop
254
+
255
+ Pause for manual approvals (large transfers, deployments, refunds) and resume exactly where you left off.
256
+
257
+ ```typescript
258
+ const requireApproval = createApprovalStep({
259
+ key: 'approve:refund',
260
+ checkApproval: async () => {
261
+ const status = await db.getApprovalStatus('refund_123');
262
+ return status ? { status: 'approved', value: status } : { status: 'pending' };
263
+ },
264
+ });
265
+
266
+ const result = await refundWorkflow(async (step) => {
267
+ const refund = await step(calculateRefund(orderId));
268
+
269
+ // Workflow pauses here until someone approves
270
+ const approval = await step(requireApproval, { key: 'approve:refund' });
271
+
272
+ return await step(processRefund(refund, approval));
273
+ });
274
+
275
+ if (!result.ok && isPendingApproval(result.error)) {
276
+ // Notify Slack, send email, etc.
277
+ // Later: injectApproval(savedState, { stepKey, value })
278
+ }
279
+ ```
280
+
281
+ ### 📊 Visualize What Happened
282
+
283
+ Hook into the event stream to generate diagrams for logs, PRs, or dashboards.
284
+
285
+ ```typescript
286
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
287
+
288
+ const viz = createVisualizer({ workflowName: 'checkout' });
289
+ const workflow = createWorkflow({ fetchOrder, chargeCard }, {
290
+ onEvent: viz.handleEvent,
291
+ });
292
+
293
+ await workflow(async (step) => {
294
+ const order = await step(() => fetchOrder('order_456'), { name: 'Fetch order' });
295
+ const payment = await step(() => chargeCard(order.total), { name: 'Charge card' });
296
+ return { order, payment };
297
+ });
298
+
299
+ console.log(viz.renderAs('mermaid'));
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Start Here
305
+
306
+ Let's build something real in five short steps. Each one adds a single concept - by the end, you'll have a working workflow with typed errors, retries, and full observability.
307
+
308
+ ### Step 1 - Install
309
+
310
+ ```bash
311
+ npm install @jagreehal/workflow
312
+ # or
313
+ pnpm add @jagreehal/workflow
314
+ ```
315
+
316
+ ### Step 2 - Describe Async Dependencies
317
+
318
+ Define the units of work as `AsyncResult<T, E>` helpers. Results encode success (`ok`) or typed failure (`err`).
319
+
320
+ ```typescript
321
+ import { ok, err, type AsyncResult } from '@jagreehal/workflow';
322
+
323
+ type User = { id: string; name: string };
28
324
 
29
325
  const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
30
326
  id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
327
+ ```
328
+
329
+ ### Step 3 - Compose a Workflow
31
330
 
331
+ `createWorkflow` collects dependencies once so the library can infer the total error union.
332
+
333
+ ```typescript
334
+ import { createWorkflow } from '@jagreehal/workflow';
335
+
336
+ const workflow = createWorkflow({ fetchUser });
337
+ ```
338
+
339
+ ### Step 4 - Run & Inspect Results
340
+
341
+ Use `step()` inside the executor. It unwraps results, exits early on failure, and gives a typed `result` back to you.
342
+
343
+ ```typescript
344
+ const result = await workflow(async (step) => {
345
+ const user = await step(fetchUser('1'));
346
+ return user;
347
+ });
348
+
349
+ if (result.ok) {
350
+ console.log(result.value.name);
351
+ } else {
352
+ console.error(result.error); // 'NOT_FOUND' | UnexpectedError
353
+ }
354
+ ```
355
+
356
+ ### Step 5 - Add Safeguards
357
+
358
+ Introduce retries, timeout protection, or wrappers for throwing code only when you need them.
359
+
360
+ ```typescript
361
+ const data = await workflow(async (step) => {
362
+ const user = await step(fetchUser('1'));
363
+
364
+ const posts = await step.try(
365
+ () => fetch(`/api/users/${user.id}/posts`).then((r) => {
366
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
367
+ return r.json();
368
+ }),
369
+ { error: 'FETCH_FAILED' as const }
370
+ );
371
+
372
+ return { user, posts };
373
+ });
374
+ ```
375
+
376
+ That's the foundation. Now let's build on it.
377
+
378
+ ---
379
+
380
+ ## Persistence Quickstart
381
+
382
+ Save workflow state to a database and resume later. Perfect for crash recovery, long-running workflows, or pausing for approvals.
383
+
384
+ ### Basic Save & Resume
385
+
386
+ ```typescript
387
+ import { createWorkflow, createStepCollector, stringifyState, parseState } from '@jagreehal/workflow';
388
+
389
+ // 1. Collect state during execution
390
+ const collector = createStepCollector();
391
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
392
+ onEvent: collector.handleEvent,
393
+ });
394
+
395
+ await workflow(async (step) => {
396
+ const user = await step(() => fetchUser("1"), { key: "user:1" });
397
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
398
+ return { user, posts };
399
+ });
400
+
401
+ // 2. Save to database
402
+ const state = collector.getState();
403
+ const json = stringifyState(state, { workflowId: "123" });
404
+ await db.workflowStates.create({ id: "123", state: json });
405
+
406
+ // 3. Resume later
407
+ const saved = await db.workflowStates.findUnique({ where: { id: "123" } });
408
+ const savedState = parseState(saved.state);
409
+
410
+ const resumed = createWorkflow({ fetchUser, fetchPosts }, {
411
+ resumeState: savedState,
412
+ });
413
+
414
+ // Cached steps skip execution automatically
415
+ await resumed(async (step) => {
416
+ const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
417
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
418
+ return { user, posts };
419
+ });
420
+ ```
421
+
422
+ ### With Database Adapter (Redis, DynamoDB, etc.)
423
+
424
+ ```typescript
425
+ import { createStatePersistence } from '@jagreehal/workflow';
426
+ import { createClient } from 'redis';
427
+
428
+ const redis = createClient();
429
+ await redis.connect();
430
+
431
+ // Create persistence adapter
432
+ const persistence = createStatePersistence({
433
+ get: (key) => redis.get(key),
434
+ set: (key, value) => redis.set(key, value),
435
+ delete: (key) => redis.del(key).then(n => n > 0),
436
+ exists: (key) => redis.exists(key).then(n => n > 0),
437
+ keys: (pattern) => redis.keys(pattern),
438
+ }, 'workflow:state:');
439
+
440
+ // Save
441
+ const collector = createStepCollector();
442
+ const workflow = createWorkflow(deps, { onEvent: collector.handleEvent });
443
+ await workflow(async (step) => { /* ... */ });
444
+
445
+ await persistence.save('run-123', collector.getState(), { userId: 'user-1' });
446
+
447
+ // Load and resume
448
+ const savedState = await persistence.load('run-123');
449
+ const resumed = createWorkflow(deps, { resumeState: savedState });
450
+ ```
451
+
452
+ **See the [Save & Resume](#-save--resume-persist-workflows-across-restarts) section for more details.**
453
+
454
+ ---
455
+
456
+ ## Guided Tutorial
457
+
458
+ We'll take a single workflow through four stages - from basic to production-ready. Each stage builds on the last, so you'll see how features compose naturally.
459
+
460
+ ### Stage 1 - Hello Workflow
461
+
462
+ 1. Declare dependencies (`fetchUser`, `fetchPosts`).
463
+ 2. Create the workflow: `const loadUserData = createWorkflow({ fetchUser, fetchPosts })`.
464
+ 3. Use `step()` to fan out and gather results.
465
+
466
+ ```typescript
32
467
  const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
33
468
  ok([{ id: 1, title: 'Hello World' }]);
34
469
 
470
+ const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
471
+ id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
472
+
35
473
  const loadUserData = createWorkflow({ fetchUser, fetchPosts });
36
474
 
37
475
  const result = await loadUserData(async (step) => {
@@ -39,33 +477,138 @@ const result = await loadUserData(async (step) => {
39
477
  const posts = await step(fetchPosts(user.id));
40
478
  return { user, posts };
41
479
  });
480
+ ```
481
+
482
+ ### Stage 2 - Validation & Branching
483
+
484
+ Add validation helpers and watch the error union update automatically.
485
+
486
+ ```typescript
487
+ const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
488
+ email.includes('@') ? ok(email) : err('INVALID_EMAIL');
42
489
 
43
- // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError
44
- // ↑ Computed automatically from { fetchUser, fetchPosts }
490
+ const signUp = createWorkflow({ validateEmail, fetchUser });
491
+
492
+ const result = await signUp(async (step) => {
493
+ const email = await step(validateEmail('user@example.com'));
494
+ const user = await step(fetchUser(email));
495
+ return { email, user };
496
+ });
45
497
  ```
46
498
 
47
- `step()` unwraps Results. On error, workflow exits early.
499
+ ### Stage 3 - Reliability Features
500
+
501
+ Layer in retries, caching, and timeouts only around the calls that need them.
502
+
503
+ ```typescript
504
+ const resilientWorkflow = createWorkflow({ fetchUser, fetchPosts }, {
505
+ cache: new Map(),
506
+ });
507
+
508
+ const result = await resilientWorkflow(async (step) => {
509
+ const user = await step(() => fetchUser('1'), {
510
+ key: 'user:1',
511
+ retry: { attempts: 3, backoff: 'exponential' },
512
+ });
513
+
514
+ const posts = await step.withTimeout(
515
+ () => fetchPosts(user.id),
516
+ { ms: 5000, name: 'Fetch posts' }
517
+ );
48
518
 
49
- ## More Examples
519
+ return { user, posts };
520
+ });
521
+ ```
50
522
 
51
- ### User signup with multiple steps
523
+ ### Stage 4 - Human-in-the-Loop & Resume
524
+
525
+ Pause long-running workflows until an operator approves, then resume using persisted step results.
526
+
527
+ ```typescript
528
+ import {
529
+ createApprovalStep,
530
+ createWorkflow,
531
+ createStepCollector,
532
+ injectApproval,
533
+ isPendingApproval,
534
+ } from '@jagreehal/workflow';
535
+
536
+ // Use collector to automatically capture state
537
+ const collector = createStepCollector();
538
+ const requireApproval = createApprovalStep({
539
+ key: 'approval:deploy',
540
+ checkApproval: async () => ({ status: 'pending' }),
541
+ });
542
+
543
+ const gatedWorkflow = createWorkflow({ requireApproval }, {
544
+ onEvent: collector.handleEvent, // Automatically collects step results
545
+ });
546
+
547
+ const result = await gatedWorkflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
548
+
549
+ if (!result.ok && isPendingApproval(result.error)) {
550
+ // Get collected state
551
+ const state = collector.getState();
552
+
553
+ // Later, when approval is granted, inject it and resume
554
+ const updatedState = injectApproval(state, {
555
+ stepKey: 'approval:deploy',
556
+ value: { approvedBy: 'ops' },
557
+ });
558
+
559
+ // Resume with approval injected
560
+ const resumed = createWorkflow({ requireApproval }, { resumeState: updatedState });
561
+ await resumed(async (step) => step(requireApproval, { key: 'approval:deploy' })); // Uses injected approval
562
+ }
563
+ ```
564
+
565
+ ## Try It Yourself
566
+
567
+ - Open the [TypeScript Playground](https://www.typescriptlang.org/play) and paste any snippet from the tutorial.
568
+ - Prefer running locally? Save a file, run `npx tsx workflow-demo.ts`, and iterate with real dependencies.
569
+ - For interactive debugging, add `console.log` inside `onEvent` callbacks to visualize timing immediately.
570
+
571
+ ## Key Concepts
572
+
573
+ | Concept | What it does |
574
+ |---------|--------------|
575
+ | **Result** | `ok(value)` or `err(error)` - typed success/failure, no exceptions |
576
+ | **Workflow** | Wraps your dependencies and tracks their error types automatically |
577
+ | **step()** | Unwraps a Result, short-circuits on failure, enables caching/retries |
578
+ | **step.try** | Catches throws and converts them to typed errors |
579
+ | **step.fromResult** | Preserves rich error objects from other Result-returning code |
580
+ | **Events** | `onEvent` streams everything - timing, retries, failures - for visualization or logging |
581
+ | **Resume** | Save completed steps, pick up later (great for approvals or crashes) |
582
+ | **UnexpectedError** | Safety net for throws outside your declared union; use `strict` mode to force explicit handling |
583
+
584
+ ## Recipes & Patterns
585
+
586
+ ### Core Recipes
587
+
588
+ #### Basic Workflow
589
+
590
+ ```typescript
591
+ const result = await loadUserData(async (step) => {
592
+ const user = await step(fetchUser('1'));
593
+ const posts = await step(fetchPosts(user.id));
594
+ return { user, posts };
595
+ });
596
+ ```
597
+
598
+ #### User Signup
52
599
 
53
600
  ```typescript
54
601
  const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
55
602
  email.includes('@') ? ok(email) : err('INVALID_EMAIL');
56
603
 
57
- const checkDuplicate = async (email: string): AsyncResult<void, 'EMAIL_EXISTS'> => {
58
- const exists = email === 'taken@example.com';
59
- return exists ? err('EMAIL_EXISTS') : ok(undefined);
60
- };
604
+ const checkDuplicate = async (email: string): AsyncResult<void, 'EMAIL_EXISTS'> =>
605
+ email === 'taken@example.com' ? err('EMAIL_EXISTS') : ok(undefined);
61
606
 
62
607
  const createAccount = async (email: string): AsyncResult<{ id: string }, 'DB_ERROR'> =>
63
608
  ok({ id: crypto.randomUUID() });
64
609
 
65
- const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> =>
66
- ok(undefined);
610
+ const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> => ok(undefined);
67
611
 
68
- // Declare deps → error union computed automatically
69
612
  const signUp = createWorkflow({ validateEmail, checkDuplicate, createAccount, sendWelcome });
70
613
 
71
614
  const result = await signUp(async (step) => {
@@ -75,11 +618,10 @@ const result = await signUp(async (step) => {
75
618
  await step(sendWelcome(account.id));
76
619
  return account;
77
620
  });
78
-
79
621
  // result.error: 'INVALID_EMAIL' | 'EMAIL_EXISTS' | 'DB_ERROR' | 'EMAIL_FAILED' | UnexpectedError
80
622
  ```
81
623
 
82
- ### Checkout flow
624
+ #### Checkout Flow
83
625
 
84
626
  ```typescript
85
627
  const authenticate = async (token: string): AsyncResult<{ userId: string }, 'UNAUTHORIZED'> =>
@@ -99,11 +641,10 @@ const result = await checkout(async (step) => {
99
641
  const payment = await step(chargeCard(order.total));
100
642
  return { userId: auth.userId, txId: payment.txId };
101
643
  });
102
-
103
644
  // result.error: 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
104
645
  ```
105
646
 
106
- ### Composing workflows
647
+ #### Composing Workflows
107
648
 
108
649
  You can combine multiple workflows together. The error types automatically aggregate:
109
650
 
@@ -117,16 +658,7 @@ const validatePassword = async (pwd: string): AsyncResult<string, 'WEAK_PASSWORD
117
658
 
118
659
  const validationWorkflow = createWorkflow({ validateEmail, validatePassword });
119
660
 
120
- // Checkout workflow (from example above)
121
- const authenticate = async (token: string): AsyncResult<{ userId: string }, 'UNAUTHORIZED'> =>
122
- token === 'valid' ? ok({ userId: 'user-1' }) : err('UNAUTHORIZED');
123
-
124
- const fetchOrder = async (id: string): AsyncResult<{ total: number }, 'ORDER_NOT_FOUND'> =>
125
- ok({ total: 99 });
126
-
127
- const chargeCard = async (amount: number): AsyncResult<{ txId: string }, 'PAYMENT_FAILED'> =>
128
- ok({ txId: 'tx-123' });
129
-
661
+ // Checkout workflow
130
662
  const checkoutWorkflow = createWorkflow({ authenticate, fetchOrder, chargeCard });
131
663
 
132
664
  // Composed workflow: validation + checkout
@@ -139,94 +671,38 @@ const validateAndCheckout = createWorkflow({
139
671
  chargeCard,
140
672
  });
141
673
 
142
- const result = await validateAndCheckout(async (step, deps) => {
143
- // Run validation workflow as a step (workflows return AsyncResult)
144
- const validated = await step(() => validationWorkflow(async (innerStep) => {
145
- const email = await innerStep(deps.validateEmail('user@example.com'));
146
- const password = await innerStep(deps.validatePassword('secret123'));
147
- return { email, password };
148
- }));
149
-
150
- // Run checkout workflow as a step
151
- const checkout = await step(() => checkoutWorkflow(async (innerStep) => {
152
- const auth = await innerStep(deps.authenticate('valid'));
153
- const order = await innerStep(deps.fetchOrder('order-1'));
154
- const payment = await innerStep(deps.chargeCard(order.total));
155
- return { userId: auth.userId, txId: payment.txId };
156
- }));
157
-
158
- return { validated, checkout };
159
- });
160
-
161
- // result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
162
- // ↑ All error types from both workflows are automatically aggregated
163
- ```
164
-
165
- **Alternative approach**: You can also combine workflows by including all their dependencies in a single workflow:
166
-
167
- ```typescript
168
- // Simpler composition - combine all dependencies
169
- const composed = createWorkflow({
170
- validateEmail,
171
- validatePassword,
172
- authenticate,
173
- fetchOrder,
174
- chargeCard,
175
- });
176
-
177
- const result = await composed(async (step, deps) => {
674
+ const result = await validateAndCheckout(async (step) => {
178
675
  // Validation steps
179
- const email = await step(deps.validateEmail('user@example.com'));
180
- const password = await step(deps.validatePassword('secret123'));
676
+ const email = await step(validateEmail('user@example.com'));
677
+ const password = await step(validatePassword('secret123'));
181
678
 
182
679
  // Checkout steps
183
- const auth = await step(deps.authenticate('valid'));
184
- const order = await step(deps.fetchOrder('order-1'));
185
- const payment = await step(deps.chargeCard(order.total));
680
+ const auth = await step(authenticate('valid'));
681
+ const order = await step(fetchOrder('order-1'));
682
+ const payment = await step(chargeCard(order.total));
186
683
 
187
684
  return { email, password, userId: auth.userId, txId: payment.txId };
188
685
  });
189
- // Same error union: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
686
+ // result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
190
687
  ```
191
688
 
192
- ### Wrapping throwing APIs with step.try
689
+ ### Common Patterns
193
690
 
194
- ```typescript
195
- const workflow = createWorkflow({ fetchUser });
691
+ - **Validation & gating** – Run early workflows so later steps never execute for invalid data.
196
692
 
197
- const result = await workflow(async (step) => {
198
- const user = await step(fetchUser('1'));
693
+ ```typescript
694
+ const validated = await step(() => validationWorkflow(async (inner) => inner(deps.validateEmail(email))));
695
+ ```
199
696
 
200
- // step.try catches throws and rejections typed error
201
- const response = await step.try(
202
- () => fetch(`/api/posts/${user.id}`).then(r => {
203
- if (!r.ok) throw new Error(`HTTP ${r.status}`);
204
- return r.json();
205
- }),
206
- { error: 'FETCH_FAILED' as const }
207
- );
697
+ - **API calls with typed errors** – Wrap fetch/axios via `step.try` and switch on the union later.
208
698
 
209
- const posts = await step.try(
210
- () => JSON.parse(response),
211
- { error: 'PARSE_FAILED' as const }
212
- );
699
+ ```typescript
700
+ const payload = await step.try(() => fetch(url).then((r) => r.json()), { error: 'HTTP_FAILED' });
701
+ ```
213
702
 
214
- return { user, posts };
215
- });
216
-
217
- // result.error: 'NOT_FOUND' | 'FETCH_FAILED' | 'PARSE_FAILED' | UnexpectedError
218
- ```
703
+ - **Wrapping Result-returning functions** – Use `step.fromResult` to preserve rich error types.
219
704
 
220
- ### Wrapping Result-returning functions with step.fromResult
221
-
222
- When calling functions that return `Result<T, E>`, use `step.fromResult()` to map their typed errors:
223
-
224
- ```typescript
225
- // callProvider returns Result<Response, ProviderError>
226
- const callProvider = async (input: string): AsyncResult<Response, ProviderError> => { ... };
227
-
228
- const result = await workflow(async (step) => {
229
- // step.fromResult gives you typed errors in onError (not unknown like step.try)
705
+ ```typescript
230
706
  const response = await step.fromResult(
231
707
  () => callProvider(input),
232
708
  {
@@ -237,271 +713,370 @@ const result = await workflow(async (step) => {
237
713
  })
238
714
  }
239
715
  );
716
+ ```
240
717
 
241
- return response;
242
- });
243
- ```
718
+ - **Retries, backoff, and timeouts** – Built into `step.retry()` and `step.withTimeout()`.
244
719
 
245
- Unlike `step.try()` where `onError` receives `unknown`, `step.fromResult()` preserves the error type.
720
+ ```typescript
721
+ const data = await step.retry(
722
+ () => step.withTimeout(() => fetchData(), { ms: 2000 }),
723
+ { attempts: 3, backoff: 'exponential', retryOn: (error) => error !== 'FATAL' }
724
+ );
725
+ ```
246
726
 
247
- ### Parallel operations
727
+ - **State save & resume** – Persist step completions and resume later.
248
728
 
249
- ```typescript
250
- import { allAsync, partition, map } from '@jagreehal/workflow';
729
+ ```typescript
730
+ import { createWorkflow, createStepCollector } from '@jagreehal/workflow';
251
731
 
252
- // First error wins
253
- const result = await allAsync([
254
- fetchUser('1'),
255
- fetchPosts('1'),
256
- ]);
257
- const data = map(result, ([user, posts]) => ({ user, posts }));
732
+ // Collect state during execution
733
+ const collector = createStepCollector();
734
+ const workflow = createWorkflow(deps, {
735
+ onEvent: collector.handleEvent, // Automatically collects step_complete events
736
+ });
258
737
 
259
- // Collect all results, even failures
260
- const results = await Promise.all(userIds.map(id => fetchUser(id)));
261
- const { values: users, errors } = partition(results);
262
- ```
738
+ await workflow(async (step) => {
739
+ const user = await step(() => fetchUser("1"), { key: "user:1" });
740
+ return user;
741
+ });
263
742
 
264
- ### Consuming results
743
+ // Get collected state
744
+ const state = collector.getState();
265
745
 
266
- ```typescript
267
- if (result.ok) {
268
- console.log(result.value.user.name);
269
- } else {
270
- console.log(result.error); // Typed error union
271
- }
272
- ```
746
+ // Resume later
747
+ const resumed = createWorkflow(deps, { resumeState: state });
748
+ ```
273
749
 
274
- ## Quick Reference
750
+ - **Human-in-the-loop approvals** – Pause a workflow until someone approves.
275
751
 
276
- | Function | What it does |
277
- |----------|--------------|
278
- | `createWorkflow(deps)` | Create workflow with auto-inferred error types |
279
- | `run(callback, options)` | Execute workflow with manual error types |
280
- | `step(op())` | Unwrap Result or exit early |
281
- | `step.try(fn, { error })` | Catch throws/rejects → typed error |
282
- | `step.fromResult(fn, { onError })` | Map Result errors with typed onError |
283
- | `step.retry(fn, opts)` | Retry with backoff on failure |
284
- | `step.withTimeout(fn, { ms })` | Timeout after specified duration |
285
- | `ok(value)` / `err(error)` | Create Results |
286
- | `map`, `andThen`, `match` | Transform Results |
287
- | `allAsync`, `partition` | Batch operations |
288
- | `isStepTimeoutError(e)` | Check if error is a timeout |
289
- | `getStepTimeoutMeta(e)` | Get timeout metadata from error |
290
- | `createCircuitBreaker(name, config)` | Create circuit breaker for step protection |
291
- | `createSagaWorkflow(deps, opts)` | Create saga with auto-compensation |
292
- | `createRateLimiter(name, config)` | Control step throughput |
293
- | `createWebhookHandler(workflow, fn, config)` | Expose workflow as HTTP endpoint |
294
- | `createWorkflowHarness(deps, opts)` | Create test harness for workflows |
752
+ ```typescript
753
+ import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
295
754
 
296
- ### Choosing Between run() and createWorkflow()
755
+ const requireApproval = createApprovalStep({ key: 'approval:deploy', checkApproval: async () => {/* ... */} });
756
+ const result = await workflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
297
757
 
298
- | Use Case | Recommendation |
299
- |----------|----------------|
300
- | Dependencies known at compile time | `createWorkflow()` |
301
- | Dependencies passed as parameters | `run()` |
302
- | Need step caching or resume | `createWorkflow()` |
303
- | One-off workflow invocation | `run()` |
304
- | Want automatic error inference | `createWorkflow()` |
305
- | Error types known upfront | `run()` |
758
+ if (!result.ok && isPendingApproval(result.error)) {
759
+ // notify operators, later call injectApproval(savedState, { stepKey, value })
760
+ }
761
+ ```
306
762
 
307
- **`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
763
+ - **Caching & deduplication** Give steps names + keys.
308
764
 
309
- ```typescript
310
- import { run } from '@jagreehal/workflow';
765
+ ```typescript
766
+ const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
767
+ ```
311
768
 
312
- const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
313
- async (step) => {
314
- const user = await step(fetchUser(userId)); // userId from parameter
315
- return user;
316
- },
317
- { onError: (e) => console.log('Failed:', e) }
318
- );
319
- ```
769
+ - **Branching logic** - It's just JavaScript - use normal `if`/`switch`.
320
770
 
321
- **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
771
+ ```typescript
772
+ const user = await step(fetchUser(id));
322
773
 
323
- ```typescript
324
- const loadUser = createWorkflow({ fetchUser, fetchPosts });
325
- // Error type computed automatically from deps
326
- ```
774
+ if (user.role === 'admin') {
775
+ return await step(fetchAdminDashboard(user.id));
776
+ }
327
777
 
328
- ### Import paths
778
+ if (user.subscription === 'free') {
779
+ return await step(fetchFreeTierData(user.id));
780
+ }
329
781
 
330
- ```typescript
331
- import { createWorkflow, ok, err } from '@jagreehal/workflow'; // Full library
332
- import { createWorkflow } from '@jagreehal/workflow/workflow'; // Workflow only
333
- import { ok, err, map, all } from '@jagreehal/workflow/core'; // Primitives only
334
- ```
782
+ return await step(fetchPremiumData(user.id));
783
+ ```
784
+
785
+ - **Parallel operations** Use helpers when you truly need concurrency.
786
+
787
+ ```typescript
788
+ import { allAsync, partition, map } from '@jagreehal/workflow';
335
789
 
336
- ## Advanced
790
+ const result = await allAsync([
791
+ fetchUser('1'),
792
+ fetchPosts('1'),
793
+ ]);
794
+ const data = map(result, ([user, posts]) => ({ user, posts }));
795
+ ```
337
796
 
338
- **You don't need this on day one.** The core is `createWorkflow`, `step`, and `step.try`.
797
+ ## Real-World Example: Safe Payment Retries with Persistence
339
798
 
340
- ### Step caching
799
+ The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
341
800
 
342
- Cache expensive operations by adding `{ key }`:
801
+ Step keys + persistence solve this. Save state to a database, and if the workflow crashes, resume from the last successful step:
343
802
 
344
803
  ```typescript
345
- const cache = new Map<string, Result<unknown, unknown>>();
346
- const workflow = createWorkflow({ fetchUser }, { cache });
804
+ import { createWorkflow, createStepCollector, stringifyState, parseState } from '@jagreehal/workflow';
347
805
 
348
- const result = await workflow(async (step) => {
349
- // Wrap in function + add key for caching
350
- const user = await step(() => fetchUser('1'), { key: 'user:1' });
806
+ const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
351
807
 
352
- // Same key = cache hit (fetchUser not called again)
353
- const userAgain = await step(() => fetchUser('1'), { key: 'user:1' });
808
+ // Collect state for persistence
809
+ const collector = createStepCollector();
810
+ const workflow = createWorkflow(
811
+ { validateCard, chargeProvider, persistResult },
812
+ { onEvent: collector.handleEvent }
813
+ );
354
814
 
355
- return user;
356
- });
357
- ```
815
+ const result = await workflow(async (step) => {
816
+ const card = await step(() => validateCard(input), { key: 'validate' });
358
817
 
359
- ### Retry with backoff
818
+ // This is the dangerous step. Once it succeeds, never repeat it:
819
+ const charge = await step(() => chargeProvider(card), {
820
+ key: `charge:${input.idempotencyKey}`,
821
+ });
360
822
 
361
- Automatically retry failed steps with configurable backoff:
823
+ // If THIS fails (DB down), save state and rerun later.
824
+ // The charge step is cached - it won't execute again.
825
+ await step(() => persistResult(charge), { key: `persist:${charge.id}` });
362
826
 
363
- ```typescript
364
- const result = await workflow(async (step) => {
365
- // Retry up to 3 times with exponential backoff
366
- const data = await step.retry(
367
- () => fetchData(),
368
- {
369
- attempts: 3,
370
- backoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
371
- initialDelay: 100, // ms
372
- maxDelay: 5000, // cap delay at 5s
373
- jitter: true, // add randomness to prevent thundering herd
374
- retryOn: (error) => error !== 'FATAL', // custom retry predicate
375
- }
376
- );
377
- return data;
827
+ return { paymentId: charge.id };
378
828
  });
829
+
830
+ // Save state after each run (or on crash)
831
+ if (result.ok) {
832
+ const state = collector.getState();
833
+ const json = stringifyState(state, { orderId: input.orderId });
834
+ await db.workflowStates.upsert({
835
+ where: { idempotencyKey: input.idempotencyKey },
836
+ update: { state: json, updatedAt: new Date() },
837
+ create: { idempotencyKey: input.idempotencyKey, state: json },
838
+ });
839
+ }
379
840
  ```
380
841
 
381
- Or use retry options directly on `step()`:
842
+ **Crash recovery:** If the workflow crashes after charging but before persisting:
382
843
 
383
844
  ```typescript
384
- const user = await step(() => fetchUser(id), {
385
- key: 'user:1',
386
- retry: { attempts: 3, backoff: 'exponential' },
845
+ // On restart, load saved state
846
+ const saved = await db.workflowStates.findUnique({
847
+ where: { idempotencyKey: input.idempotencyKey },
387
848
  });
849
+
850
+ if (saved) {
851
+ const savedState = parseState(saved.state);
852
+ const workflow = createWorkflow(
853
+ { validateCard, chargeProvider, persistResult },
854
+ { resumeState: savedState }
855
+ );
856
+
857
+ // Resume - charge step uses cached result, no double-billing!
858
+ const result = await workflow(async (step) => {
859
+ const card = await step(() => validateCard(input), { key: 'validate' }); // Cache hit
860
+ const charge = await step(() => chargeProvider(card), {
861
+ key: `charge:${input.idempotencyKey}`,
862
+ }); // Cache hit - returns previous charge result
863
+ await step(() => persistResult(charge), { key: `persist:${charge.id}` }); // Executes fresh
864
+ return { paymentId: charge.id };
865
+ });
866
+ }
388
867
  ```
389
868
 
390
- ### Timeout
869
+ Crash after charging but before persisting? Resume the workflow. The charge step returns its cached result. No double-billing.
391
870
 
392
- Prevent steps from hanging with timeouts:
871
+ ## Is This Library Right for You?
393
872
 
394
- ```typescript
395
- const result = await workflow(async (step) => {
396
- // Timeout after 5 seconds
397
- const data = await step.withTimeout(
398
- () => slowOperation(),
399
- { ms: 5000, name: 'slow-op' }
400
- );
401
- return data;
402
- });
403
- ```
873
+ ```mermaid
874
+ flowchart TD
875
+ Start([Need typed errors?]) --> Simple{Simple use case?}
404
876
 
405
- With AbortSignal for cancellable operations:
877
+ Simple -->|Yes| TryCatch["try/catch is fine"]
878
+ Simple -->|No| WantAsync{Want async/await syntax?}
406
879
 
407
- ```typescript
408
- const data = await step.withTimeout(
409
- (signal) => fetch('/api/data', { signal }),
410
- { ms: 5000, signal: true } // pass signal to operation
411
- );
412
- ```
880
+ WantAsync -->|Yes| NeedOrchestration{Need retries/caching/resume?}
881
+ WantAsync -->|No| Neverthrow["Consider neverthrow"]
413
882
 
414
- Combine retry and timeout - each attempt gets its own timeout:
883
+ NeedOrchestration -->|Yes| Workflow["✓ @jagreehal/workflow"]
884
+ NeedOrchestration -->|No| Either["Either works - workflow adds room to grow"]
415
885
 
416
- ```typescript
417
- const data = await step.retry(
418
- () => fetchData(),
419
- {
420
- attempts: 3,
421
- timeout: { ms: 2000 }, // 2s timeout per attempt
422
- }
423
- );
886
+ style Workflow fill:#E8F5E9
424
887
  ```
425
888
 
426
- Detecting timeout errors:
889
+ **Choose this library when:**
427
890
 
428
- ```typescript
429
- import { isStepTimeoutError, getStepTimeoutMeta } from '@jagreehal/workflow';
891
+ - You want Result types with familiar async/await syntax
892
+ - You need automatic error type inference
893
+ - You're building workflows that benefit from step caching or resume
894
+ - You want type-safe error handling without Effect's learning curve
430
895
 
431
- if (!result.ok && isStepTimeoutError(result.error)) {
432
- const meta = getStepTimeoutMeta(result.error);
433
- console.log(`Timed out after ${meta?.timeoutMs}ms on attempt ${meta?.attempt}`);
434
- }
435
- ```
896
+ ## How It Compares
897
+
898
+ **`try/catch` everywhere** - You lose error types. Every catch block sees `unknown`. Retries? Manual. Timeouts? Manual. Observability? Hope you remembered to add logging.
899
+
900
+ **Result-only libraries** (fp-ts, neverthrow) - Great for typed errors in pure functions. But when you need retries, caching, timeouts, or human approvals, you're back to wiring it yourself.
901
+
902
+ **This library** - Typed errors *plus* the orchestration primitives. Error inference flows from your dependencies. Retries, timeouts, caching, resume, and visualization are built in - use them when you need them.
436
903
 
437
- ### State save & resume
904
+ ### vs neverthrow
438
905
 
439
- Save step results for workflow replay:
906
+ | Aspect | neverthrow | workflow |
907
+ |--------|-----------|----------|
908
+ | **Chaining style** | `.andThen()` method chains (nest with 3+ ops) | `step()` with async/await (stays flat) |
909
+ | **Error inference** | Manual: `type Errors = 'A' \| 'B' \| 'C'` | Automatic from `createWorkflow({ deps })` |
910
+ | **Result access** | `.isOk()`, `.isErr()` methods | `.ok` boolean property |
911
+ | **Wrapping throws** | `ResultAsync.fromPromise(p, mapErr)` | `step.try(fn, { error })` or wrap in AsyncResult |
912
+ | **Parallel ops** | `ResultAsync.combine([...])` | `allAsync([...])` |
913
+ | **Retries** | DIY with recursive `.orElse()` | Built-in `step.retry({ attempts, backoff })` |
914
+ | **Timeouts** | DIY with `Promise.race()` | Built-in `step.withTimeout({ ms })` |
915
+ | **Caching** | DIY | Built-in with `{ key: 'cache-key' }` |
916
+ | **Resume/persist** | DIY | Built-in with `resumeState` + `isStepComplete()` |
917
+ | **Events** | DIY | 15+ event types via `onEvent` |
440
918
 
919
+ **When to use neverthrow:** You want typed Results with minimal bundle size and prefer functional chaining.
920
+
921
+ **When to use workflow:** You want typed Results with async/await syntax, automatic error inference, and built-in reliability primitives.
922
+
923
+ See [Coming from neverthrow](docs/coming-from-neverthrow.md) for pattern-by-pattern equivalents.
924
+
925
+ ### Where workflow shines
926
+
927
+ **Complex checkout flows:**
441
928
  ```typescript
442
- import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
929
+ // 5 different error types, all automatically inferred
930
+ const checkout = createWorkflow({ validateCart, checkInventory, getPricing, processPayment, createOrder });
443
931
 
444
- const savedSteps = new Map<string, ResumeStateEntry>();
445
- const userId = '123';
932
+ const result = await checkout(async (step) => {
933
+ const cart = await step(() => validateCart(input));
446
934
 
447
- const workflow = createWorkflow({ fetchUser, requireApproval }, {
448
- onEvent: (event) => {
449
- if (isStepComplete(event)) {
450
- savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
451
- }
452
- }
453
- });
935
+ // Parallel execution stays clean
936
+ const [inventory, pricing] = await step(() => allAsync([
937
+ checkInventory(cart.items),
938
+ getPricing(cart.items)
939
+ ]));
454
940
 
455
- // First run
456
- const result = await workflow(async (step) => {
457
- const user = await step(() => fetchUser(userId), { key: `user:${userId}` });
458
- const approval = await step(() => requireApproval(user.id), { key: `approval:${userId}` });
459
- return { user, approval };
941
+ const payment = await step(() => processPayment(cart, pricing.total));
942
+ return await step(() => createOrder(cart, payment));
460
943
  });
944
+ // TypeScript knows: Result<Order, ValidationError | InventoryError | PricingError | PaymentError | OrderError>
945
+ ```
461
946
 
462
- // Resume later
463
- const workflow2 = createWorkflow({ fetchUser, requireApproval }, {
464
- resumeState: { steps: savedSteps }
465
- });
466
- // Cached steps are skipped on resume
947
+ **Branching logic with native control flow:**
948
+ ```typescript
949
+ // Just JavaScript - no functional gymnastics
950
+ const tenant = await step(() => fetchTenant(id));
951
+
952
+ if (tenant.plan === 'free') {
953
+ return await step(() => calculateFreeUsage(tenant));
954
+ }
955
+
956
+ // Variables from earlier steps are in scope - no closure drilling
957
+ const [users, resources] = await step(() => allAsync([fetchUsers(), fetchResources()]));
958
+
959
+ switch (tenant.plan) {
960
+ case 'pro': await step(() => sendProNotification(tenant)); break;
961
+ case 'enterprise': await step(() => sendEnterpriseNotification(tenant)); break;
962
+ }
467
963
  ```
468
964
 
469
- ### Strict mode (closed error unions)
965
+ **Data pipelines with caching and resume:**
966
+ ```typescript
967
+ const pipeline = createWorkflow(deps, { cache: new Map() });
968
+
969
+ const result = await pipeline(async (step) => {
970
+ // `key` enables caching and resume from last successful step
971
+ const user = await step(() => fetchUser(id), { key: 'user' });
972
+ const posts = await step(() => fetchPosts(user.id), { key: 'posts' });
973
+ const comments = await step(() => fetchComments(posts), { key: 'comments' });
974
+ return { user, posts, comments };
975
+ }, { resumeState: savedState });
976
+ ```
977
+
978
+ ## Quick Reference
470
979
 
471
- Remove `UnexpectedError` from the union:
980
+ ### Workflow Builders
981
+
982
+ | API | Description |
983
+ |-----|-------------|
984
+ | `createWorkflow(deps, opts?)` | Reusable workflow with automatic error unions, caching, resume, events, strict mode. |
985
+ | `run(executor, opts?)` | One-off workflow; you supply `Output` and `Error` generics manually. |
986
+ | `createSagaWorkflow(deps, opts?)` | Workflow with automatic compensation handlers. |
987
+ | `createWorkflowHarness(deps, opts?)` | Testing harness with deterministic step control. |
988
+
989
+ ### Step Helpers
990
+
991
+ | API | Description |
992
+ |-----|-------------|
993
+ | `step(op, meta?)` | Execute a dependency or thunk. Supports `{ key, name, retry, timeout }`. |
994
+ | `step.try(fn, { error })` | Catch throws/rejections and emit a typed error. |
995
+ | `step.fromResult(fn, { onError })` | Preserve rich error objects from other Result-returning code. |
996
+ | `step.retry(fn, opts)` | Retries with fixed/linear/exponential backoff, jitter, and predicates. |
997
+ | `step.withTimeout(fn, { ms, signal?, name? })` | Auto-timeout operations and optionally pass AbortSignal. |
998
+
999
+ ### Result & Utility Helpers
1000
+
1001
+ | API | Description |
1002
+ |-----|-------------|
1003
+ | `ok(value)` / `err(error)` | Construct Results. |
1004
+ | `map`, `mapError`, `bimap` | Transform values or errors. |
1005
+ | `andThen`, `match` | Chain or pattern-match Results. |
1006
+ | `orElse`, `recover` | Error recovery and fallback patterns. |
1007
+ | `allAsync`, `partition` | Batch operations where the first error wins or you collect everything. |
1008
+ | `isStepTimeoutError(error)` | Runtime guard for timeout failures. |
1009
+ | `getStepTimeoutMeta(error)` | Inspect timeout metadata (attempt, ms, name). |
1010
+ | `createCircuitBreaker(name, config)` | Guard dependencies with open/close behavior. |
1011
+ | `createRateLimiter(name, config)` | Ensure steps respect throughput policies. |
1012
+ | `createWebhookHandler(workflow, fn, config)` | Turn workflows into HTTP handlers quickly. |
1013
+
1014
+ ### Choosing Between run() and createWorkflow()
1015
+
1016
+ | Use Case | Recommendation |
1017
+ |----------|----------------|
1018
+ | Dependencies known at compile time | `createWorkflow()` |
1019
+ | Dependencies passed as parameters | `run()` |
1020
+ | Need step caching or resume | `createWorkflow()` |
1021
+ | One-off workflow invocation | `run()` |
1022
+ | Want automatic error inference | `createWorkflow()` |
1023
+ | Error types known upfront | `run()` |
1024
+
1025
+ **`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
472
1026
 
473
1027
  ```typescript
474
- const workflow = createWorkflow(
475
- { fetchUser, fetchPosts },
476
- { strict: true, catchUnexpected: () => 'UNEXPECTED' as const }
1028
+ import { run } from '@jagreehal/workflow';
1029
+
1030
+ const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
1031
+ async (step) => {
1032
+ const user = await step(fetchUser(userId)); // userId from parameter
1033
+ return user;
1034
+ },
1035
+ { onError: (e) => console.log('Failed:', e) }
477
1036
  );
1037
+ ```
1038
+
1039
+ **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
478
1040
 
479
- // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | 'UNEXPECTED' (exactly)
1041
+ ```typescript
1042
+ const loadUser = createWorkflow({ fetchUser, fetchPosts });
1043
+ // Error type computed automatically from deps
480
1044
  ```
481
1045
 
482
- ### Event stream
1046
+ ### Import paths
483
1047
 
484
1048
  ```typescript
485
- const workflow = createWorkflow({ fetchUser }, {
486
- onEvent: (event) => {
487
- // workflow_start | workflow_success | workflow_error
488
- // step_start | step_success | step_error | step_complete
489
- // step_retry | step_timeout | step_retries_exhausted
490
- console.log(event.type, event.durationMs);
491
- }
492
- });
1049
+ import { createWorkflow, ok, err } from '@jagreehal/workflow';
1050
+ import { createWorkflow } from '@jagreehal/workflow/workflow';
1051
+ import { ok, err, map, all } from '@jagreehal/workflow/core';
493
1052
  ```
494
1053
 
495
- ### Visualization
1054
+ ## Common Pitfalls
1055
+
1056
+ **Use thunks for caching.** `step(fetchUser('1'))` executes immediately. Use `step(() => fetchUser('1'), { key })` for caching to work.
1057
+
1058
+ **Keys must be stable.** Use `user:${id}`, not `user:${Date.now()}`.
1059
+
1060
+ **Don't cache writes blindly.** Payments need carefully designed idempotency keys.
1061
+
1062
+ ## Troubleshooting & FAQ
1063
+
1064
+ - **Why is `UnexpectedError` in my union?** Add `{ strict: true, catchUnexpected: () => 'UNEXPECTED' }` when creating the workflow to map unknown errors explicitly.
1065
+ - **How do I inspect what ran?** Pass `onEvent` and log `step_*` / `workflow_*` events or feed them into `createIRBuilder()` for diagrams.
1066
+ - **A workflow is stuck waiting for approval. Now what?** Use `isPendingApproval(error)` to detect the state, notify operators, then call `injectApproval(state, { stepKey, value })` to resume.
1067
+ - **Cache is not used between runs.** Supply a stable `{ key }` per step and provide a cache/resume adapter in `createWorkflow(deps, { cache })`.
1068
+ - **I only need a single run with dynamic dependencies.** Use `run()` instead of `createWorkflow()` and pass dependencies directly to the executor.
496
1069
 
497
- Render workflow execution as ASCII art or Mermaid diagrams:
1070
+ ## Visualizing Workflows
1071
+
1072
+ Hook into the event stream and render diagrams for docs, PRs, or dashboards:
498
1073
 
499
1074
  ```typescript
500
- import { createIRBuilder, renderToAscii, renderToMermaid } from '@jagreehal/workflow/visualize';
1075
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
501
1076
 
502
- const builder = createIRBuilder();
1077
+ const viz = createVisualizer({ workflowName: 'user-posts-flow' });
503
1078
  const workflow = createWorkflow({ fetchUser, fetchPosts }, {
504
- onEvent: (event) => builder.addEvent(event),
1079
+ onEvent: viz.handleEvent,
505
1080
  });
506
1081
 
507
1082
  await workflow(async (step) => {
@@ -510,69 +1085,44 @@ await workflow(async (step) => {
510
1085
  return { user, posts };
511
1086
  });
512
1087
 
513
- // ASCII output
514
- console.log(renderToAscii(builder.getIR()));
515
- // ┌── my-workflow ──────────────────────────┐
516
- // │ ✓ Fetch user [150ms] │
517
- // │ ✓ Fetch posts [89ms] │
518
- // │ Completed in 240ms │
519
- // └─────────────────────────────────────────┘
520
-
521
- // Mermaid output (for docs, GitHub, etc.)
522
- console.log(renderToMermaid(builder.getIR()));
523
- ```
1088
+ // ASCII output for terminal/CLI
1089
+ console.log(viz.render());
524
1090
 
525
- Visualization includes retry and timeout indicators:
1091
+ // Mermaid diagram for Markdown/docs
1092
+ console.log(viz.renderAs('mermaid'));
526
1093
 
527
- ```
528
- ✓ Fetch data [500ms] [2 retries] [timeout 5000ms]
1094
+ // JSON IR for programmatic access
1095
+ console.log(viz.renderAs('json'));
529
1096
  ```
530
1097
 
531
- ### Human-in-the-loop
1098
+ Mermaid output drops directly into Markdown for documentation. The ASCII block is handy for CLI screenshots or incident runbooks.
1099
+
1100
+ **For post-execution visualization**, collect events and visualize later:
532
1101
 
533
1102
  ```typescript
534
- import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
1103
+ import { createEventCollector } from '@jagreehal/workflow/visualize';
535
1104
 
536
- const requireApproval = createApprovalStep<{ approvedBy: string }>({
537
- key: 'approval:deploy',
538
- checkApproval: async () => {
539
- const status = await db.getApproval('deploy');
540
- if (!status) return { status: 'pending' };
541
- return { status: 'approved', value: { approvedBy: status.approver } };
542
- },
1105
+ const collector = createEventCollector({ workflowName: 'my-workflow' });
1106
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
1107
+ onEvent: collector.handleEvent,
543
1108
  });
544
1109
 
545
- const result = await workflow(async (step) => {
546
- const approval = await step(requireApproval, { key: 'approval:deploy' });
547
- return approval;
548
- });
1110
+ await workflow(async (step) => { /* ... */ });
549
1111
 
550
- if (!result.ok && isPendingApproval(result.error)) {
551
- // Workflow paused, waiting for approval
552
- // Later: injectApproval(savedState, { stepKey, value }) to resume
553
- }
1112
+ // Visualize collected events
1113
+ console.log(collector.visualize());
1114
+ console.log(collector.visualizeAs('mermaid'));
554
1115
  ```
555
1116
 
556
- ### More utilities
1117
+ ## Keep Going
1118
+
1119
+ **Already using neverthrow?** [The migration guide](docs/coming-from-neverthrow.md) shows pattern-by-pattern equivalents - you'll feel at home quickly.
557
1120
 
558
- See [docs/advanced.md](docs/advanced.md) for:
559
- - Batch operations (`all`, `allSettled`, `partition`)
560
- - Result transformers (`map`, `andThen`, `match`)
561
- - Circuit breaker pattern
562
- - Saga/compensation pattern for rollbacks
563
- - Rate limiting and concurrency control
564
- - Workflow versioning and migrations
565
- - Pluggable persistence adapters
566
- - Webhook and event trigger adapters
567
- - Policy-driven step middleware
568
- - Developer tools and visualization
569
- - HITL orchestration helpers
570
- - Deterministic testing harness
571
- - OpenTelemetry integration
1121
+ **Ready for production features?** [Advanced usage](docs/advanced.md) covers sagas, circuit breakers, rate limiting, persistence adapters, and HITL orchestration.
572
1122
 
573
- ## API Reference
1123
+ **Need the full API?** [API reference](docs/api.md) has everything in one place.
574
1124
 
575
- See [docs/api.md](docs/api.md).
1125
+ ---
576
1126
 
577
1127
  ## License
578
1128