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