@jagreehal/workflow 1.4.0 → 1.6.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,302 @@
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.
10
39
 
11
40
  ```typescript
12
- // try/catch loses error attribution
13
- async function loadUserData(id: string) {
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:**
58
+
59
+ ```typescript
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
+ ### 🧑‍💻 Human-in-the-Loop
158
+
159
+ Pause for manual approvals (large transfers, deployments, refunds) and resume exactly where you left off.
160
+
161
+ ```typescript
162
+ const requireApproval = createApprovalStep({
163
+ key: 'approve:refund',
164
+ checkApproval: async () => {
165
+ const status = await db.getApprovalStatus('refund_123');
166
+ return status ? { status: 'approved', value: status } : { status: 'pending' };
167
+ },
168
+ });
169
+
170
+ const result = await refundWorkflow(async (step) => {
171
+ const refund = await step(calculateRefund(orderId));
172
+
173
+ // Workflow pauses here until someone approves
174
+ const approval = await step(requireApproval, { key: 'approve:refund' });
175
+
176
+ return await step(processRefund(refund, approval));
177
+ });
178
+
179
+ if (!result.ok && isPendingApproval(result.error)) {
180
+ // Notify Slack, send email, etc.
181
+ // Later: injectApproval(savedState, { stepKey, value })
182
+ }
183
+ ```
184
+
185
+ ### 📊 Visualize What Happened
186
+
187
+ Hook into the event stream to generate diagrams for logs, PRs, or dashboards.
188
+
189
+ ```typescript
190
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
191
+
192
+ const viz = createVisualizer({ workflowName: 'checkout' });
193
+ const workflow = createWorkflow({ fetchOrder, chargeCard }, {
194
+ onEvent: viz.handleEvent,
195
+ });
196
+
197
+ await workflow(async (step) => {
198
+ const order = await step(() => fetchOrder('order_456'), { name: 'Fetch order' });
199
+ const payment = await step(() => chargeCard(order.total), { name: 'Charge card' });
200
+ return { order, payment };
201
+ });
202
+
203
+ console.log(viz.renderAs('mermaid'));
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Start Here
209
+
210
+ 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.
211
+
212
+ ### Step 1 - Install
213
+
214
+ ```bash
215
+ npm install @jagreehal/workflow
216
+ # or
217
+ pnpm add @jagreehal/workflow
218
+ ```
219
+
220
+ ### Step 2 - Describe Async Dependencies
221
+
222
+ Define the units of work as `AsyncResult<T, E>` helpers. Results encode success (`ok`) or typed failure (`err`).
25
223
 
26
224
  ```typescript
27
- import { createWorkflow, ok, err, type AsyncResult } from '@jagreehal/workflow';
225
+ import { ok, err, type AsyncResult } from '@jagreehal/workflow';
226
+
227
+ type User = { id: string; name: string };
28
228
 
29
229
  const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
30
230
  id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
231
+ ```
232
+
233
+ ### Step 3 - Compose a Workflow
31
234
 
235
+ `createWorkflow` collects dependencies once so the library can infer the total error union.
236
+
237
+ ```typescript
238
+ import { createWorkflow } from '@jagreehal/workflow';
239
+
240
+ const workflow = createWorkflow({ fetchUser });
241
+ ```
242
+
243
+ ### Step 4 - Run & Inspect Results
244
+
245
+ Use `step()` inside the executor. It unwraps results, exits early on failure, and gives a typed `result` back to you.
246
+
247
+ ```typescript
248
+ const result = await workflow(async (step) => {
249
+ const user = await step(fetchUser('1'));
250
+ return user;
251
+ });
252
+
253
+ if (result.ok) {
254
+ console.log(result.value.name);
255
+ } else {
256
+ console.error(result.error); // 'NOT_FOUND' | UnexpectedError
257
+ }
258
+ ```
259
+
260
+ ### Step 5 - Add Safeguards
261
+
262
+ Introduce retries, timeout protection, or wrappers for throwing code only when you need them.
263
+
264
+ ```typescript
265
+ const data = await workflow(async (step) => {
266
+ const user = await step(fetchUser('1'));
267
+
268
+ const posts = await step.try(
269
+ () => fetch(`/api/users/${user.id}/posts`).then((r) => {
270
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
271
+ return r.json();
272
+ }),
273
+ { error: 'FETCH_FAILED' as const }
274
+ );
275
+
276
+ return { user, posts };
277
+ });
278
+ ```
279
+
280
+ That's the foundation. Now let's build on it.
281
+
282
+ ---
283
+
284
+ ## Guided Tutorial
285
+
286
+ 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.
287
+
288
+ ### Stage 1 - Hello Workflow
289
+
290
+ 1. Declare dependencies (`fetchUser`, `fetchPosts`).
291
+ 2. Create the workflow: `const loadUserData = createWorkflow({ fetchUser, fetchPosts })`.
292
+ 3. Use `step()` to fan out and gather results.
293
+
294
+ ```typescript
32
295
  const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
