@jagreehal/workflow 1.5.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,271 +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 |
290
- | `createCircuitBreaker(name, config)` | Create circuit breaker for step protection |
291
- | `createSagaWorkflow(deps, opts)` | Create saga with auto-compensation |
292
- | `createRateLimiter(name, config)` | Control step throughput |
293
- | `createWebhookHandler(workflow, fn, config)` | Expose workflow as HTTP endpoint |
294
- | `createWorkflowHarness(deps, opts)` | Create test harness for workflows |
574
+ - **Caching & deduplication** Give steps names + keys.
295
575
 
296
- ### Choosing Between run() and createWorkflow()
576
+ ```typescript
577
+ const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
578
+ ```
297
579
 
298
- | Use Case | Recommendation |
299
- |----------|----------------|
300
- | Dependencies known at compile time | `createWorkflow()` |
301
- | Dependencies passed as parameters | `run()` |
302
- | Need step caching or resume | `createWorkflow()` |
303
- | One-off workflow invocation | `run()` |
304
- | Want automatic error inference | `createWorkflow()` |
305
- | Error types known upfront | `run()` |
580
+ - **Branching logic** - It's just JavaScript - use normal `if`/`switch`.
306
581
 
307
- **`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
308
-
309
- ```typescript
310
- import { run } from '@jagreehal/workflow';
582
+ ```typescript
583
+ const user = await step(fetchUser(id));
311
584
 
312
- const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
313
- async (step) => {
314
- const user = await step(fetchUser(userId)); // userId from parameter
315
- return user;
316
- },
317
- { onError: (e) => console.log('Failed:', e) }
318
- );
319
- ```
585
+ if (user.role === 'admin') {
586
+ return await step(fetchAdminDashboard(user.id));
587
+ }
320
588
 
321
- **`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
+ }
322
592
 
323
- ```typescript
324
- const loadUser = createWorkflow({ fetchUser, fetchPosts });
325
- // Error type computed automatically from deps
326
- ```
593
+ return await step(fetchPremiumData(user.id));
594
+ ```
327
595
 
328
- ### Import paths
596
+ - **Parallel operations** – Use helpers when you truly need concurrency.
329
597
 
330
- ```typescript
331
- import { createWorkflow, ok, err } from '@jagreehal/workflow'; // Full library
332
- import { createWorkflow } from '@jagreehal/workflow/workflow'; // Workflow only
333
- import { ok, err, map, all } from '@jagreehal/workflow/core'; // Primitives only
334
- ```
598
+ ```typescript
599
+ import { allAsync, partition, map } from '@jagreehal/workflow';
335
600
 
336
- ## Advanced
601
+ const result = await allAsync([
602
+ fetchUser('1'),
603
+ fetchPosts('1'),
604
+ ]);
605
+ const data = map(result, ([user, posts]) => ({ user, posts }));
606
+ ```
337
607
 
338
- **You don't need this on day one.** The core is `createWorkflow`, `step`, and `step.try`.
608
+ ## Real-World Example: Safe Payment Retries
339
609
 
340
- ### Step caching
610
+ The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
341
611
 
342
- Cache expensive operations by adding `{ key }`:
612
+ Step keys solve this. Once a step succeeds, it's cached - retries skip it automatically:
343
613
 
344
614
  ```typescript
345
- const cache = new Map<string, Result<unknown, unknown>>();
346
- const workflow = createWorkflow({ fetchUser }, { cache });
615
+ const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
347
616
 
348
- const result = await workflow(async (step) => {
349
- // Wrap in function + add key for caching
350
- const user = await step(() => fetchUser('1'), { key: 'user:1' });
617
+ const result = await processPayment(async (step) => {
618
+ const card = await step(() => validateCard(input), { key: 'validate' });
351
619
 
352
- // Same key = cache hit (fetchUser not called again)
353
- 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
+ });
354
624
 
355
- 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 };
356
630
  });
357
631
  ```
358
632
 
359
- ### Retry with backoff
633
+ Crash after charging but before persisting? Rerun the workflow. The charge step returns its cached result. No double-billing.
360
634
 
361
- Automatically retry failed steps with configurable backoff:
635
+ ## Is This Library Right for You?
362
636
 
