@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 +664 -350
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +179 -1
- package/dist/core.d.ts +179 -1
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/visualize.cjs +6 -6
- package/dist/visualize.cjs.map +1 -1
- package/dist/visualize.js +6 -6
- package/dist/visualize.js.map +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- package/docs/coming-from-neverthrow.md +920 -0
- package/docs/visualize-examples.md +330 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
180
|
-
const password = await step(
|
|
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(
|
|
184
|
-
const order = await step(
|
|
185
|
-
const payment = await step(
|
|
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
|
-
//
|
|
506
|
+
// result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
|
|
190
507
|
```
|
|
191
508
|
|
|
192
|
-
###
|
|
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
|
-
|
|
215
|
-
});
|
|
511
|
+
- **Validation & gating** – Run early workflows so later steps never execute for invalid data.
|
|
216
512
|
|
|
217
|
-
|
|
218
|
-
|
|
513
|
+
```typescript
|
|
514
|
+
const validated = await step(() => validationWorkflow(async (inner) => inner(deps.validateEmail(email))));
|
|
515
|
+
```
|
|
219
516
|
|
|
220
|
-
|
|
517
|
+
- **API calls with typed errors** – Wrap fetch/axios via `step.try` and switch on the union later.
|
|
221
518
|
|
|
222
|
-
|
|
519
|
+
```typescript
|
|
520
|
+
const payload = await step.try(() => fetch(url).then((r) => r.json()), { error: 'HTTP_FAILED' });
|
|
521
|
+
```
|
|
223
522
|
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
});
|
|
243
|
-
```
|
|
538
|
+
- **Retries, backoff, and timeouts** – Built into `step.retry()` and `step.withTimeout()`.
|
|
244
539
|
|
|
245
|
-
|
|
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
|
-
|
|
547
|
+
- **State save & resume** – Persist step completions and resume later.
|
|
248
548
|
|
|
249
|
-
```typescript
|
|
250
|
-
import {
|
|
549
|
+
```typescript
|
|
550
|
+
import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
|
|
251
551
|
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
+
```typescript
|
|
564
|
+
import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
|
|
265
565
|
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
569
|
+
if (!result.ok && isPendingApproval(result.error)) {
|
|
570
|
+
// notify operators, later call injectApproval(savedState, { stepKey, value })
|
|
571
|
+
}
|
|
572
|
+
```
|
|
275
573
|
|
|
276
|
-
|
|
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
|
-
|
|
576
|
+
```typescript
|
|
577
|
+
const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
|
|
578
|
+
```
|
|
297
579
|
|
|
298
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
```typescript
|
|
310
|
-
import { run } from '@jagreehal/workflow';
|
|
582
|
+
```typescript
|
|
583
|
+
const user = await step(fetchUser(id));
|
|
311
584
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
589
|
+
if (user.subscription === 'free') {
|
|
590
|
+
return await step(fetchFreeTierData(user.id));
|
|
591
|
+
}
|
|
322
592
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// Error type computed automatically from deps
|
|
326
|
-
```
|
|
593
|
+
return await step(fetchPremiumData(user.id));
|
|
594
|
+
```
|
|
327
595
|
|
|
328
|
-
|
|
596
|
+
- **Parallel operations** – Use helpers when you truly need concurrency.
|
|
329
597
|
|
|
330
|
-
```typescript
|
|
331
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
608
|
+
## Real-World Example: Safe Payment Retries
|
|
339
609
|
|
|
340
|
-
|
|
610
|
+
The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
|
|
341
611
|
|
|
342
|
-
|
|
612
|
+
Step keys solve this. Once a step succeeds, it's cached - retries skip it automatically:
|
|
343
613
|
|
|
344
614
|
```typescript
|
|
345
|
-
const
|
|
346
|
-
const workflow = createWorkflow({ fetchUser }, { cache });
|
|
615
|
+
const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
|
|
347
616
|
|
|
348
|
-
const result = await
|
|
349
|
-
|
|
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
|
-
//
|
|
353
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
633
|
+
Crash after charging but before persisting? Rerun the workflow. The charge step returns its cached result. No double-billing.
|
|
360
634
|
|
|
361
|
-
|
|
635
|
+
## Is This Library Right for You?
|
|
362
636
|
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
641
|
+
Simple -->|Yes| TryCatch["try/catch is fine"]
|
|
642
|
+
Simple -->|No| WantAsync{Want async/await syntax?}
|
|
382
643
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
653
|
+
**Choose this library when:**
|
|
391
654
|
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
687
|
+
See [Coming from neverthrow](docs/coming-from-neverthrow.md) for pattern-by-pattern equivalents.
|
|
438
688
|
|
|
439
|
-
|
|
689
|
+
### Where workflow shines
|
|
440
690
|
|
|
691
|
+
**Complex checkout flows:**
|
|
441
692
|
```typescript
|
|
442
|
-
|
|
693
|
+
// 5 different error types, all automatically inferred
|
|
694
|
+
const checkout = createWorkflow({ validateCart, checkInventory, getPricing, processPayment, createOrder });
|
|
443
695
|
|
|
444
|
-
const
|
|
445
|
-
const
|
|
696
|
+
const result = await checkout(async (step) => {
|
|
697
|
+
const cart = await step(() => validateCart(input));
|
|
446
698
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
805
|
+
```typescript
|
|
806
|
+
const loadUser = createWorkflow({ fetchUser, fetchPosts });
|
|
807
|
+
// Error type computed automatically from deps
|
|
480
808
|
```
|
|
481
809
|
|
|
482
|
-
###
|
|
810
|
+
### Import paths
|
|
483
811
|
|
|
484
812
|
```typescript
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
839
|
+
import { createVisualizer } from '@jagreehal/workflow/visualize';
|
|
501
840
|
|
|
502
|
-
const
|
|
841
|
+
const viz = createVisualizer({ workflowName: 'user-posts-flow' });
|
|
503
842
|
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
504
|
-
onEvent:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
867
|
+
import { createEventCollector } from '@jagreehal/workflow/visualize';
|
|
535
868
|
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
const approval = await step(requireApproval, { key: 'approval:deploy' });
|
|
547
|
-
return approval;
|
|
548
|
-
});
|
|
874
|
+
await workflow(async (step) => { /* ... */ });
|
|
549
875
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
876
|
+
// Visualize collected events
|
|
877
|
+
console.log(collector.visualize());
|
|
878
|
+
console.log(collector.visualizeAs('mermaid'));
|
|
554
879
|
```
|
|
555
880
|
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
887
|
+
**Need the full API?** [API reference](docs/api.md) has everything in one place.
|
|
574
888
|
|
|
575
|
-
|
|
889
|
+
---
|
|
576
890
|
|
|
577
891
|
## License
|
|
578
892
|
|