33
296
  ok([{ id: 1, title: 'Hello World' }]);
34
297
 
298
+ const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
299
+ id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
300
+
35
301
  const loadUserData = createWorkflow({ fetchUser, fetchPosts });
36
302
 
37
303
  const result = await loadUserData(async (step) => {
@@ -39,33 +305,130 @@ const result = await loadUserData(async (step) => {
39
305
  const posts = await step(fetchPosts(user.id));
40
306
  return { user, posts };
41
307
  });
308
+ ```
309
+
310
+ ### Stage 2 - Validation & Branching
311
+
312
+ Add validation helpers and watch the error union update automatically.
313
+
314
+ ```typescript
315
+ const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
316
+ email.includes('@') ? ok(email) : err('INVALID_EMAIL');
317
+
318
+ const signUp = createWorkflow({ validateEmail, fetchUser });
319
+
320
+ const result = await signUp(async (step) => {
321
+ const email = await step(validateEmail('user@example.com'));
322
+ const user = await step(fetchUser(email));
323
+ return { email, user };
324
+ });
325
+ ```
326
+
327
+ ### Stage 3 - Reliability Features
328
+
329
+ Layer in retries, caching, and timeouts only around the calls that need them.
330
+
331
+ ```typescript
332
+ const resilientWorkflow = createWorkflow({ fetchUser, fetchPosts }, {
333
+ cache: new Map(),
334
+ });
335
+
336
+ const result = await resilientWorkflow(async (step) => {
337
+ const user = await step(() => fetchUser('1'), {
338
+ key: 'user:1',
339
+ retry: { attempts: 3, backoff: 'exponential' },
340
+ });
341
+
342
+ const posts = await step.withTimeout(
343
+ () => fetchPosts(user.id),
344
+ { ms: 5000, name: 'Fetch posts' }
345
+ );
346
+
347
+ return { user, posts };
348
+ });
349
+ ```
350
+
351
+ ### Stage 4 - Human-in-the-Loop & Resume
352
+
353
+ Pause long-running workflows until an operator approves, then resume using persisted step results.
42
354
 
43
- // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError
44
- // ↑ Computed automatically from { fetchUser, fetchPosts }
355
+ ```typescript
356
+ import {
357
+ createApprovalStep,
358
+ createWorkflow,
359
+ injectApproval,
360
+ isPendingApproval,
361
+ isStepComplete,
362
+ type ResumeStateEntry,
363
+ } from '@jagreehal/workflow';
364
+
365
+ const savedSteps = new Map<string, ResumeStateEntry>();
366
+ const requireApproval = createApprovalStep({
367
+ key: 'approval:deploy',
368
+ checkApproval: async () => ({ status: 'pending' }),
369
+ });
370
+
371
+ const gatedWorkflow = createWorkflow({ requireApproval }, {
372
+ onEvent: (event) => {
373
+ if (isStepComplete(event)) savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
374
+ },
375
+ });
376
+
377
+ const result = await gatedWorkflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
378
+
379
+ if (!result.ok && isPendingApproval(result.error)) {
380
+ // later
381
+ injectApproval({ steps: savedSteps }, { stepKey: 'approval:deploy', value: { approvedBy: 'ops' } });
382
+ }
45
383
  ```
46
384
 
47
- `step()` unwraps Results. On error, workflow exits early.
385
+ ## Try It Yourself
386
+
387
+ - Open the [TypeScript Playground](https://www.typescriptlang.org/play) and paste any snippet from the tutorial.
388
+ - Prefer running locally? Save a file, run `npx tsx workflow-demo.ts`, and iterate with real dependencies.
389
+ - For interactive debugging, add `console.log` inside `onEvent` callbacks to visualize timing immediately.
390
+
391
+ ## Key Concepts
392
+
393
+ | Concept | What it does |
394
+ |---------|--------------|
395
+ | **Result** | `ok(value)` or `err(error)` - typed success/failure, no exceptions |
396
+ | **Workflow** | Wraps your dependencies and tracks their error types automatically |
397
+ | **step()** | Unwraps a Result, short-circuits on failure, enables caching/retries |
398
+ | **step.try** | Catches throws and converts them to typed errors |
399
+ | **step.fromResult** | Preserves rich error objects from other Result-returning code |
400
+ | **Events** | `onEvent` streams everything - timing, retries, failures - for visualization or logging |
401
+ | **Resume** | Save completed steps, pick up later (great for approvals or crashes) |
402
+ | **UnexpectedError** | Safety net for throws outside your declared union; use `strict` mode to force explicit handling |
403
+
404
+ ## Recipes & Patterns
405
+
406
+ ### Core Recipes
48
407
 
49
- ## More Examples
408
+ #### Basic Workflow
409
+
410
+ ```typescript
411
+ const result = await loadUserData(async (step) => {
412
+ const user = await step(fetchUser('1'));
413
+ const posts = await step(fetchPosts(user.id));
414
+ return { user, posts };
415
+ });
416
+ ```
50
417
 
51
- ### User signup with multiple steps
418
+ #### User Signup
52
419
 
53
420
  ```typescript
54
421
  const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
55
422
  email.includes('@') ? ok(email) : err('INVALID_EMAIL');
56
423
 
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
- };
424
+ const checkDuplicate = async (email: string): AsyncResult<void, 'EMAIL_EXISTS'> =>
425
+ email === 'taken@example.com' ? err('EMAIL_EXISTS') : ok(undefined);
61
426
 
62
427
  const createAccount = async (email: string): AsyncResult<{ id: string }, 'DB_ERROR'> =>
63
428
  ok({ id: crypto.randomUUID() });
64
429
 
65
- const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> =>
66
- ok(undefined);
430
+ const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> => ok(undefined);
67
431
 
68
- // Declare deps → error union computed automatically
69
432
  const signUp = createWorkflow({ validateEmail, checkDuplicate, createAccount, sendWelcome });
70
433
 
71
434
  const result = await signUp(async (step) => {
@@ -75,11 +438,10 @@ const result = await signUp(async (step) => {
75
438
  await step(sendWelcome(account.id));
76
439
  return account;
77
440
  });
78
-
79
441
  // result.error: 'INVALID_EMAIL' | 'EMAIL_EXISTS' | 'DB_ERROR' | 'EMAIL_FAILED' | UnexpectedError
80
442
  ```
81
443
 
82
- ### Checkout flow
444
+ #### Checkout Flow
83
445
 
84
446
  ```typescript
85
447
  const authenticate = async (token: string): AsyncResult<{ userId: string }, 'UNAUTHORIZED'> =>
@@ -99,11 +461,10 @@ const result = await checkout(async (step) => {
99
461
  const payment = await step(chargeCard(order.total));
100
462
  return { userId: auth.userId, txId: payment.txId };
101
463
  });
102
-
103
464
  // result.error: 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
104
465
  ```
105
466
 
106
- ### Composing workflows
467
+ #### Composing Workflows
107
468
 
108
469
  You can combine multiple workflows together. The error types automatically aggregate:
109
470
 
@@ -117,16 +478,7 @@ const validatePassword = async (pwd: string): AsyncResult<string, 'WEAK_PASSWORD
117
478
 
118
479
  const validationWorkflow = createWorkflow({ validateEmail, validatePassword });
119
480
 
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
-
481
+ // Checkout workflow
130
482
  const checkoutWorkflow = createWorkflow({ authenticate, fetchOrder, chargeCard });
131
483
 
132
484
  // Composed workflow: validation + checkout
@@ -139,94 +491,38 @@ const validateAndCheckout = createWorkflow({
139
491
  chargeCard,
140
492
  });
141
493
 
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) => {
494
+ const result = await validateAndCheckout(async (step) => {
178
495
  // Validation steps
179
- const email = await step(deps.validateEmail('user@example.com'));
180
- const password = await step(deps.validatePassword('secret123'));
496
+ const email = await step(validateEmail('user@example.com'));
497
+ const password = await step(validatePassword('secret123'));
181
498
 
182
499
  // 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));
500
+ const auth = await step(authenticate('valid'));
501
+ const order = await step(fetchOrder('order-1'));
502
+ const payment = await step(chargeCard(order.total));
186
503
 
187
504
  return { email, password, userId: auth.userId, txId: payment.txId };
188
505
  });
189
- // Same error union: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
506
+ // result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
190
507
  ```
191
508
 
192
- ### Wrapping throwing APIs with step.try
193
-
194
- ```typescript
195
- const workflow = createWorkflow({ fetchUser });
196
-
197
- const result = await workflow(async (step) => {
198
- const user = await step(fetchUser('1'));
199
-
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
- );
208
-
209
- const posts = await step.try(
210
- () => JSON.parse(response),
211
- { error: 'PARSE_FAILED' as const }
212
- );
509
+ ### Common Patterns
213
510
 
214
- return { user, posts };
215
- });
511
+ - **Validation & gating** – Run early workflows so later steps never execute for invalid data.
216
512
 
217
- // result.error: 'NOT_FOUND' | 'FETCH_FAILED' | 'PARSE_FAILED' | UnexpectedError
218
- ```
513
+ ```typescript
514
+ const validated = await step(() => validationWorkflow(async (inner) => inner(deps.validateEmail(email))));
515
+ ```
219
516
 
220
- ### Wrapping Result-returning functions with step.fromResult
517
+ - **API calls with typed errors** – Wrap fetch/axios via `step.try` and switch on the union later.
221
518
 
222
- When calling functions that return `Result<T, E>`, use `step.fromResult()` to map their typed errors:
519
+ ```typescript
520
+ const payload = await step.try(() => fetch(url).then((r) => r.json()), { error: 'HTTP_FAILED' });
521
+ ```
223
522
 
224
- ```typescript
225
- // callProvider returns Result<Response, ProviderError>
226
- const callProvider = async (input: string): AsyncResult<Response, ProviderError> => { ... };
523
+ - **Wrapping Result-returning functions** – Use `step.fromResult` to preserve rich error types.
227
524
 
228
- const result = await workflow(async (step) => {
229
- // step.fromResult gives you typed errors in onError (not unknown like step.try)
525
+ ```typescript
230
526
  const response = await step.fromResult(
231
527
  () => callProvider(input),
232
528
  {
@@ -237,266 +533,314 @@ const result = await workflow(async (step) => {
237
533
  })
238
534
  }
239
535
  );
536
+ ```
240
537
 
241
- return response;
242
- });
243
- ```
538
+ - **Retries, backoff, and timeouts** – Built into `step.retry()` and `step.withTimeout()`.
244
539
 
245
- Unlike `step.try()` where `onError` receives `unknown`, `step.fromResult()` preserves the error type.
540
+ ```typescript
541
+ const data = await step.retry(
542
+ () => step.withTimeout(() => fetchData(), { ms: 2000 }),
543
+ { attempts: 3, backoff: 'exponential', retryOn: (error) => error !== 'FATAL' }
544
+ );
545
+ ```
246
546
 
247
- ### Parallel operations
547
+ - **State save & resume** – Persist step completions and resume later.
248
548
 
249
- ```typescript
250
- import { allAsync, partition, map } from '@jagreehal/workflow';
549
+ ```typescript
550
+ import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
251
551
 
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 }));
552
+ const savedSteps = new Map<string, ResumeStateEntry>();
553
+ const workflow = createWorkflow(deps, {
554
+ onEvent: (event) => {
555
+ if (isStepComplete(event)) savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
556
+ },
557
+ });
558
+ const resumed = createWorkflow(deps, { resumeState: { steps: savedSteps } });
559
+ ```
258
560
 
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
- ```
561
+ - **Human-in-the-loop approvals** Pause a workflow until someone approves.
263
562
 
