@jagreehal/workflow 1.12.0 → 1.13.0

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