@jagreehal/workflow 1.8.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 +21 -1346
- package/dist/batch.cjs +2 -0
- package/dist/batch.cjs.map +1 -0
- package/dist/batch.d.cts +197 -0
- package/dist/batch.d.ts +197 -0
- package/dist/batch.js +2 -0
- package/dist/batch.js.map +1 -0
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/resource.cjs +2 -0
- package/dist/resource.cjs.map +1 -0
- package/dist/resource.d.cts +171 -0
- package/dist/resource.d.ts +171 -0
- package/dist/resource.js +2 -0
- package/dist/resource.js.map +1 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -1,1373 +1,48 @@
|
|
|
1
1
|
# @jagreehal/workflow
|
|
2
2
|
|
|
3
|
-
Typed async workflows with automatic error inference.
|
|
3
|
+
Typed async workflows with automatic error inference.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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:**
|
|
58
|
-
|
|
59
|
-
```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
|
-
```
|
|
73
|
-
|
|
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
|
-
### 💾 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'));
|
|
346
|
-
return user;
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
if (result.ok) {
|
|
350
|
-
console.log(result.value.name);
|
|
351
|
-
} else {
|
|
352
|
-
console.error(result.error); // 'NOT_FOUND' | UnexpectedError
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
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
|
-
| **TaggedError** | Factory for rich error types with exhaustive pattern matching |
|
|
584
|
-
|
|
585
|
-
## Tagged Errors: When String Literals Aren't Enough
|
|
586
|
-
|
|
587
|
-
String literal errors like `'NOT_FOUND'` are perfect for simple cases. But sometimes you need **rich error objects** with contextual data. That's where `TaggedError` shines.
|
|
588
|
-
|
|
589
|
-
### When to Use What
|
|
590
|
-
|
|
591
|
-
| Use Case | Recommendation |
|
|
592
|
-
|----------|----------------|
|
|
593
|
-
| Simple, distinct error states | String literals: `'NOT_FOUND' \| 'UNAUTHORIZED'` |
|
|
594
|
-
| Errors with contextual data | TaggedError: `NotFoundError { id, resource }` |
|
|
595
|
-
| Multiple error variants to handle | TaggedError with `match()` for exhaustive handling |
|
|
596
|
-
| API responses or user messages | TaggedError for structured error details |
|
|
597
|
-
|
|
598
|
-
### String Literals (Simple Cases)
|
|
599
|
-
|
|
600
|
-
For most workflows, string literals are cleaner and simpler:
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND' | 'DB_ERROR'> =>
|
|
604
|
-
id ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
605
|
-
|
|
606
|
-
const result = await workflow(async (step) => {
|
|
607
|
-
const user = await step(fetchUser('123'));
|
|
608
|
-
return user;
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
if (!result.ok) {
|
|
612
|
-
switch (result.error) {
|
|
613
|
-
case 'NOT_FOUND': return res.status(404).send('User not found');
|
|
614
|
-
case 'DB_ERROR': return res.status(500).send('Database error');
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
### TaggedError (Rich Error Objects)
|
|
620
|
-
|
|
621
|
-
Use `TaggedError` when you need to carry additional context:
|
|
11
|
+
## Quick Example
|
|
622
12
|
|
|
623
13
|
```typescript
|
|
624
|
-
import {
|
|
625
|
-
|
|
626
|
-
// Pattern 1: Props via generic (default message = tag name)
|
|
627
|
-
class NotFoundError extends TaggedError('NotFoundError')<{
|
|
628
|
-
resource: string;
|
|
629
|
-
id: string;
|
|
630
|
-
}> {}
|
|
631
|
-
|
|
632
|
-
// Pattern 2: Type-safe message (Props inferred from callback annotation)
|
|
633
|
-
class ValidationError extends TaggedError('ValidationError', {
|
|
634
|
-
message: (p: { field: string; reason: string }) => `Validation failed for ${p.field}: ${p.reason}`,
|
|
635
|
-
}) {}
|
|
14
|
+
import { createWorkflow, ok, err, type AsyncResult } from '@jagreehal/workflow';
|
|
636
15
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
// Runtime type checks work!
|
|
642
|
-
const error = new NotFoundError({ resource: 'User', id: '123' });
|
|
643
|
-
console.log(error instanceof TaggedError); // true
|
|
644
|
-
|
|
645
|
-
// Use in your functions
|
|
646
|
-
type UserError = NotFoundError | ValidationError | RateLimitError;
|
|
647
|
-
|
|
648
|
-
const fetchUser = async (id: string): AsyncResult<User, UserError> => {
|
|
649
|
-
if (!id) return err(new ValidationError({ field: 'id', reason: 'required' }));
|
|
650
|
-
if (id === 'limited') return err(new RateLimitError({ retryAfter: 60 }));
|
|
651
|
-
if (id === 'missing') return err(new NotFoundError({ resource: 'User', id }));
|
|
652
|
-
return ok({ id, name: 'Alice' });
|
|
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');
|
|
653
20
|
};
|
|
654
|
-
```
|
|
655
21
|
|
|
656
|
-
|
|
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
|
+
};
|
|
657
26
|
|
|
658
|
-
|
|
27
|
+
// Create workflow - error types are inferred automatically
|
|
28
|
+
const workflow = createWorkflow({ fetchUser, sendEmail });
|
|
659
29
|
|
|
660
|
-
|
|
30
|
+
// Run workflow - step() unwraps results or exits early
|
|
661
31
|
const result = await workflow(async (step) => {
|
|
662
32
|
const user = await step(fetchUser('123'));
|
|
33
|
+
await step(sendEmail(user.email));
|
|
663
34
|
return user;
|
|
664
35
|
});
|
|
665
36
|
|
|
666
|
-
|
|
667
|
-
// TypeScript enforces all variants are handled
|
|
668
|
-
const response = TaggedError.match(result.error, {
|
|
669
|
-
NotFoundError: (e) => ({
|
|
670
|
-
status: 404,
|
|
671
|
-
body: { error: 'not_found', resource: e.resource, id: e.id },
|
|
672
|
-
}),
|
|
673
|
-
ValidationError: (e) => ({
|
|
674
|
-
status: 400,
|
|
675
|
-
body: { error: 'validation', field: e.field, reason: e.reason },
|
|
676
|
-
}),
|
|
677
|
-
RateLimitError: (e) => ({
|
|
678
|
-
status: 429,
|
|
679
|
-
body: { error: 'rate_limited', retryAfter: e.retryAfter },
|
|
680
|
-
}),
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
return res.status(response.status).json(response.body);
|
|
684
|
-
}
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
**Add a new error type? TypeScript will error until you handle it.** This catches bugs at compile time, not production.
|
|
688
|
-
|
|
689
|
-
### Partial Matching with Fallback
|
|
690
|
-
|
|
691
|
-
Handle specific errors and catch-all for the rest:
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
const message = TaggedError.matchPartial(
|
|
695
|
-
result.error,
|
|
696
|
-
{
|
|
697
|
-
RateLimitError: (e) => `Please wait ${e.retryAfter} seconds`,
|
|
698
|
-
},
|
|
699
|
-
(e) => `Something went wrong: ${e.message}` // Fallback for other errors
|
|
700
|
-
);
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
### Real-World Example: Payment Errors
|
|
704
|
-
|
|
705
|
-
```typescript
|
|
706
|
-
// Type-safe message with Props inferred from callback annotation
|
|
707
|
-
class PaymentDeclinedError extends TaggedError('PaymentDeclinedError', {
|
|
708
|
-
message: (p: {
|
|
709
|
-
code: string;
|
|
710
|
-
declineReason: 'insufficient_funds' | 'card_expired' | 'fraud_suspected';
|
|
711
|
-
}) => `Payment declined: ${p.declineReason}`,
|
|
712
|
-
}) {}
|
|
713
|
-
|
|
714
|
-
class PaymentProviderError extends TaggedError('PaymentProviderError', {
|
|
715
|
-
message: (p: { provider: string; statusCode: number }) => `${p.provider} returned ${p.statusCode}`,
|
|
716
|
-
}) {}
|
|
717
|
-
|
|
718
|
-
type PaymentError = PaymentDeclinedError | PaymentProviderError;
|
|
719
|
-
|
|
720
|
-
// In your workflow
|
|
721
|
-
if (!result.ok) {
|
|
722
|
-
TaggedError.match(result.error, {
|
|
723
|
-
PaymentDeclinedError: (e) => {
|
|
724
|
-
switch (e.declineReason) {
|
|
725
|
-
case 'insufficient_funds': notifyUser('Please use a different card');
|
|
726
|
-
case 'card_expired': notifyUser('Your card has expired');
|
|
727
|
-
case 'fraud_suspected': alertFraudTeam(e);
|
|
728
|
-
}
|
|
729
|
-
},
|
|
730
|
-
PaymentProviderError: (e) => {
|
|
731
|
-
logToDatadog({ provider: e.provider, status: e.statusCode });
|
|
732
|
-
retryWithBackup(e.provider);
|
|
733
|
-
},
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
```
|
|
737
|
-
|
|
738
|
-
### Type Helpers
|
|
739
|
-
|
|
740
|
-
```typescript
|
|
741
|
-
import { type TagOf, type ErrorByTag } from '@jagreehal/workflow';
|
|
742
|
-
|
|
743
|
-
type PaymentError = PaymentDeclinedError | PaymentProviderError;
|
|
744
|
-
|
|
745
|
-
// Extract the tag literal type
|
|
746
|
-
type Tags = TagOf<PaymentError>; // 'PaymentDeclinedError' | 'PaymentProviderError'
|
|
747
|
-
|
|
748
|
-
// Extract a specific variant from the union
|
|
749
|
-
type Declined = ErrorByTag<PaymentError, 'PaymentDeclinedError'>; // PaymentDeclinedError
|
|
750
|
-
```
|
|
751
|
-
|
|
752
|
-
### Reserved Keys & Cause Handling
|
|
753
|
-
|
|
754
|
-
**Reserved property names** are stripped from props to preserve Error semantics:
|
|
755
|
-
|
|
756
|
-
| Key | Reason |
|
|
757
|
-
|-----|--------|
|
|
758
|
-
| `_tag` | Discriminant for pattern matching (cannot be forged) |
|
|
759
|
-
| `name` | Error.name (shown in stack traces/logs) |
|
|
760
|
-
| `message` | Error.message (shown in logs/telemetry) |
|
|
761
|
-
| `stack` | Error.stack (the stack trace) |
|
|
762
|
-
|
|
763
|
-
```typescript
|
|
764
|
-
// These reserved keys are silently stripped - use different names!
|
|
765
|
-
class BadExample extends TaggedError('BadExample')<{
|
|
766
|
-
message: string; // ❌ Won't work - use 'details' or 'description' instead
|
|
767
|
-
id: string;
|
|
768
|
-
}> {}
|
|
769
|
-
|
|
770
|
-
// Better approach:
|
|
771
|
-
class GoodExample extends TaggedError('GoodExample')<{
|
|
772
|
-
details: string; // ✅ Custom prop name
|
|
773
|
-
id: string;
|
|
774
|
-
}> {}
|
|
775
|
-
```
|
|
776
|
-
|
|
777
|
-
**The `cause` property** is special - it can be used as a user prop for domain data:
|
|
778
|
-
|
|
779
|
-
```typescript
|
|
780
|
-
// cause as domain data (e.g., structured validation errors)
|
|
781
|
-
class ValidationError extends TaggedError('ValidationError')<{
|
|
782
|
-
cause: { field: string; reason: string }; // ✅ Allowed as user prop
|
|
783
|
-
}> {}
|
|
784
|
-
|
|
785
|
-
const error = new ValidationError({
|
|
786
|
-
cause: { field: 'email', reason: 'invalid format' },
|
|
787
|
-
});
|
|
788
|
-
console.log(error.cause); // { field: 'email', reason: 'invalid format' }
|
|
789
|
-
```
|
|
790
|
-
|
|
791
|
-
**Conflict detection**: You cannot provide `cause` in both props AND ErrorOptions:
|
|
792
|
-
|
|
793
|
-
```typescript
|
|
794
|
-
// This throws TypeError at runtime!
|
|
795
|
-
const error = new ValidationError(
|
|
796
|
-
{ cause: { field: 'email', reason: 'invalid' } }, // cause in props
|
|
797
|
-
{ cause: new Error('original') } // cause in options
|
|
798
|
-
);
|
|
799
|
-
// TypeError: cannot provide 'cause' in props when also setting ErrorOptions.cause
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
Use ErrorOptions.cause for error chaining (linking to the original error):
|
|
803
|
-
|
|
804
|
-
```typescript
|
|
805
|
-
class NetworkError extends TaggedError('NetworkError')<{
|
|
806
|
-
url: string;
|
|
807
|
-
statusCode: number;
|
|
808
|
-
}> {}
|
|
809
|
-
|
|
810
|
-
try {
|
|
811
|
-
await fetch('/api');
|
|
812
|
-
} catch (original) {
|
|
813
|
-
// Chain to original error via ErrorOptions (not props)
|
|
814
|
-
throw new NetworkError(
|
|
815
|
-
{ url: '/api', statusCode: 500 },
|
|
816
|
-
{ cause: original } // ✅ Error chaining
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
### Summary
|
|
822
|
-
|
|
823
|
-
- **String literals**: Simple, great for distinct states without extra data
|
|
824
|
-
- **TaggedError**: Rich errors with context, exhaustive matching, proper stack traces
|
|
825
|
-
- **`match()`**: Forces you to handle every variant (compile-time safety)
|
|
826
|
-
- **`matchPartial()`**: Handle some variants with a catch-all fallback
|
|
827
|
-
|
|
828
|
-
## Recipes & Patterns
|
|
829
|
-
|
|
830
|
-
### Core Recipes
|
|
831
|
-
|
|
832
|
-
#### Basic Workflow
|
|
833
|
-
|
|
834
|
-
```typescript
|
|
835
|
-
const result = await loadUserData(async (step) => {
|
|
836
|
-
const user = await step(fetchUser('1'));
|
|
837
|
-
const posts = await step(fetchPosts(user.id));
|
|
838
|
-
return { user, posts };
|
|
839
|
-
});
|
|
840
|
-
```
|
|
841
|
-
|
|
842
|
-
#### User Signup
|
|
843
|
-
|
|
844
|
-
```typescript
|
|
845
|
-
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
|
|
846
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
847
|
-
|
|
848
|
-
const checkDuplicate = async (email: string): AsyncResult<void, 'EMAIL_EXISTS'> =>
|
|
849
|
-
email === 'taken@example.com' ? err('EMAIL_EXISTS') : ok(undefined);
|
|
850
|
-
|
|
851
|
-
const createAccount = async (email: string): AsyncResult<{ id: string }, 'DB_ERROR'> =>
|
|
852
|
-
ok({ id: crypto.randomUUID() });
|
|
853
|
-
|
|
854
|
-
const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> => ok(undefined);
|
|
855
|
-
|
|
856
|
-
const signUp = createWorkflow({ validateEmail, checkDuplicate, createAccount, sendWelcome });
|
|
857
|
-
|
|
858
|
-
const result = await signUp(async (step) => {
|
|
859
|
-
const email = await step(validateEmail('user@example.com'));
|
|
860
|
-
await step(checkDuplicate(email));
|
|
861
|
-
const account = await step(createAccount(email));
|
|
862
|
-
await step(sendWelcome(account.id));
|
|
863
|
-
return account;
|
|
864
|
-
});
|
|
865
|
-
// result.error: 'INVALID_EMAIL' | 'EMAIL_EXISTS' | 'DB_ERROR' | 'EMAIL_FAILED' | UnexpectedError
|
|
866
|
-
```
|
|
867
|
-
|
|
868
|
-
#### Checkout Flow
|
|
869
|
-
|
|
870
|
-
```typescript
|
|
871
|
-
const authenticate = async (token: string): AsyncResult<{ userId: string }, 'UNAUTHORIZED'> =>
|
|
872
|
-
token === 'valid' ? ok({ userId: 'user-1' }) : err('UNAUTHORIZED');
|
|
873
|
-
|
|
874
|
-
const fetchOrder = async (id: string): AsyncResult<{ total: number }, 'ORDER_NOT_FOUND'> =>
|
|
875
|
-
ok({ total: 99 });
|
|
876
|
-
|
|
877
|
-
const chargeCard = async (amount: number): AsyncResult<{ txId: string }, 'PAYMENT_FAILED'> =>
|
|
878
|
-
ok({ txId: 'tx-123' });
|
|
879
|
-
|
|
880
|
-
const checkout = createWorkflow({ authenticate, fetchOrder, chargeCard });
|
|
881
|
-
|
|
882
|
-
const result = await checkout(async (step) => {
|
|
883
|
-
const auth = await step(authenticate(token));
|
|
884
|
-
const order = await step(fetchOrder(orderId));
|
|
885
|
-
const payment = await step(chargeCard(order.total));
|
|
886
|
-
return { userId: auth.userId, txId: payment.txId };
|
|
887
|
-
});
|
|
888
|
-
// result.error: 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
|
|
889
|
-
```
|
|
890
|
-
|
|
891
|
-
#### Composing Workflows
|
|
892
|
-
|
|
893
|
-
You can combine multiple workflows together. The error types automatically aggregate:
|
|
894
|
-
|
|
895
|
-
```typescript
|
|
896
|
-
// Validation workflow
|
|
897
|
-
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
|
|
898
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
899
|
-
|
|
900
|
-
const validatePassword = async (pwd: string): AsyncResult<string, 'WEAK_PASSWORD'> =>
|
|
901
|
-
pwd.length >= 8 ? ok(pwd) : err('WEAK_PASSWORD');
|
|
902
|
-
|
|
903
|
-
const validationWorkflow = createWorkflow({ validateEmail, validatePassword });
|
|
904
|
-
|
|
905
|
-
// Checkout workflow
|
|
906
|
-
const checkoutWorkflow = createWorkflow({ authenticate, fetchOrder, chargeCard });
|
|
907
|
-
|
|
908
|
-
// Composed workflow: validation + checkout
|
|
909
|
-
// Include all dependencies from both workflows
|
|
910
|
-
const validateAndCheckout = createWorkflow({
|
|
911
|
-
validateEmail,
|
|
912
|
-
validatePassword,
|
|
913
|
-
authenticate,
|
|
914
|
-
fetchOrder,
|
|
915
|
-
chargeCard,
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
const result = await validateAndCheckout(async (step) => {
|
|
919
|
-
// Validation steps
|
|
920
|
-
const email = await step(validateEmail('user@example.com'));
|
|
921
|
-
const password = await step(validatePassword('secret123'));
|
|
922
|
-
|
|
923
|
-
// Checkout steps
|
|
924
|
-
const auth = await step(authenticate('valid'));
|
|
925
|
-
const order = await step(fetchOrder('order-1'));
|
|
926
|
-
const payment = await step(chargeCard(order.total));
|
|
927
|
-
|
|
928
|
-
return { email, password, userId: auth.userId, txId: payment.txId };
|
|
929
|
-
});
|
|
930
|
-
// result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
|
|
931
|
-
```
|
|
932
|
-
|
|
933
|
-
### Common Patterns
|
|
934
|
-
|
|
935
|
-
- **Validation & gating** – Run early workflows so later steps never execute for invalid data.
|
|
936
|
-
|
|
937
|
-
```typescript
|
|
938
|
-
const validated = await step(() => validationWorkflow(async (inner) => inner(deps.validateEmail(email))));
|
|
939
|
-
```
|
|
940
|
-
|
|
941
|
-
- **API calls with typed errors** – Wrap fetch/axios via `step.try` and switch on the union later.
|
|
942
|
-
|
|
943
|
-
```typescript
|
|
944
|
-
const payload = await step.try(() => fetch(url).then((r) => r.json()), { error: 'HTTP_FAILED' });
|
|
945
|
-
```
|
|
946
|
-
|
|
947
|
-
- **Wrapping Result-returning functions** – Use `step.fromResult` to preserve rich error types.
|
|
948
|
-
|
|
949
|
-
```typescript
|
|
950
|
-
const response = await step.fromResult(
|
|
951
|
-
() => callProvider(input),
|
|
952
|
-
{
|
|
953
|
-
onError: (e) => ({
|
|
954
|
-
type: 'PROVIDER_FAILED' as const,
|
|
955
|
-
provider: e.provider, // TypeScript knows e is ProviderError
|
|
956
|
-
code: e.code,
|
|
957
|
-
})
|
|
958
|
-
}
|
|
959
|
-
);
|
|
960
|
-
```
|
|
961
|
-
|
|
962
|
-
- **Retries, backoff, and timeouts** – Built into `step.retry()` and `step.withTimeout()`.
|
|
963
|
-
|
|
964
|
-
```typescript
|
|
965
|
-
const data = await step.retry(
|
|
966
|
-
() => step.withTimeout(() => fetchData(), { ms: 2000 }),
|
|
967
|
-
{ attempts: 3, backoff: 'exponential', retryOn: (error) => error !== 'FATAL' }
|
|
968
|
-
);
|
|
969
|
-
```
|
|
970
|
-
|
|
971
|
-
- **State save & resume** – Persist step completions and resume later.
|
|
972
|
-
|
|
973
|
-
```typescript
|
|
974
|
-
import { createWorkflow, createStepCollector } from '@jagreehal/workflow';
|
|
975
|
-
|
|
976
|
-
// Collect state during execution
|
|
977
|
-
const collector = createStepCollector();
|
|
978
|
-
const workflow = createWorkflow(deps, {
|
|
979
|
-
onEvent: collector.handleEvent, // Automatically collects step_complete events
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
await workflow(async (step) => {
|
|
983
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" });
|
|
984
|
-
return user;
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
// Get collected state
|
|
988
|
-
const state = collector.getState();
|
|
989
|
-
|
|
990
|
-
// Resume later
|
|
991
|
-
const resumed = createWorkflow(deps, { resumeState: state });
|
|
992
|
-
```
|
|
993
|
-
|
|
994
|
-
- **Human-in-the-loop approvals** – Pause a workflow until someone approves.
|
|
995
|
-
|
|
996
|
-
```typescript
|
|
997
|
-
import { createApprovalStep, isPendingApproval, injectApproval } from '@jagreehal/workflow';
|
|
998
|
-
|
|
999
|
-
const requireApproval = createApprovalStep({ key: 'approval:deploy', checkApproval: async () => {/* ... */} });
|
|
1000
|
-
const result = await workflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
|
|
1001
|
-
|
|
1002
|
-
if (!result.ok && isPendingApproval(result.error)) {
|
|
1003
|
-
// notify operators, later call injectApproval(savedState, { stepKey, value })
|
|
1004
|
-
}
|
|
1005
|
-
```
|
|
1006
|
-
|
|
1007
|
-
- **Caching & deduplication** – Give steps names + keys.
|
|
1008
|
-
|
|
1009
|
-
```typescript
|
|
1010
|
-
const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
|
|
1011
|
-
```
|
|
1012
|
-
|
|
1013
|
-
- **Branching logic** - It's just JavaScript - use normal `if`/`switch`.
|
|
1014
|
-
|
|
1015
|
-
```typescript
|
|
1016
|
-
const user = await step(fetchUser(id));
|
|
1017
|
-
|
|
1018
|
-
if (user.role === 'admin') {
|
|
1019
|
-
return await step(fetchAdminDashboard(user.id));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (user.subscription === 'free') {
|
|
1023
|
-
return await step(fetchFreeTierData(user.id));
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
return await step(fetchPremiumData(user.id));
|
|
1027
|
-
```
|
|
1028
|
-
|
|
1029
|
-
- **Parallel operations** – Use helpers when you truly need concurrency.
|
|
1030
|
-
|
|
1031
|
-
```typescript
|
|
1032
|
-
import { allAsync, partition, map } from '@jagreehal/workflow';
|
|
1033
|
-
|
|
1034
|
-
const result = await allAsync([
|
|
1035
|
-
fetchUser('1'),
|
|
1036
|
-
fetchPosts('1'),
|
|
1037
|
-
]);
|
|
1038
|
-
const data = map(result, ([user, posts]) => ({ user, posts }));
|
|
1039
|
-
```
|
|
1040
|
-
|
|
1041
|
-
## Real-World Example: Safe Payment Retries with Persistence
|
|
1042
|
-
|
|
1043
|
-
The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
|
|
1044
|
-
|
|
1045
|
-
Step keys + persistence solve this. Save state to a database, and if the workflow crashes, resume from the last successful step:
|
|
1046
|
-
|
|
1047
|
-
```typescript
|
|
1048
|
-
import { createWorkflow, createStepCollector, stringifyState, parseState } from '@jagreehal/workflow';
|
|
1049
|
-
|
|
1050
|
-
const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
|
|
1051
|
-
|
|
1052
|
-
// Collect state for persistence
|
|
1053
|
-
const collector = createStepCollector();
|
|
1054
|
-
const workflow = createWorkflow(
|
|
1055
|
-
{ validateCard, chargeProvider, persistResult },
|
|
1056
|
-
{ onEvent: collector.handleEvent }
|
|
1057
|
-
);
|
|
1058
|
-
|
|
1059
|
-
const result = await workflow(async (step) => {
|
|
1060
|
-
const card = await step(() => validateCard(input), { key: 'validate' });
|
|
1061
|
-
|
|
1062
|
-
// This is the dangerous step. Once it succeeds, never repeat it:
|
|
1063
|
-
const charge = await step(() => chargeProvider(card), {
|
|
1064
|
-
key: `charge:${input.idempotencyKey}`,
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
// If THIS fails (DB down), save state and rerun later.
|
|
1068
|
-
// The charge step is cached - it won't execute again.
|
|
1069
|
-
await step(() => persistResult(charge), { key: `persist:${charge.id}` });
|
|
1070
|
-
|
|
1071
|
-
return { paymentId: charge.id };
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
// Save state after each run (or on crash)
|
|
37
|
+
// Handle result
|
|
1075
38
|
if (result.ok) {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
update: { state: json, updatedAt: new Date() },
|
|
1081
|
-
create: { idempotencyKey: input.idempotencyKey, state: json },
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1084
|
-
```
|
|
1085
|
-
|
|
1086
|
-
**Crash recovery:** If the workflow crashes after charging but before persisting:
|
|
1087
|
-
|
|
1088
|
-
```typescript
|
|
1089
|
-
// On restart, load saved state
|
|
1090
|
-
const saved = await db.workflowStates.findUnique({
|
|
1091
|
-
where: { idempotencyKey: input.idempotencyKey },
|
|
1092
|
-
});
|
|
1093
|
-
|
|
1094
|
-
if (saved) {
|
|
1095
|
-
const savedState = parseState(saved.state);
|
|
1096
|
-
const workflow = createWorkflow(
|
|
1097
|
-
{ validateCard, chargeProvider, persistResult },
|
|
1098
|
-
{ resumeState: savedState }
|
|
1099
|
-
);
|
|
1100
|
-
|
|
1101
|
-
// Resume - charge step uses cached result, no double-billing!
|
|
1102
|
-
const result = await workflow(async (step) => {
|
|
1103
|
-
const card = await step(() => validateCard(input), { key: 'validate' }); // Cache hit
|
|
1104
|
-
const charge = await step(() => chargeProvider(card), {
|
|
1105
|
-
key: `charge:${input.idempotencyKey}`,
|
|
1106
|
-
}); // Cache hit - returns previous charge result
|
|
1107
|
-
await step(() => persistResult(charge), { key: `persist:${charge.id}` }); // Executes fresh
|
|
1108
|
-
return { paymentId: charge.id };
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
```
|
|
1112
|
-
|
|
1113
|
-
Crash after charging but before persisting? Resume the workflow. The charge step returns its cached result. No double-billing.
|
|
1114
|
-
|
|
1115
|
-
## Is This Library Right for You?
|
|
1116
|
-
|
|
1117
|
-
```mermaid
|
|
1118
|
-
flowchart TD
|
|
1119
|
-
Start([Need typed errors?]) --> Simple{Simple use case?}
|
|
1120
|
-
|
|
1121
|
-
Simple -->|Yes| TryCatch["try/catch is fine"]
|
|
1122
|
-
Simple -->|No| WantAsync{Want async/await syntax?}
|
|
1123
|
-
|
|
1124
|
-
WantAsync -->|Yes| NeedOrchestration{Need retries/caching/resume?}
|
|
1125
|
-
WantAsync -->|No| Neverthrow["Consider neverthrow"]
|
|
1126
|
-
|
|
1127
|
-
NeedOrchestration -->|Yes| Workflow["✓ @jagreehal/workflow"]
|
|
1128
|
-
NeedOrchestration -->|No| Either["Either works - workflow adds room to grow"]
|
|
1129
|
-
|
|
1130
|
-
style Workflow fill:#E8F5E9
|
|
1131
|
-
```
|
|
1132
|
-
|
|
1133
|
-
**Choose this library when:**
|
|
1134
|
-
|
|
1135
|
-
- You want Result types with familiar async/await syntax
|
|
1136
|
-
- You need automatic error type inference
|
|
1137
|
-
- You're building workflows that benefit from step caching or resume
|
|
1138
|
-
- You want type-safe error handling without Effect's learning curve
|
|
1139
|
-
|
|
1140
|
-
## How It Compares
|
|
1141
|
-
|
|
1142
|
-
**`try/catch` everywhere** - You lose error types. Every catch block sees `unknown`. Retries? Manual. Timeouts? Manual. Observability? Hope you remembered to add logging.
|
|
1143
|
-
|
|
1144
|
-
**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.
|
|
1145
|
-
|
|
1146
|
-
**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.
|
|
1147
|
-
|
|
1148
|
-
### vs neverthrow
|
|
1149
|
-
|
|
1150
|
-
| Aspect | neverthrow | workflow |
|
|
1151
|
-
|--------|-----------|----------|
|
|
1152
|
-
| **Chaining style** | `.andThen()` method chains (nest with 3+ ops) | `step()` with async/await (stays flat) |
|
|
1153
|
-
| **Error inference** | Manual: `type Errors = 'A' \| 'B' \| 'C'` | Automatic from `createWorkflow({ deps })` |
|
|
1154
|
-
| **Result access** | `.isOk()`, `.isErr()` methods | `.ok` boolean property |
|
|
1155
|
-
| **Wrapping throws** | `ResultAsync.fromPromise(p, mapErr)` | `step.try(fn, { error })` or wrap in AsyncResult |
|
|
1156
|
-
| **Parallel ops** | `ResultAsync.combine([...])` | `allAsync([...])` |
|
|
1157
|
-
| **Retries** | DIY with recursive `.orElse()` | Built-in `step.retry({ attempts, backoff })` |
|
|
1158
|
-
| **Timeouts** | DIY with `Promise.race()` | Built-in `step.withTimeout({ ms })` |
|
|
1159
|
-
| **Caching** | DIY | Built-in with `{ key: 'cache-key' }` |
|
|
1160
|
-
| **Resume/persist** | DIY | Built-in with `resumeState` + `isStepComplete()` |
|
|
1161
|
-
| **Events** | DIY | 15+ event types via `onEvent` |
|
|
1162
|
-
|
|
1163
|
-
**When to use neverthrow:** You want typed Results with minimal bundle size and prefer functional chaining.
|
|
1164
|
-
|
|
1165
|
-
**When to use workflow:** You want typed Results with async/await syntax, automatic error inference, and built-in reliability primitives.
|
|
1166
|
-
|
|
1167
|
-
See [Coming from neverthrow](docs/coming-from-neverthrow.md) for pattern-by-pattern equivalents.
|
|
1168
|
-
|
|
1169
|
-
### Where workflow shines
|
|
1170
|
-
|
|
1171
|
-
**Complex checkout flows:**
|
|
1172
|
-
```typescript
|
|
1173
|
-
// 5 different error types, all automatically inferred
|
|
1174
|
-
const checkout = createWorkflow({ validateCart, checkInventory, getPricing, processPayment, createOrder });
|
|
1175
|
-
|
|
1176
|
-
const result = await checkout(async (step) => {
|
|
1177
|
-
const cart = await step(() => validateCart(input));
|
|
1178
|
-
|
|
1179
|
-
// Parallel execution stays clean
|
|
1180
|
-
const [inventory, pricing] = await step(() => allAsync([
|
|
1181
|
-
checkInventory(cart.items),
|
|
1182
|
-
getPricing(cart.items)
|
|
1183
|
-
]));
|
|
1184
|
-
|
|
1185
|
-
const payment = await step(() => processPayment(cart, pricing.total));
|
|
1186
|
-
return await step(() => createOrder(cart, payment));
|
|
1187
|
-
});
|
|
1188
|
-
// TypeScript knows: Result<Order, ValidationError | InventoryError | PricingError | PaymentError | OrderError>
|
|
1189
|
-
```
|
|
1190
|
-
|
|
1191
|
-
**Branching logic with native control flow:**
|
|
1192
|
-
```typescript
|
|
1193
|
-
// Just JavaScript - no functional gymnastics
|
|
1194
|
-
const tenant = await step(() => fetchTenant(id));
|
|
1195
|
-
|
|
1196
|
-
if (tenant.plan === 'free') {
|
|
1197
|
-
return await step(() => calculateFreeUsage(tenant));
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// Variables from earlier steps are in scope - no closure drilling
|
|
1201
|
-
const [users, resources] = await step(() => allAsync([fetchUsers(), fetchResources()]));
|
|
1202
|
-
|
|
1203
|
-
switch (tenant.plan) {
|
|
1204
|
-
case 'pro': await step(() => sendProNotification(tenant)); break;
|
|
1205
|
-
case 'enterprise': await step(() => sendEnterpriseNotification(tenant)); break;
|
|
39
|
+
console.log(result.value.name);
|
|
40
|
+
} else {
|
|
41
|
+
// TypeScript knows: 'NOT_FOUND' | 'SEND_FAILED' | UnexpectedError
|
|
42
|
+
console.error(result.error);
|
|
1206
43
|
}
|
|
1207
44
|
```
|
|
1208
45
|
|
|
1209
|
-
**Data pipelines with caching and resume:**
|
|
1210
|
-
```typescript
|
|
1211
|
-
const pipeline = createWorkflow(deps, { cache: new Map() });
|
|
1212
|
-
|
|
1213
|
-
const result = await pipeline(async (step) => {
|
|
1214
|
-
// `key` enables caching and resume from last successful step
|
|
1215
|
-
const user = await step(() => fetchUser(id), { key: 'user' });
|
|
1216
|
-
const posts = await step(() => fetchPosts(user.id), { key: 'posts' });
|
|
1217
|
-
const comments = await step(() => fetchComments(posts), { key: 'comments' });
|
|
1218
|
-
return { user, posts, comments };
|
|
1219
|
-
}, { resumeState: savedState });
|
|
1220
|
-
```
|
|
1221
|
-
|
|
1222
|
-
## Quick Reference
|
|
1223
|
-
|
|
1224
|
-
### Workflow Builders
|
|
1225
|
-
|
|
1226
|
-
| API | Description |
|
|
1227
|
-
|-----|-------------|
|
|
1228
|
-
| `createWorkflow(deps, opts?)` | Reusable workflow with automatic error unions, caching, resume, events, strict mode. |
|
|
1229
|
-
| `run(executor, opts?)` | One-off workflow; you supply `Output` and `Error` generics manually. |
|
|
1230
|
-
| `createSagaWorkflow(deps, opts?)` | Workflow with automatic compensation handlers. |
|
|
1231
|
-
| `createWorkflowHarness(deps, opts?)` | Testing harness with deterministic step control. |
|
|
1232
|
-
|
|
1233
|
-
### Step Helpers
|
|
1234
|
-
|
|
1235
|
-
| API | Description |
|
|
1236
|
-
|-----|-------------|
|
|
1237
|
-
| `step(op, meta?)` | Execute a dependency or thunk. Supports `{ key, name, retry, timeout }`. |
|
|
1238
|
-
| `step.try(fn, { error })` | Catch throws/rejections and emit a typed error. |
|
|
1239
|
-
| `step.fromResult(fn, { onError })` | Preserve rich error objects from other Result-returning code. |
|
|
1240
|
-
| `step.retry(fn, opts)` | Retries with fixed/linear/exponential backoff, jitter, and predicates. |
|
|
1241
|
-
| `step.withTimeout(fn, { ms, signal?, name? })` | Auto-timeout operations and optionally pass AbortSignal. |
|
|
1242
|
-
|
|
1243
|
-
### Result & Utility Helpers
|
|
1244
|
-
|
|
1245
|
-
| API | Description |
|
|
1246
|
-
|-----|-------------|
|
|
1247
|
-
| `ok(value)` / `err(error)` | Construct Results. |
|
|
1248
|
-
| `map`, `mapError`, `bimap` | Transform values or errors. |
|
|
1249
|
-
| `andThen`, `match` | Chain or pattern-match Results. |
|
|
1250
|
-
| `orElse`, `recover` | Error recovery and fallback patterns. |
|
|
1251
|
-
| `allAsync`, `partition` | Batch operations where the first error wins or you collect everything. |
|
|
1252
|
-
| `isStepTimeoutError(error)` | Runtime guard for timeout failures. |
|
|
1253
|
-
| `getStepTimeoutMeta(error)` | Inspect timeout metadata (attempt, ms, name). |
|
|
1254
|
-
| `createCircuitBreaker(name, config)` | Guard dependencies with open/close behavior. |
|
|
1255
|
-
| `createRateLimiter(name, config)` | Ensure steps respect throughput policies. |
|
|
1256
|
-
| `createWebhookHandler(workflow, fn, config)` | Turn workflows into HTTP handlers quickly. |
|
|
1257
|
-
|
|
1258
|
-
### Choosing Between run() and createWorkflow()
|
|
1259
|
-
|
|
1260
|
-
| Use Case | Recommendation |
|
|
1261
|
-
|----------|----------------|
|
|
1262
|
-
| Dependencies known at compile time | `createWorkflow()` |
|
|
1263
|
-
| Dependencies passed as parameters | `run()` |
|
|
1264
|
-
| Need step caching or resume | `createWorkflow()` |
|
|
1265
|
-
| One-off workflow invocation | `run()` |
|
|
1266
|
-
| Want automatic error inference | `createWorkflow()` |
|
|
1267
|
-
| Error types known upfront | `run()` |
|
|
1268
|
-
|
|
1269
|
-
**`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
|
|
1270
|
-
|
|
1271
|
-
```typescript
|
|
1272
|
-
import { run } from '@jagreehal/workflow';
|
|
1273
|
-
|
|
1274
|
-
const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
|
|
1275
|
-
async (step) => {
|
|
1276
|
-
const user = await step(fetchUser(userId)); // userId from parameter
|
|
1277
|
-
return user;
|
|
1278
|
-
},
|
|
1279
|
-
{ onError: (e) => console.log('Failed:', e) }
|
|
1280
|
-
);
|
|
1281
|
-
```
|
|
1282
|
-
|
|
1283
|
-
**`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
|
|
1284
|
-
|
|
1285
|
-
```typescript
|
|
1286
|
-
const loadUser = createWorkflow({ fetchUser, fetchPosts });
|
|
1287
|
-
// Error type computed automatically from deps
|
|
1288
|
-
```
|
|
1289
|
-
|
|
1290
|
-
### Import paths
|
|
1291
|
-
|
|
1292
|
-
```typescript
|
|
1293
|
-
import { createWorkflow, ok, err } from '@jagreehal/workflow';
|
|
1294
|
-
import { createWorkflow } from '@jagreehal/workflow/workflow';
|
|
1295
|
-
import { ok, err, map, all } from '@jagreehal/workflow/core';
|
|
1296
|
-
```
|
|
1297
|
-
|
|
1298
|
-
## Common Pitfalls
|
|
1299
|
-
|
|
1300
|
-
**Use thunks for caching.** `step(fetchUser('1'))` executes immediately. Use `step(() => fetchUser('1'), { key })` for caching to work.
|
|
1301
|
-
|
|
1302
|
-
**Keys must be stable.** Use `user:${id}`, not `user:${Date.now()}`.
|
|
1303
|
-
|
|
1304
|
-
**Don't cache writes blindly.** Payments need carefully designed idempotency keys.
|
|
1305
|
-
|
|
1306
|
-
## Troubleshooting & FAQ
|
|
1307
|
-
|
|
1308
|
-
- **Why is `UnexpectedError` in my union?** Add `{ strict: true, catchUnexpected: () => 'UNEXPECTED' }` when creating the workflow to map unknown errors explicitly.
|
|
1309
|
-
- **How do I inspect what ran?** Pass `onEvent` and log `step_*` / `workflow_*` events or feed them into `createIRBuilder()` for diagrams.
|
|
1310
|
-
- **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.
|
|
1311
|
-
- **Cache is not used between runs.** Supply a stable `{ key }` per step and provide a cache/resume adapter in `createWorkflow(deps, { cache })`.
|
|
1312
|
-
- **I only need a single run with dynamic dependencies.** Use `run()` instead of `createWorkflow()` and pass dependencies directly to the executor.
|
|
1313
|
-
|
|
1314
|
-
## Visualizing Workflows
|
|
1315
|
-
|
|
1316
|
-
Hook into the event stream and render diagrams for docs, PRs, or dashboards:
|
|
1317
|
-
|
|
1318
|
-
```typescript
|
|
1319
|
-
import { createVisualizer } from '@jagreehal/workflow/visualize';
|
|
1320
|
-
|
|
1321
|
-
const viz = createVisualizer({ workflowName: 'user-posts-flow' });
|
|
1322
|
-
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
1323
|
-
onEvent: viz.handleEvent,
|
|
1324
|
-
});
|
|
1325
|
-
|
|
1326
|
-
await workflow(async (step) => {
|
|
1327
|
-
const user = await step(() => fetchUser('1'), { name: 'Fetch user' });
|
|
1328
|
-
const posts = await step(() => fetchPosts(user.id), { name: 'Fetch posts' });
|
|
1329
|
-
return { user, posts };
|
|
1330
|
-
});
|
|
1331
|
-
|
|
1332
|
-
// ASCII output for terminal/CLI
|
|
1333
|
-
console.log(viz.render());
|
|
1334
|
-
|
|
1335
|
-
// Mermaid diagram for Markdown/docs
|
|
1336
|
-
console.log(viz.renderAs('mermaid'));
|
|
1337
|
-
|
|
1338
|
-
// JSON IR for programmatic access
|
|
1339
|
-
console.log(viz.renderAs('json'));
|
|
1340
|
-
```
|
|
1341
|
-
|
|
1342
|
-
Mermaid output drops directly into Markdown for documentation. The ASCII block is handy for CLI screenshots or incident runbooks.
|
|
1343
|
-
|
|
1344
|
-
**For post-execution visualization**, collect events and visualize later:
|
|
1345
|
-
|
|
1346
|
-
```typescript
|
|
1347
|
-
import { createEventCollector } from '@jagreehal/workflow/visualize';
|
|
1348
|
-
|
|
1349
|
-
const collector = createEventCollector({ workflowName: 'my-workflow' });
|
|
1350
|
-
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
1351
|
-
onEvent: collector.handleEvent,
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
await workflow(async (step) => { /* ... */ });
|
|
1355
|
-
|
|
1356
|
-
// Visualize collected events
|
|
1357
|
-
console.log(collector.visualize());
|
|
1358
|
-
console.log(collector.visualizeAs('mermaid'));
|
|
1359
|
-
```
|
|
1360
|
-
|
|
1361
|
-
## Keep Going
|
|
1362
|
-
|
|
1363
|
-
**Already using neverthrow?** [The migration guide](docs/coming-from-neverthrow.md) shows pattern-by-pattern equivalents - you'll feel at home quickly.
|
|
1364
|
-
|
|
1365
|
-
**Ready for production features?** [Advanced usage](docs/advanced.md) covers sagas, circuit breakers, rate limiting, persistence adapters, and HITL orchestration.
|
|
1366
|
-
|
|
1367
|
-
**Need the full API?** [API reference](docs/api.md) has everything in one place.
|
|
1368
|
-
|
|
1369
|
-
---
|
|
1370
|
-
|
|
1371
46
|
## License
|
|
1372
47
|
|
|
1373
48
|
MIT
|