264
- ### Consuming results
563
+ ```typescript
564
+ import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
265
565
 
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
- ```
566
+ const requireApproval = createApprovalStep({ key: 'approval:deploy', checkApproval: async () => {/* ... */} });
567
+ const result = await workflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
273
568
 
274
- ## Quick Reference
569
+ if (!result.ok && isPendingApproval(result.error)) {
570
+ // notify operators, later call injectApproval(savedState, { stepKey, value })
571
+ }
572
+ ```
275
573
 
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 |
574
+ - **Caching & deduplication** Give steps names + keys.
290
575
 
291
- ### Choosing Between run() and createWorkflow()
576
+ ```typescript
577
+ const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
578
+ ```
292
579
 
293
- | Use Case | Recommendation |
294
- |----------|----------------|
295
- | Dependencies known at compile time | `createWorkflow()` |
296
- | Dependencies passed as parameters | `run()` |
297
- | Need step caching or resume | `createWorkflow()` |
298
- | One-off workflow invocation | `run()` |
299
- | Want automatic error inference | `createWorkflow()` |
300
- | Error types known upfront | `run()` |
580
+ - **Branching logic** - It's just JavaScript - use normal `if`/`switch`.
301
581
 
302
- **`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
303
-
304
- ```typescript
305
- import { run } from '@jagreehal/workflow';
582
+ ```typescript
583
+ const user = await step(fetchUser(id));
306
584
 