363
- ```typescript
364
- const result = await workflow(async (step) => {
365
- // Retry up to 3 times with exponential backoff
366
- const data = await step.retry(
367
- () => fetchData(),
368
- {
369
- attempts: 3,
370
- backoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
371
- initialDelay: 100, // ms
372
- maxDelay: 5000, // cap delay at 5s
373
- jitter: true, // add randomness to prevent thundering herd
374
- retryOn: (error) => error !== 'FATAL', // custom retry predicate
375
- }
376
- );
377
- return data;
378
- });
379
- ```
637
+ ```mermaid
638
+ flowchart TD
639
+ Start([Need typed errors?]) --> Simple{Simple use case?}
380
640
 
381
- Or use retry options directly on `step()`:
641
+ Simple -->|Yes| TryCatch["try/catch is fine"]
642
+ Simple -->|No| WantAsync{Want async/await syntax?}
382
643
 
383
- ```typescript
384
- const user = await step(() => fetchUser(id), {
385
- key: 'user:1',
386
- retry: { attempts: 3, backoff: 'exponential' },
387
- });
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
388
651
  ```
389
652
 
390
- ### Timeout
653
+ **Choose this library when:**
391
654
 
392
- 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
393
659
 
394
- ```typescript
395
- const result = await workflow(async (step) => {
396
- // Timeout after 5 seconds
397
- const data = await step.withTimeout(
398
- () => slowOperation(),
399
- { ms: 5000, name: 'slow-op' }
400
- );
401
- return data;
402
- });
403
- ```
660
+ ## How It Compares
404
661
 
405
- 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.
406
663
 
407
- ```typescript
408
- const data = await step.withTimeout(
409
- (signal) => fetch('/api/data', { signal }),
410
- { ms: 5000, signal: true } // pass signal to operation
411
- );
412
- ```
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.
413
665
 
414
- 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.
415
667
 
416
- ```typescript
417
- const data = await step.retry(
418
- () => fetchData(),
419
- {
420
- attempts: 3,
421
- timeout: { ms: 2000 }, // 2s timeout per attempt
422
- }
423
- );
424
- ```
668
+ ### vs neverthrow
425
669
 
426
- 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` |
427
682
 
428
- ```typescript
429
- import { isStepTimeoutError, getStepTimeoutMeta } from '@jagreehal/workflow';
683
+ **When to use neverthrow:** You want typed Results with minimal bundle size and prefer functional chaining.
430
684
 
431
- if (!result.ok && isStepTimeoutError(result.error)) {
432
- const meta = getStepTimeoutMeta(result.error);
433
- console.log(`Timed out after ${meta?.timeoutMs}ms on attempt ${meta?.attempt}`);
434
- }
435
- ```
685
+ **When to use workflow:** You want typed Results with async/await syntax, automatic error inference, and built-in reliability primitives.
436
686
 
437
- ### State save & resume
687
+ See [Coming from neverthrow](docs/coming-from-neverthrow.md) for pattern-by-pattern equivalents.
438
688
 
439
- Save step results for workflow replay:
689
+ ### Where workflow shines
440
690
 
691
+ **Complex checkout flows:**
441
692
  ```typescript
442
- 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 });
443
695
 
444
- const savedSteps = new Map<string, ResumeStateEntry>();
445
- const userId = '123';
696
+ const result = await checkout(async (step) => {
697
+ const cart = await step(() => validateCart(input));
446
698
 
447
- const workflow = createWorkflow({ fetchUser, requireApproval }, {
448
- onEvent: (event) => {
449
- if (isStepComplete(event)) {
450
- savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
451
- }
452
- }
453
- });
699
+ // Parallel execution stays clean
700
+ const [inventory, pricing] = await step(() => allAsync([
701
+ checkInventory(cart.items),
702
+ getPricing(cart.items)
703
+ ]));
454
704
 
455
- // First run
456
- const result = await workflow(async (step) => {
457
- const user = await step(() => fetchUser(userId), { key: `user:${userId}` });
458
- const approval = await step(() => requireApproval(user.id), { key: `approval:${userId}` });
459
- return { user, approval };
705
+ const payment = await step(() => processPayment(cart, pricing.total));
706
+ return await step(() => createOrder(cart, payment));
460
707
  });
708
+ // TypeScript knows: Result<Order, ValidationError | InventoryError | PricingError | PaymentError | OrderError>
709
+ ```
461
710
 
462
- // Resume later
463
- const workflow2 = createWorkflow({ fetchUser, requireApproval }, {
464
- resumeState: { steps: savedSteps }
465
- });
466
- // 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 });
467
740
  ```
468
741
 
469
- ### 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. |
470
777
 
471
- 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:
472
790
 
473
791
  ```typescript
474
- const workflow = createWorkflow(
475
- { fetchUser, fetchPosts },
476
- { 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) }
477
800
  );
801
+ ```
802
+
803
+ **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
478
804
 
479
- // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | 'UNEXPECTED' (exactly)
805
+ ```typescript
806
+ const loadUser = createWorkflow({ fetchUser, fetchPosts });
807
+ // Error type computed automatically from deps
480
808
  ```
481
809
 
482
- ### Event stream
810
+ ### Import paths
483
811
 
484
812
  ```typescript