307
- const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
308
- async (step) => {
309
- const user = await step(fetchUser(userId)); // userId from parameter
310
- return user;
311
- },
312
- { onError: (e) => console.log('Failed:', e) }
313
- );
314
- ```
585
+ if (user.role === 'admin') {
586
+ return await step(fetchAdminDashboard(user.id));
587
+ }
315
588
 
316
- **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
589
+ if (user.subscription === 'free') {
590
+ return await step(fetchFreeTierData(user.id));
591
+ }
317
592
 
318
- ```typescript
319
- const loadUser = createWorkflow({ fetchUser, fetchPosts });
320
- // Error type computed automatically from deps
321
- ```
593
+ return await step(fetchPremiumData(user.id));
594
+ ```
322
595
 
323
- ### Import paths
596
+ - **Parallel operations** – Use helpers when you truly need concurrency.
324
597
 
325
- ```typescript
326
- import { createWorkflow, ok, err } from '@jagreehal/workflow'; // Full library
327
- import { createWorkflow } from '@jagreehal/workflow/workflow'; // Workflow only
328
- import { ok, err, map, all } from '@jagreehal/workflow/core'; // Primitives only
329
- ```
598
+ ```typescript
599
+ import { allAsync, partition, map } from '@jagreehal/workflow';
330
600
 
331
- ## Advanced
601
+ const result = await allAsync([
602
+ fetchUser('1'),
603
+ fetchPosts('1'),
604
+ ]);
605
+ const data = map(result, ([user, posts]) => ({ user, posts }));
606
+ ```
332
607
 
333
- **You don't need this on day one.** The core is `createWorkflow`, `step`, and `step.try`.
608
+ ## Real-World Example: Safe Payment Retries
334
609
 
335
- ### Step caching
610
+ The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
336
611
 
337
- Cache expensive operations by adding `{ key }`:
612
+ Step keys solve this. Once a step succeeds, it's cached - retries skip it automatically:
338
613
 
339
614
  ```typescript
340
- const cache = new Map<string, Result<unknown, unknown>>();
341
- const workflow = createWorkflow({ fetchUser }, { cache });
615
+ const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
342
616
 
343
- const result = await workflow(async (step) => {
344
- // Wrap in function + add key for caching
345
- const user = await step(() => fetchUser('1'), { key: 'user:1' });
617
+ const result = await processPayment(async (step) => {
618
+ const card = await step(() => validateCard(input), { key: 'validate' });
346
619
 
347
- // Same key = cache hit (fetchUser not called again)
348
- const userAgain = await step(() => fetchUser('1'), { key: 'user:1' });
620
+ // This is the dangerous step. Once it succeeds, never repeat it:
621
+ const charge = await step(() => chargeProvider(card), {
622
+ key: `charge:${input.idempotencyKey}`,
623
+ });
349
624
 
350
- return user;
625
+ // If THIS fails (DB down), you can rerun the workflow later.
626
+ // The charge step is cached - it won't execute again.
627
+ await step(() => persistResult(charge), { key: `persist:${charge.id}` });
628
+
629
+ return { paymentId: charge.id };
351
630
  });
352
631
  ```
353
632
 
354
- ### Retry with backoff
633
+ Crash after charging but before persisting? Rerun the workflow. The charge step returns its cached result. No double-billing.
355
634
 
356
- Automatically retry failed steps with configurable backoff:
635
+ ## Is This Library Right for You?
357
636
 
358
- ```typescript
359
- const result = await workflow(async (step) => {
360
- // Retry up to 3 times with exponential backoff
361
- const data = await step.retry(
362
- () => fetchData(),
363
- {
364
- attempts: 3,
365
- backoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
366
- initialDelay: 100, // ms
367
- maxDelay: 5000, // cap delay at 5s
368
- jitter: true, // add randomness to prevent thundering herd
369
- retryOn: (error) => error !== 'FATAL', // custom retry predicate
370
- }
371
- );
372
- return data;
373
- });
374
- ```
637
+ ```mermaid
638
+ flowchart TD
639
+ Start([Need typed errors?]) --> Simple{Simple use case?}
375
640
 
376
- Or use retry options directly on `step()`:
641
+ Simple -->|Yes| TryCatch["try/catch is fine"]
642
+ Simple -->|No| WantAsync{Want async/await syntax?}
377
643
 
378
- ```typescript
379
- const user = await step(() => fetchUser(id), {
380
- key: 'user:1',
381
- retry: { attempts: 3, backoff: 'exponential' },
382
- });
644
+ WantAsync -->|Yes| NeedOrchestration{Need retries/caching/resume?}
645
+ WantAsync -->|No| Neverthrow["Consider neverthrow"]
646
+
647
+ NeedOrchestration -->|Yes| Workflow["✓ @jagreehal/workflow"]
648
+ NeedOrchestration -->|No| Either["Either works - workflow adds room to grow"]
649
+
650
+ style Workflow fill:#E8F5E9
383
651
  ```
384
652
 
385
- ### Timeout
653
+ **Choose this library when:**
386
654
 
387
- Prevent steps from hanging with timeouts:
655
+ - You want Result types with familiar async/await syntax
656
+ - You need automatic error type inference
657
+ - You're building workflows that benefit from step caching or resume
658
+ - You want type-safe error handling without Effect's learning curve
388
659
 
389
- ```typescript
390
- const result = await workflow(async (step) => {
391
- // Timeout after 5 seconds
392
- const data = await step.withTimeout(
393
- () => slowOperation(),
394
- { ms: 5000, name: 'slow-op' }
395
- );
396
- return data;
397
- });
398
- ```
660
+ ## How It Compares
399
661
 
400
- With AbortSignal for cancellable operations:
662
+ **`try/catch` everywhere** - You lose error types. Every catch block sees `unknown`. Retries? Manual. Timeouts? Manual. Observability? Hope you remembered to add logging.
401
663
 
402
- ```typescript
403
- const data = await step.withTimeout(
404
- (signal) => fetch('/api/data', { signal }),
405
- { ms: 5000, signal: true } // pass signal to operation
406
- );
407
- ```
664
+ **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.
408
665
 
409
- Combine retry and timeout - each attempt gets its own timeout:
666
+ **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.
410
667
 
411
- ```typescript
412
- const data = await step.retry(
413
- () => fetchData(),
414
- {
415
- attempts: 3,
416
- timeout: { ms: 2000 }, // 2s timeout per attempt
417
- }
418
- );
419
- ```
668
+ ### vs neverthrow
420
669
 
421
- Detecting timeout errors:
670
+ | Aspect | neverthrow | workflow |
671
+ |--------|-----------|----------|
672
+ | **Chaining style** | `.andThen()` method chains (nest with 3+ ops) | `step()` with async/await (stays flat) |
673
+ | **Error inference** | Manual: `type Errors = 'A' \| 'B' \| 'C'` | Automatic from `createWorkflow({ deps })` |
674
+ | **Result access** | `.isOk()`, `.isErr()` methods | `.ok` boolean property |
675
+ | **Wrapping throws** | `ResultAsync.fromPromise(p, mapErr)` | `step.try(fn, { error })` or wrap in AsyncResult |
676
+ | **Parallel ops** | `ResultAsync.combine([...])` | `allAsync([...])` |
677
+ | **Retries** | DIY with recursive `.orElse()` | Built-in `step.retry({ attempts, backoff })` |
678
+ | **Timeouts** | DIY with `Promise.race()` | Built-in `step.withTimeout({ ms })` |
679
+ | **Caching** | DIY | Built-in with `{ key: 'cache-key' }` |
680
+ | **Resume/persist** | DIY | Built-in with `resumeState` + `isStepComplete()` |
681
+ | **Events** | DIY | 15+ event types via `onEvent` |
422
682
 
423
- ```typescript
424
- import { isStepTimeoutError, getStepTimeoutMeta } from '@jagreehal/workflow';
683
+ **When to use neverthrow:** You want typed Results with minimal bundle size and prefer functional chaining.
425
684
 