485
- const workflow = createWorkflow({ fetchUser }, {
486
- onEvent: (event) => {
487
- // workflow_start | workflow_success | workflow_error
488
- // step_start | step_success | step_error | step_complete
489
- // step_retry | step_timeout | step_retries_exhausted
490
- console.log(event.type, event.durationMs);
491
- }
492
- });
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';
493
816
  ```
494
817
 
495
- ### Visualization
818
+ ## Common Pitfalls
819
+
820
+ **Use thunks for caching.** `step(fetchUser('1'))` executes immediately. Use `step(() => fetchUser('1'), { key })` for caching to work.
496
821
 
497
- 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:
498
837
 
499
838
  ```typescript
500
- import { createIRBuilder, renderToAscii, renderToMermaid } from '@jagreehal/workflow/visualize';
839
+ import { createVisualizer } from '@jagreehal/workflow/visualize';
501
840
 
502
- const builder = createIRBuilder();
841
+ const viz = createVisualizer({ workflowName: 'user-posts-flow' });
503
842
  const workflow = createWorkflow({ fetchUser, fetchPosts }, {
504
- onEvent: (event) => builder.addEvent(event),
843
+ onEvent: viz.handleEvent,
505
844
  });
506
845
 
507
846
  await workflow(async (step) => {
@@ -510,69 +849,44 @@ await workflow(async (step) => {
510
849
  return { user, posts };
511
850
  });
512
851
 
513
- // ASCII output
514
- console.log(renderToAscii(builder.getIR()));
515
- // ┌── my-workflow ──────────────────────────┐
516
- // │ ✓ Fetch user [150ms] │
517
- // │ ✓ Fetch posts [89ms] │
518
- // │ Completed in 240ms │
519
- // └─────────────────────────────────────────┘
520
-
521
- // Mermaid output (for docs, GitHub, etc.)
522
- console.log(renderToMermaid(builder.getIR()));
523
- ```
852
+ // ASCII output for terminal/CLI
853
+ console.log(viz.render());
524
854
 
525
- Visualization includes retry and timeout indicators:
855
+ // Mermaid diagram for Markdown/docs
856
+ console.log(viz.renderAs('mermaid'));
526
857
 
858
+ // JSON IR for programmatic access
859
+ console.log(viz.renderAs('json'));
527
860
  ```
528
- ✓ Fetch data [500ms] [2 retries] [timeout 5000ms]
529
- ```
530
861
 
531
- ### 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:
532
865
 
533
866
  ```typescript
534
- import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
867
+ import { createEventCollector } from '@jagreehal/workflow/visualize';
535
868
 
536
- const requireApproval = createApprovalStep<{ approvedBy: string }>({
537
- key: 'approval:deploy',
538
- checkApproval: async () => {
539
- const status = await db.getApproval('deploy');
540
- if (!status) return { status: 'pending' };
541
- return { status: 'approved', value: { approvedBy: status.approver } };
542
- },
869
+ const collector = createEventCollector({ workflowName: 'my-workflow' });
870
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
871
+ onEvent: collector.handleEvent,
543
872
  });
544
873
 
545
- const result = await workflow(async (step) => {
546
- const approval = await step(requireApproval, { key: 'approval:deploy' });
547
- return approval;
548
- });
874
+ await workflow(async (step) => { /* ... */ });
549
875
 
550
- if (!result.ok && isPendingApproval(result.error)) {
551
- // Workflow paused, waiting for approval
552
- // Later: injectApproval(savedState, { stepKey, value }) to resume
553
- }
876
+ // Visualize collected events
877
+ console.log(collector.visualize());
878
+ console.log(collector.visualizeAs('mermaid'));
554
879
  ```
555
880
 
556
- ### 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.
557
884
 
558
- See [docs/advanced.md](docs/advanced.md) for:
559
- - Batch operations (`all`, `allSettled`, `partition`)
560
- - Result transformers (`map`, `andThen`, `match`)
561
- - Circuit breaker pattern
562
- - Saga/compensation pattern for rollbacks
563
- - Rate limiting and concurrency control
564
- - Workflow versioning and migrations
565
- - Pluggable persistence adapters
566
- - Webhook and event trigger adapters
567
- - Policy-driven step middleware
568
- - Developer tools and visualization
569
- - HITL orchestration helpers
570
- - Deterministic testing harness
571
- - OpenTelemetry integration
885
+ **Ready for production features?** [Advanced usage](docs/advanced.md) covers sagas, circuit breakers, rate limiting, persistence adapters, and HITL orchestration.
572
886
 
573
- ## API Reference
887
+ **Need the full API?** [API reference](docs/api.md) has everything in one place.
574
888
 
575
- See [docs/api.md](docs/api.md).
889
+ ---
576
890
 
577
891
  ## License
578
892