426
- if (!result.ok && isStepTimeoutError(result.error)) {
427
- const meta = getStepTimeoutMeta(result.error);
428
- console.log(`Timed out after ${meta?.timeoutMs}ms on attempt ${meta?.attempt}`);
429
- }
430
- ```
685
+ **When to use workflow:** You want typed Results with async/await syntax, automatic error inference, and built-in reliability primitives.
431
686
 
432
- ### State save & resume
687
+ See [Coming from neverthrow](docs/coming-from-neverthrow.md) for pattern-by-pattern equivalents.
433
688
 
434
- Save step results for workflow replay:
689
+ ### Where workflow shines
435
690
 
691
+ **Complex checkout flows:**
436
692
  ```typescript
437
- import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
693
+ // 5 different error types, all automatically inferred
694
+ const checkout = createWorkflow({ validateCart, checkInventory, getPricing, processPayment, createOrder });
438
695
 
439
- const savedSteps = new Map<string, ResumeStateEntry>();
440
- const userId = '123';
696
+ const result = await checkout(async (step) => {
697
+ const cart = await step(() => validateCart(input));
441
698
 
442
- const workflow = createWorkflow({ fetchUser, requireApproval }, {
443
- onEvent: (event) => {
444
- if (isStepComplete(event)) {
445
- savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
446
- }
447
- }
448
- });
699
+ // Parallel execution stays clean
700
+ const [inventory, pricing] = await step(() => allAsync([
701
+ checkInventory(cart.items),
702
+ getPricing(cart.items)
703
+ ]));
449
704
 
450
- // First run
451
- const result = await workflow(async (step) => {
452
- const user = await step(() => fetchUser(userId), { key: `user:${userId}` });
453
- const approval = await step(() => requireApproval(user.id), { key: `approval:${userId}` });
454
- return { user, approval };
705
+ const payment = await step(() => processPayment(cart, pricing.total));
706
+ return await step(() => createOrder(cart, payment));
455
707
  });
708
+ // TypeScript knows: Result<Order, ValidationError | InventoryError | PricingError | PaymentError | OrderError>
709
+ ```
456
710
 
457
- // Resume later
458
- const workflow2 = createWorkflow({ fetchUser, requireApproval }, {
459
- resumeState: { steps: savedSteps }
460
- });
461
- // Cached steps are skipped on resume
711
+ **Branching logic with native control flow:**
712
+ ```typescript
713
+ // Just JavaScript - no functional gymnastics
714
+ const tenant = await step(() => fetchTenant(id));
715
+
716
+ if (tenant.plan === 'free') {
717
+ return await step(() => calculateFreeUsage(tenant));
718
+ }
719
+
720
+ // Variables from earlier steps are in scope - no closure drilling
721
+ const [users, resources] = await step(() => allAsync([fetchUsers(), fetchResources()]));
722
+
723
+ switch (tenant.plan) {
724
+ case 'pro': await step(() => sendProNotification(tenant)); break;
725
+ case 'enterprise': await step(() => sendEnterpriseNotification(tenant)); break;
726
+ }
727
+ ```
728
+
729
+ **Data pipelines with caching and resume:**
730
+ ```typescript
731
+ const pipeline = createWorkflow(deps, { cache: new Map() });
732
+
733
+ const result = await pipeline(async (step) => {
734
+ // `key` enables caching and resume from last successful step
735
+ const user = await step(() => fetchUser(id), { key: 'user' });
736
+ const posts = await step(() => fetchPosts(user.id), { key: 'posts' });
737
+ const comments = await step(() => fetchComments(posts), { key: 'comments' });
738
+ return { user, posts, comments };
739
+ }, { resumeState: savedState });
462
740
  ```
463
741
 
464
- ### Strict mode (closed error unions)
742
+ ## Quick Reference
743
+
744
+ ### Workflow Builders
745
+
746
+ | API | Description |
747
+ |-----|-------------|
748
+ | `createWorkflow(deps, opts?)` | Reusable workflow with automatic error unions, caching, resume, events, strict mode. |
749
+ | `run(executor, opts?)` | One-off workflow; you supply `Output` and `Error` generics manually. |
750
+ | `createSagaWorkflow(deps, opts?)` | Workflow with automatic compensation handlers. |
751
+ | `createWorkflowHarness(deps, opts?)` | Testing harness with deterministic step control. |
752
+
753
+ ### Step Helpers
754
+
755
+ | API | Description |
756
+ |-----|-------------|
757
+ | `step(op, meta?)` | Execute a dependency or thunk. Supports `{ key, name, retry, timeout }`. |
758
+ | `step.try(fn, { error })` | Catch throws/rejections and emit a typed error. |
759
+ | `step.fromResult(fn, { onError })` | Preserve rich error objects from other Result-returning code. |
760
+ | `step.retry(fn, opts)` | Retries with fixed/linear/exponential backoff, jitter, and predicates. |
761
+ | `step.withTimeout(fn, { ms, signal?, name? })` | Auto-timeout operations and optionally pass AbortSignal. |
762
+
763
+ ### Result & Utility Helpers
764
+
765
+ | API | Description |
766
+ |-----|-------------|
767
+ | `ok(value)` / `err(error)` | Construct Results. |
768
+ | `map`, `mapError`, `bimap` | Transform values or errors. |
769
+ | `andThen`, `match` | Chain or pattern-match Results. |
770
+ | `orElse`, `recover` | Error recovery and fallback patterns. |
771
+ | `allAsync`, `partition` | Batch operations where the first error wins or you collect everything. |
772
+ | `isStepTimeoutError(error)` | Runtime guard for timeout failures. |
773
+ | `getStepTimeoutMeta(error)` | Inspect timeout metadata (attempt, ms, name). |
774
+ | `createCircuitBreaker(name, config)` | Guard dependencies with open/close behavior. |
775
+ | `createRateLimiter(name, config)` | Ensure steps respect throughput policies. |
776
+ | `createWebhookHandler(workflow, fn, config)` | Turn workflows into HTTP handlers quickly. |
465
777
 
466
- Remove `UnexpectedError` from the union:
778
+ ### Choosing Between run() and createWorkflow()
779
+
780
+ | Use Case | Recommendation |
781
+ |----------|----------------|
782
+ | Dependencies known at compile time | `createWorkflow()` |
783
+ | Dependencies passed as parameters | `run()` |
784
+ | Need step caching or resume | `createWorkflow()` |
785
+ | One-off workflow invocation | `run()` |
786
+ | Want automatic error inference | `createWorkflow()` |
787
+ | Error types known upfront | `run()` |
788
+
789
+ **`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
467
790
 
468
791
  ```typescript
469
- const workflow = createWorkflow(
470
- { fetchUser, fetchPosts },
471
- { strict: true, catchUnexpected: () => 'UNEXPECTED' as const }
792
+ import { run } from '@jagreehal/workflow';
793
+
794
+ const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
795
+ async (step) => {
796
+ const user = await step(fetchUser(userId)); // userId from parameter
797
+ return user;
798
+ },
799
+ { onError: (e) => console.log('Failed:', e) }
472
800
  );
801
+ ```
802
+
803
+ **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
473
804
 
474
- // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | 'UNEXPECTED' (exactly)
805
+ ```typescript
806
+ const loadUser = createWorkflow({ fetchUser, fetchPosts });
807
+ // Error type computed automatically from deps
475
808
  ```
476
809
 
477
- ### Event stream
810
+ ### Import paths
478
811
 
479
812
  ```typescript
480
- const workflow = createWorkflow({ fetchUser }, {
481
- onEvent: (event) => {
482
- // workflow_start | workflow_success | workflow_error
483
- // step_start | step_success | step_error | step_complete
484
- // step_retry | step_timeout | step_retries_exhausted
485
- console.log(event.type, event.durationMs);
486
- }
487
- });
813
+ import { createWorkflow, ok, err } from '@jagreehal/workflow';
814
+ import { createWorkflow } from '@jagreehal/workflow/workflow';
815
+ import { ok, err, map, all } from '@jagreehal/workflow/core';
488
816
  ```
489
817
 
490
- ### Visualization
818
+ ## Common Pitfalls
819
+
820
+ **Use thunks for caching.** `step(fetchUser('1'))` executes immediately. Use `step(() => fetchUser('1'), { key })` for caching to work.
491
821
 
492
- Render workflow execution as ASCII art or Mermaid diagrams:
822
+ **Keys must be stable.** Use `user:${id}`, not `user:${Date.now()}`.
823
+
824
+ **Don't cache writes blindly.** Payments need carefully designed idempotency keys.
825
+
826
+ ## Troubleshooting & FAQ
827
+
828
+ - **Why is `UnexpectedError` in my union?** Add `{ strict: true, catchUnexpected: () => 'UNEXPECTED' }` when creating the workflow to map unknown errors explicitly.
829
+ - **How do I inspect what ran?** Pass `onEvent` and log `step_*` / `workflow_*` events or feed them into `createIRBuilder()` for diagrams.
830
+ - **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.
831
+ - **Cache is not used between runs.** Supply a stable `{ key }` per step and provide a cache/resume adapter in `createWorkflow(deps, { cache })`.
832
+ - **I only need a single run with dynamic dependencies.** Use `run()` instead of `createWorkflow()` and pass dependencies directly to the executor.
833
+
834
+ ## Visualizing Workflows
835
+
836
+ Hook into the event stream and render diagrams for docs, PRs, or dashboards:
493
837
 
494
838
  ```typescript
495
- import { createIRBuilder, renderToAscii, renderToMermaid } from '@jagreehal/workflow/visualize';
839
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
496
840
 
497
- const builder = createIRBuilder();
841
+ const viz = createVisualizer({ workflowName: 'user-posts-flow' });
498
842
  const workflow = createWorkflow({ fetchUser, fetchPosts }, {
499
- onEvent: (event) => builder.addEvent(event),
843
+ onEvent: viz.handleEvent,
500
844
  });
501
845
 
502
846
  await workflow(async (step) => {
@@ -505,56 +849,44 @@ await workflow(async (step) => {
505
849
  return { user, posts };
506
850
  });
507
851
 
508
- // ASCII output
509
- console.log(renderToAscii(builder.getIR()));
510
- // ┌── my-workflow ──────────────────────────┐
511
- // │ ✓ Fetch user [150ms] │
512
- // │ ✓ Fetch posts [89ms] │
513
- // │ Completed in 240ms │
514
- // └─────────────────────────────────────────┘
515
-
516
- // Mermaid output (for docs, GitHub, etc.)
517
- console.log(renderToMermaid(builder.getIR()));
518
- ```
852
+ // ASCII output for terminal/CLI
853
+ console.log(viz.render());
519
854
 
520
- Visualization includes retry and timeout indicators:
855
+ // Mermaid diagram for Markdown/docs
856
+ console.log(viz.renderAs('mermaid'));
521
857
 
858
+ // JSON IR for programmatic access
859
+ console.log(viz.renderAs('json'));
522
860
  ```
523
- ✓ Fetch data [500ms] [2 retries] [timeout 5000ms]
524
- ```
525
861
 
526
- ### Human-in-the-loop
862
+ Mermaid output drops directly into Markdown for documentation. The ASCII block is handy for CLI screenshots or incident runbooks.
863
+
864
+ **For post-execution visualization**, collect events and visualize later:
527
865
 
528
866
  ```typescript
529
- import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
867
+ import { createEventCollector } from '@jagreehal/workflow/visualize';
530
868
 
531
- const requireApproval = createApprovalStep<{ approvedBy: string }>({
532
- key: 'approval:deploy',
533
- checkApproval: async () => {
534
- const status = await db.getApproval('deploy');
535
- if (!status) return { status: 'pending' };
536
- return { status: 'approved', value: { approvedBy: status.approver } };
537
- },
869
+ const collector = createEventCollector({ workflowName: 'my-workflow' });
870
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
871
+ onEvent: collector.handleEvent,
538
872
  });
539
873
 
540
- const result = await workflow(async (step) => {
541
- const approval = await step(requireApproval, { key: 'approval:deploy' });
542
- return approval;
543
- });
874
+ await workflow(async (step) => { /* ... */ });
544
875
 
545
- if (!result.ok && isPendingApproval(result.error)) {
546
- // Workflow paused, waiting for approval
547
- // Later: injectApproval(savedState, { stepKey, value }) to resume
548
- }
876
+ // Visualize collected events
877
+ console.log(collector.visualize());
878
+ console.log(collector.visualizeAs('mermaid'));
549
879
  ```
550
880
 
551
- ### More utilities
881
+ ## Keep Going
882
+
883
+ **Already using neverthrow?** [The migration guide](docs/coming-from-neverthrow.md) shows pattern-by-pattern equivalents - you'll feel at home quickly.
552
884
 
553
- See [docs/advanced.md](docs/advanced.md) for batch operations, transformers, and neverthrow interop.
885
+ **Ready for production features?** [Advanced usage](docs/advanced.md) covers sagas, circuit breakers, rate limiting, persistence adapters, and HITL orchestration.
554
886
 
555
- ## API Reference
887
+ **Need the full API?** [API reference](docs/api.md) has everything in one place.
556
888
 
557
- See [docs/api.md](docs/api.md).
889
+ ---
558
890
 
559
891
  ## License
560
892