awaitly 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 +397 -927
- package/dist/adapters.cjs +1 -1
- package/dist/adapters.cjs.map +1 -1
- package/dist/adapters.d.cts +1 -1
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +1 -1
- package/dist/adapters.js.map +1 -1
- package/dist/batch.cjs +1 -1
- package/dist/batch.cjs.map +1 -1
- package/dist/batch.d.cts +1 -1
- package/dist/batch.d.ts +1 -1
- package/dist/batch.js +1 -1
- package/dist/batch.js.map +1 -1
- package/dist/circuit-breaker.cjs +1 -1
- package/dist/circuit-breaker.cjs.map +1 -1
- package/dist/circuit-breaker.d.cts +1 -1
- package/dist/circuit-breaker.d.ts +1 -1
- package/dist/circuit-breaker.js +1 -1
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/conditional.d.cts +1 -1
- package/dist/conditional.d.ts +1 -1
- package/dist/{core-CxdbubQK.d.cts → core-BKqkPqOF.d.cts} +20 -3
- package/dist/{core-CxdbubQK.d.ts → core-BKqkPqOF.d.ts} +20 -3
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +1 -1
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/devtools.d.cts +1 -1
- package/dist/devtools.d.ts +1 -1
- package/dist/durable.cjs +1 -1
- package/dist/durable.cjs.map +1 -1
- package/dist/durable.d.cts +5 -5
- package/dist/durable.d.ts +5 -5
- package/dist/durable.js +1 -1
- package/dist/durable.js.map +1 -1
- package/dist/hitl.cjs +1 -1
- package/dist/hitl.cjs.map +1 -1
- package/dist/hitl.d.cts +3 -3
- package/dist/hitl.d.ts +3 -3
- package/dist/hitl.js +1 -1
- package/dist/hitl.js.map +1 -1
- package/dist/index.cjs +1 -1
- 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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/otel.d.cts +1 -1
- package/dist/otel.d.ts +1 -1
- package/dist/{persistence-Bps_-g2-.d.ts → persistence-BmaP38VB.d.ts} +2 -2
- package/dist/{persistence-Co4_SpYv.d.cts → persistence-DBs3SyqK.d.cts} +2 -2
- package/dist/persistence.cjs +1 -1
- package/dist/persistence.cjs.map +1 -1
- package/dist/persistence.d.cts +3 -3
- package/dist/persistence.d.ts +3 -3
- package/dist/persistence.js +1 -1
- package/dist/persistence.js.map +1 -1
- package/dist/policies.d.cts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/ratelimit.cjs +1 -1
- package/dist/ratelimit.cjs.map +1 -1
- package/dist/ratelimit.d.cts +1 -1
- package/dist/ratelimit.d.ts +1 -1
- package/dist/ratelimit.js +1 -1
- package/dist/ratelimit.js.map +1 -1
- package/dist/reliability.cjs +1 -1
- package/dist/reliability.cjs.map +1 -1
- package/dist/reliability.d.cts +1 -1
- package/dist/reliability.d.ts +1 -1
- package/dist/reliability.js +1 -1
- package/dist/reliability.js.map +1 -1
- package/dist/resource.cjs +1 -1
- package/dist/resource.cjs.map +1 -1
- package/dist/resource.d.cts +1 -1
- package/dist/resource.d.ts +1 -1
- package/dist/resource.js +1 -1
- package/dist/resource.js.map +1 -1
- package/dist/saga.cjs +1 -1
- package/dist/saga.cjs.map +1 -1
- package/dist/saga.d.cts +1 -1
- package/dist/saga.d.ts +1 -1
- package/dist/saga.js +1 -1
- package/dist/saga.js.map +1 -1
- package/dist/singleflight.d.cts +1 -1
- package/dist/singleflight.d.ts +1 -1
- package/dist/testing.cjs +2 -2
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/dist/visualize.d.cts +1 -1
- package/dist/visualize.d.ts +1 -1
- package/dist/webhook.cjs +1 -1
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +2 -2
- package/dist/webhook.d.ts +2 -2
- package/dist/webhook.js +1 -1
- package/dist/webhook.js.map +1 -1
- package/dist/{workflow-C1Pt6ZaO.d.cts → workflow-884tiHWH.d.cts} +1 -1
- package/dist/{workflow-DJd6N9dy.d.ts → workflow-x4TnDXYW.d.ts} +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,25 +1,256 @@
|
|
|
1
1
|
# awaitly
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stop writing `try/catch` in every async handler.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
awaitly lets you:
|
|
6
|
+
- return errors as data (`ok` / `err`)
|
|
7
|
+
- compose async steps linearly
|
|
8
|
+
- TypeScript knows **all possible errors** automatically
|
|
9
|
+
- map errors at the boundary (HTTP, RPC, jobs)
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
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.
|
|
11
|
+
No exceptions for expected failures. No manual error unions.
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
npm install awaitly
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
📚 **[Full Documentation](https://jagreehal.github.io/awaitly/)**
|
|
17
|
+
📚 **[Full Documentation](https://jagreehal.github.io/awaitly/)** - guides, API reference, and examples.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The Problem
|
|
22
|
+
|
|
23
|
+
JavaScript async code conflates two kinds of failures:
|
|
24
|
+
|
|
25
|
+
- **Expected**: "User not found", "Payment declined" — these are business outcomes
|
|
26
|
+
- **Unexpected**: Network timeout, SDK crash, OOM — these are bugs
|
|
27
|
+
|
|
28
|
+
Traditional try/catch loses type information:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
try {
|
|
32
|
+
const user = await getUser(id);
|
|
33
|
+
const order = await createOrder(user);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// What type is error? unknown.
|
|
36
|
+
// Was it "user not found" or a network crash? No idea.
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
awaitly separates these: expected failures become typed data, unexpected failures become `UnexpectedError`.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Results as Data
|
|
45
|
+
|
|
46
|
+
Functions that can fail return `AsyncResult<SuccessType, ErrorType>`:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { ok, err, type AsyncResult } from "awaitly";
|
|
50
|
+
|
|
51
|
+
type User = { id: string; name: string };
|
|
52
|
+
type UserNotFound = { type: "USER_NOT_FOUND"; userId: string };
|
|
53
|
+
|
|
54
|
+
async function getUser(id: string): AsyncResult<User, UserNotFound> {
|
|
55
|
+
if (id === "u-1") return ok({ id, name: "Alice" });
|
|
56
|
+
return err({ type: "USER_NOT_FOUND", userId: id });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = await getUser("u-2");
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
console.log(result.value.name); // TypeScript knows this is User
|
|
62
|
+
} else {
|
|
63
|
+
console.log(result.error.userId); // TypeScript knows this is UserNotFound
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
No exceptions. TypeScript tracks every possible error.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## The Composition Problem
|
|
72
|
+
|
|
73
|
+
When you compose multiple Result-returning functions, you hit boilerplate:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// ❌ Every call needs: if (!result.ok) return result
|
|
77
|
+
async function processOrder(orderId: string) {
|
|
78
|
+
const orderResult = await getOrder(orderId);
|
|
79
|
+
if (!orderResult.ok) return orderResult; // boilerplate
|
|
80
|
+
|
|
81
|
+
const userResult = await getUser(orderResult.value.userId);
|
|
82
|
+
if (!userResult.ok) return userResult; // more boilerplate
|
|
83
|
+
|
|
84
|
+
const paymentResult = await charge(orderResult.value.total);
|
|
85
|
+
if (!paymentResult.ok) return paymentResult; // even more
|
|
86
|
+
|
|
87
|
+
return paymentResult;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
10 steps = 10 if-checks. This is what `step()` solves.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## run() — Simple Composition
|
|
96
|
+
|
|
97
|
+
`run()` gives you a `step()` function that unwraps Results automatically:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { run } from "awaitly";
|
|
101
|
+
|
|
102
|
+
const result = await run(async (step) => {
|
|
103
|
+
const order = await step(() => getOrder(orderId)); // unwraps ok, exits on err
|
|
104
|
+
const user = await step(() => getUser(order.userId)); // same
|
|
105
|
+
const payment = await step(() => charge(order.total)); // same
|
|
106
|
+
return payment;
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**The happy path reads linearly.** No if-checks.
|
|
111
|
+
|
|
112
|
+
### Why thunks? `step(() => fn())` not `step(fn())`
|
|
113
|
+
|
|
114
|
+
Always wrap in a function:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
step(() => getUser(id)); // ✅ Correct - step controls when it runs
|
|
118
|
+
step(getUser(id)); // ❌ Wrong - executes immediately
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Thunks enable:
|
|
122
|
+
|
|
123
|
+
- **Caching**: step checks cache before calling
|
|
124
|
+
- **Retries**: step can re-call on failure
|
|
125
|
+
- **Timeouts**: step can abort mid-execution
|
|
126
|
+
|
|
127
|
+
### UnexpectedError — The Safety Net
|
|
128
|
+
|
|
129
|
+
If code throws instead of returning a Result, `run()` catches it:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { UNEXPECTED_ERROR } from "awaitly/workflow";
|
|
133
|
+
|
|
134
|
+
if (!result.ok && result.error.type === UNEXPECTED_ERROR) {
|
|
135
|
+
console.error("Bug or SDK error:", result.error.cause);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- **Expected failures** → your typed errors
|
|
140
|
+
- **Unexpected failures** → `UnexpectedError`
|
|
141
|
+
|
|
142
|
+
TypeScript forces you to handle both.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## createWorkflow — Production API
|
|
147
|
+
|
|
148
|
+
`run()` requires manual error type declaration. `createWorkflow()` infers them automatically:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { createWorkflow } from "awaitly/workflow";
|
|
152
|
+
|
|
153
|
+
const deps = {
|
|
154
|
+
getUser: async (id: string): AsyncResult<User, UserNotFound> => {
|
|
155
|
+
/* ... */
|
|
156
|
+
},
|
|
157
|
+
getOrder: async (id: string): AsyncResult<Order, OrderNotFound> => {
|
|
158
|
+
/* ... */
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const workflow = createWorkflow(deps);
|
|
163
|
+
|
|
164
|
+
const result = await workflow(async (step, deps) => {
|
|
165
|
+
const user = await step(() => deps.getUser(userId));
|
|
166
|
+
const order = await step(() => deps.getOrder(orderId));
|
|
167
|
+
return { user, order };
|
|
168
|
+
});
|
|
169
|
+
// TypeScript KNOWS: result.error is UserNotFound | OrderNotFound | UnexpectedError
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### When to use which?
|
|
173
|
+
|
|
174
|
+
| `run()` | `createWorkflow()` |
|
|
175
|
+
| ------------------------------ | ------------------------------ |
|
|
176
|
+
| Simple one-off composition | Production handlers |
|
|
177
|
+
| Explicit error types | Automatic error inference |
|
|
178
|
+
| Building abstractions | Caching, retries, events |
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## How It Works
|
|
183
|
+
|
|
184
|
+
```mermaid
|
|
185
|
+
flowchart TD
|
|
186
|
+
subgraph "step() unwraps Results, exits early on error"
|
|
187
|
+
S1["step(() => deps.fetchUser(...))"] -->|ok| S2["step(() => deps.fetchPosts(...))"]
|
|
188
|
+
S2 -->|ok| S3["step(() => deps.sendEmail(...))"]
|
|
189
|
+
S3 -->|ok| S4["✓ Success"]
|
|
190
|
+
|
|
191
|
+
S1 -.->|error| EXIT["Return error"]
|
|
192
|
+
S2 -.->|error| EXIT
|
|
193
|
+
S3 -.->|error| EXIT
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
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.ok)` checks needed. The happy path stays clean.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Key Concepts
|
|
202
|
+
|
|
203
|
+
| Concept | What it does |
|
|
204
|
+
| ------------------- | ---------------------------------------------------------------------------------------- |
|
|
205
|
+
| **Result** | `ok(value)` or `err(error)` — typed success/failure, no exceptions |
|
|
206
|
+
| **Workflow** | Wraps your dependencies and tracks their error types automatically |
|
|
207
|
+
| **step()** | Unwraps a Result, short-circuits on failure, enables caching/retries |
|
|
208
|
+
| **step.try** | Catches throws and converts them to typed errors |
|
|
209
|
+
| **step.fromResult** | Preserves rich error objects from other Result-returning code |
|
|
210
|
+
| **Events** | `onEvent` streams everything — timing, retries, failures — for visualization or logging |
|
|
211
|
+
| **Resume** | Save completed steps, pick up later (great for approvals or crashes) |
|
|
212
|
+
| **UnexpectedError** | Safety net for throws outside your declared errors; map it to HTTP 500 at the boundary |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Quickstart
|
|
217
|
+
|
|
218
|
+
Now that you understand the concepts, here's the complete pattern:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { ok, err, type AsyncResult } from "awaitly";
|
|
222
|
+
import { createWorkflow } from "awaitly/workflow";
|
|
223
|
+
|
|
224
|
+
type Task = { id: string };
|
|
225
|
+
type TaskNotFound = { type: "TASK_NOT_FOUND"; id: string };
|
|
226
|
+
|
|
227
|
+
// 1. Define dependencies that return Results
|
|
228
|
+
const deps = {
|
|
229
|
+
loadTask: async (id: string): AsyncResult<Task, TaskNotFound> => {
|
|
230
|
+
if (id === "t-1") return ok({ id });
|
|
231
|
+
return err({ type: "TASK_NOT_FOUND", id });
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// 2. Create and run a workflow
|
|
236
|
+
const workflow = createWorkflow(deps);
|
|
237
|
+
|
|
238
|
+
const result = await workflow(async (step, deps) => {
|
|
239
|
+
return await step(() => deps.loadTask("t-1"));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// 3. Handle the result
|
|
243
|
+
console.log(result.ok ? result.value : result.error);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### What just happened?
|
|
16
247
|
|
|
17
|
-
|
|
248
|
+
- `deps.loadTask` returns a Result (`ok` or `err`)
|
|
249
|
+
- `createWorkflow(deps)` groups dependencies and infers all possible errors
|
|
250
|
+
- `step(() => ...)` runs the operation and unwraps the success value
|
|
251
|
+
- if a step returns `err`, the workflow exits early
|
|
18
252
|
|
|
19
|
-
|
|
20
|
-
- **Built-in reliability** - Retries, timeouts, caching, and circuit breakers when you need them. Not before.
|
|
21
|
-
- **Resume & approvals** - Pause workflows for human review, persist state, pick up where you left off.
|
|
22
|
-
- **Full visibility** - Event streams, ASCII timelines, Mermaid diagrams. See what ran, what failed, and why.
|
|
253
|
+
---
|
|
23
254
|
|
|
24
255
|
## Before & After: See Why This Matters
|
|
25
256
|
|
|
@@ -86,128 +317,106 @@ async function executeTransfer(
|
|
|
86
317
|
**With workflow: typed errors, automatic inference, clean code**
|
|
87
318
|
|
|
88
319
|
```typescript
|
|
89
|
-
import { ok, err, type AsyncResult } from
|
|
90
|
-
import { createWorkflow } from
|
|
320
|
+
import { ok, err, type AsyncResult } from "awaitly";
|
|
321
|
+
import { createWorkflow, UNEXPECTED_ERROR } from "awaitly/workflow";
|
|
91
322
|
|
|
92
|
-
|
|
93
|
-
type UserNotFound = { type:
|
|
94
|
-
type InsufficientFunds = { type:
|
|
95
|
-
type TransferFailed = { type:
|
|
323
|
+
type User = { id: string; balance: number };
|
|
324
|
+
type UserNotFound = { type: "USER_NOT_FOUND"; userId: string };
|
|
325
|
+
type InsufficientFunds = { type: "INSUFFICIENT_FUNDS"; required: number; available: number };
|
|
326
|
+
type TransferFailed = { type: "TRANSFER_FAILED"; reason: string };
|
|
96
327
|
|
|
97
|
-
// Operations return Results - errors are part of the type signature
|
|
98
328
|
const deps = {
|
|
99
|
-
getUser: async (userId: string): AsyncResult<
|
|
100
|
-
if (userId === "unknown") {
|
|
101
|
-
return err({ type: 'USER_NOT_FOUND', userId }); // Typed error with context!
|
|
102
|
-
}
|
|
329
|
+
getUser: async (userId: string): AsyncResult<User, UserNotFound> => {
|
|
330
|
+
if (userId === "unknown") return err({ type: "USER_NOT_FOUND", userId });
|
|
103
331
|
return ok({ id: userId, balance: 1000 });
|
|
104
332
|
},
|
|
105
333
|
|
|
106
|
-
validateBalance: (user:
|
|
334
|
+
validateBalance: (user: User, amount: number): AsyncResult<void, InsufficientFunds> => {
|
|
107
335
|
if (user.balance < amount) {
|
|
108
|
-
return err({
|
|
109
|
-
type: 'INSUFFICIENT_FUNDS',
|
|
110
|
-
required: amount,
|
|
111
|
-
available: user.balance, // Rich error context!
|
|
112
|
-
});
|
|
336
|
+
return err({ type: "INSUFFICIENT_FUNDS", required: amount, available: user.balance });
|
|
113
337
|
}
|
|
114
338
|
return ok(undefined);
|
|
115
339
|
},
|
|
116
340
|
|
|
117
|
-
executeTransfer: async (
|
|
118
|
-
fromUser: { id: string },
|
|
119
|
-
toUser: { id: string },
|
|
120
|
-
amount: number
|
|
121
|
-
): AsyncResult<{ transactionId: string }, TransferFailed> => {
|
|
341
|
+
executeTransfer: async (): AsyncResult<{ transactionId: string }, TransferFailed> => {
|
|
122
342
|
return ok({ transactionId: "tx-12345" });
|
|
123
343
|
},
|
|
124
344
|
};
|
|
125
345
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
346
|
+
const transfer = createWorkflow(deps);
|
|
347
|
+
|
|
348
|
+
// In an HTTP handler
|
|
349
|
+
async function handler(fromUserId: string, toUserId: string, amount: number) {
|
|
350
|
+
const result = await transfer(async (step, deps) => {
|
|
351
|
+
const fromUser = await step(() => deps.getUser(fromUserId));
|
|
352
|
+
const toUser = await step(() => deps.getUser(toUserId));
|
|
353
|
+
await step(() => deps.validateBalance(fromUser, amount));
|
|
354
|
+
return await step(() => deps.executeTransfer());
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// TypeScript knows ALL possible errors - map them to HTTP responses
|
|
358
|
+
if (result.ok) return { statusCode: 200, body: result.value };
|
|
138
359
|
|
|
139
|
-
// Handle the final result with full type safety
|
|
140
|
-
if (result.ok) {
|
|
141
|
-
console.log("Success! Transaction:", result.value.transactionId);
|
|
142
|
-
} else {
|
|
143
|
-
// TypeScript knows EXACTLY which error types are possible!
|
|
144
360
|
switch (result.error.type) {
|
|
145
|
-
case
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
console.log("Error: Transfer failed:", result.error.reason);
|
|
153
|
-
break;
|
|
361
|
+
case "USER_NOT_FOUND":
|
|
362
|
+
return { statusCode: 404, body: { message: "User not found", userId: result.error.userId } };
|
|
363
|
+
case "INSUFFICIENT_FUNDS":
|
|
364
|
+
return { statusCode: 400, body: result.error };
|
|
365
|
+
case "TRANSFER_FAILED":
|
|
366
|
+
case UNEXPECTED_ERROR:
|
|
367
|
+
return { statusCode: 500, body: { message: "Internal error" } };
|
|
154
368
|
}
|
|
155
369
|
}
|
|
156
370
|
```
|
|
157
371
|
|
|
158
|
-
**
|
|
372
|
+
**How it works:** TypeScript knows **all possible errors** from your dependencies. Add a step? The errors update automatically. Remove one? They update. You'll never miss an error case.
|
|
159
373
|
|
|
160
|
-
|
|
374
|
+
---
|
|
161
375
|
|
|
162
|
-
|
|
376
|
+
## Mental model
|
|
163
377
|
|
|
164
|
-
|
|
378
|
+
Think of awaitly like this:
|
|
165
379
|
|
|
166
|
-
|
|
167
|
-
|
|
380
|
+
- Leaf functions return data OR an error (never throw for expected cases)
|
|
381
|
+
- A workflow runs steps in order
|
|
382
|
+
- `step()`:
|
|
383
|
+
- gives you the value on success
|
|
384
|
+
- exits immediately on error
|
|
385
|
+
- The boundary (HTTP, job, CLI) decides how to respond
|
|
168
386
|
|
|
169
|
-
|
|
170
|
-
id ? ok({ id, total: 99.99, email: 'user@example.com' }) : err('ORDER_NOT_FOUND');
|
|
171
|
-
|
|
172
|
-
const chargeCard = async (amount: number): AsyncResult<Payment, 'CARD_DECLINED'> =>
|
|
173
|
-
amount < 10000 ? ok({ id: 'pay_123', amount }) : err('CARD_DECLINED');
|
|
174
|
-
```
|
|
387
|
+
---
|
|
175
388
|
|
|
176
|
-
|
|
389
|
+
## Mapping errors at the boundary
|
|
177
390
|
|
|
178
|
-
|
|
391
|
+
The final result maps cleanly to HTTP responses, job statuses, or CLI exit codes:
|
|
179
392
|
|
|
180
393
|
```typescript
|
|
181
|
-
import {
|
|
394
|
+
import { UNEXPECTED_ERROR } from "awaitly/workflow";
|
|
182
395
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const payment = await step(chargeCard(order.total));
|
|
188
|
-
return { order, payment };
|
|
189
|
-
});
|
|
190
|
-
// result.error is: 'ORDER_NOT_FOUND' | 'CARD_DECLINED' | UnexpectedError
|
|
191
|
-
```
|
|
396
|
+
// In an HTTP handler
|
|
397
|
+
if (result.ok) {
|
|
398
|
+
return { statusCode: 200, body: result.value };
|
|
399
|
+
}
|
|
192
400
|
|
|
193
|
-
|
|
401
|
+
switch (result.error.type) {
|
|
402
|
+
case "TASK_NOT_FOUND":
|
|
403
|
+
return { statusCode: 404, body: { message: "Task not found" } };
|
|
194
404
|
|
|
195
|
-
|
|
405
|
+
case UNEXPECTED_ERROR:
|
|
406
|
+
// Log the cause for debugging (it's the original thrown error)
|
|
407
|
+
console.error("Unexpected error:", result.error.cause);
|
|
408
|
+
return { statusCode: 500, body: { message: "Internal error" } };
|
|
196
409
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
S1["step(fetchUser)"] -->|ok| S2["step(fetchPosts)"]
|
|
201
|
-
S2 -->|ok| S3["step(sendEmail)"]
|
|
202
|
-
S3 -->|ok| S4["✓ Success"]
|
|
203
|
-
|
|
204
|
-
S1 -.->|error| EXIT["Return error"]
|
|
205
|
-
S2 -.->|error| EXIT
|
|
206
|
-
S3 -.->|error| EXIT
|
|
207
|
-
end
|
|
410
|
+
default:
|
|
411
|
+
return { statusCode: 500, body: { message: "Internal error" } };
|
|
412
|
+
}
|
|
208
413
|
```
|
|
209
414
|
|
|
210
|
-
|
|
415
|
+
**Why `UnexpectedError`?**
|
|
416
|
+
- Expected failures → your typed errors (e.g., `TASK_NOT_FOUND`)
|
|
417
|
+
- Unexpected failures (bugs, SDK throws) → `UnexpectedError`
|
|
418
|
+
|
|
419
|
+
TypeScript will force you to handle both. This is intentional.
|
|
211
420
|
|
|
212
421
|
---
|
|
213
422
|
|
|
@@ -218,13 +427,13 @@ Each `step()` unwraps a `Result`. If it's `ok`, you get the value and continue.
|
|
|
218
427
|
Add resilience exactly where you need it - no nested try/catch or custom retry loops.
|
|
219
428
|
|
|
220
429
|
```typescript
|
|
221
|
-
const result = await workflow(async (step) => {
|
|
430
|
+
const result = await workflow(async (step, deps) => {
|
|
222
431
|
// Retry 3 times with exponential backoff, timeout after 5 seconds
|
|
223
|
-
const
|
|
224
|
-
() =>
|
|
225
|
-
{ attempts: 3, backoff:
|
|
432
|
+
const task = await step.retry(
|
|
433
|
+
() => deps.loadTask("t-1"),
|
|
434
|
+
{ attempts: 3, backoff: "exponential", timeout: { ms: 5000 } }
|
|
226
435
|
);
|
|
227
|
-
return
|
|
436
|
+
return task;
|
|
228
437
|
});
|
|
229
438
|
```
|
|
230
439
|
|
|
@@ -264,10 +473,10 @@ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
|
264
473
|
onEvent: collector.handleEvent, // Automatically collects step_complete events
|
|
265
474
|
});
|
|
266
475
|
|
|
267
|
-
await workflow(async (step) => {
|
|
476
|
+
await workflow(async (step, deps) => {
|
|
268
477
|
// Only steps with keys are saved
|
|
269
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" });
|
|
270
|
-
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
|
|
478
|
+
const user = await step(() => deps.fetchUser("1"), { key: "user:1" });
|
|
479
|
+
const posts = await step(() => deps.fetchPosts(user.id), { key: `posts:${user.id}` });
|
|
271
480
|
return { user, posts };
|
|
272
481
|
});
|
|
273
482
|
|
|
@@ -305,9 +514,9 @@ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
|
305
514
|
resumeState: savedState, // Pre-populates cache from saved state
|
|
306
515
|
});
|
|
307
516
|
|
|
308
|
-
await workflow(async (step) => {
|
|
309
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
|
|
310
|
-
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
|
|
517
|
+
await workflow(async (step, deps) => {
|
|
518
|
+
const user = await step(() => deps.fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
|
|
519
|
+
const posts = await step(() => deps.fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
|
|
311
520
|
return { user, posts };
|
|
312
521
|
});
|
|
313
522
|
```
|
|
@@ -359,13 +568,13 @@ const requireApproval = createApprovalStep({
|
|
|
359
568
|
},
|
|
360
569
|
});
|
|
361
570
|
|
|
362
|
-
const result = await refundWorkflow(async (step) => {
|
|
363
|
-
const refund = await step(calculateRefund(orderId));
|
|
571
|
+
const result = await refundWorkflow(async (step, deps) => {
|
|
572
|
+
const refund = await step(() => deps.calculateRefund(orderId));
|
|
364
573
|
|
|
365
574
|
// Workflow pauses here until someone approves
|
|
366
|
-
const approval = await step(requireApproval, { key:
|
|
575
|
+
const approval = await step(() => requireApproval(), { key: "approve:refund" });
|
|
367
576
|
|
|
368
|
-
return await step(processRefund(refund, approval));
|
|
577
|
+
return await step(() => deps.processRefund(refund, approval));
|
|
369
578
|
});
|
|
370
579
|
|
|
371
580
|
if (!result.ok && isPendingApproval(result.error)) {
|
|
@@ -386,9 +595,9 @@ const workflow = createWorkflow({ fetchOrder, chargeCard }, {
|
|
|
386
595
|
onEvent: viz.handleEvent,
|
|
387
596
|
});
|
|
388
597
|
|
|
389
|
-
await workflow(async (step) => {
|
|
390
|
-
const order = await step(() => fetchOrder('order_456'), { name: 'Fetch order' });
|
|
391
|
-
const payment = await step(() => chargeCard(order.total), { name: 'Charge card' });
|
|
598
|
+
await workflow(async (step, deps) => {
|
|
599
|
+
const order = await step(() => deps.fetchOrder('order_456'), { name: 'Fetch order' });
|
|
600
|
+
const payment = await step(() => deps.chargeCard(order.total), { name: 'Charge card' });
|
|
392
601
|
return { order, payment };
|
|
393
602
|
});
|
|
394
603
|
|
|
@@ -397,815 +606,112 @@ console.log(viz.renderAs('mermaid'));
|
|
|
397
606
|
|
|
398
607
|
---
|
|
399
608
|
|
|
400
|
-
##
|
|
401
|
-
|
|
402
|
-
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.
|
|
403
|
-
|
|
404
|
-
### Step 1 - Install
|
|
405
|
-
|
|
406
|
-
```bash
|
|
407
|
-
npm install awaitly
|
|
408
|
-
# or
|
|
409
|
-
pnpm add awaitly
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
### Step 2 - Describe Async Dependencies
|
|
413
|
-
|
|
414
|
-
Define the units of work as `AsyncResult<T, E>` helpers. Results encode success (`ok`) or typed failure (`err`).
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
import { ok, err, type AsyncResult } from 'awaitly';
|
|
418
|
-
|
|
419
|
-
type User = { id: string; name: string };
|
|
420
|
-
|
|
421
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
422
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### Step 3 - Compose a Workflow
|
|
426
|
-
|
|
427
|
-
`createWorkflow` collects dependencies once so the library can infer the total error union.
|
|
428
|
-
|
|
429
|
-
```typescript
|
|
430
|
-
import { createWorkflow } from 'awaitly/workflow';
|
|
431
|
-
|
|
432
|
-
const workflow = createWorkflow({ fetchUser });
|
|
433
|
-
```
|
|
609
|
+
## What's next?
|
|
434
610
|
|
|
435
|
-
|
|
611
|
+
You have the foundation. Pick one:
|
|
436
612
|
|
|
437
|
-
|
|
613
|
+
- **If you need retries/timeouts:** [Reliability guide](https://jagreehal.github.io/awaitly/advanced/policies/)
|
|
614
|
+
- **If you need crash recovery:** [Persistence guide](https://jagreehal.github.io/awaitly/guides/persistence/)
|
|
615
|
+
- **If you want observability:** [Visualization guide](https://jagreehal.github.io/awaitly/guides/visualization/)
|
|
438
616
|
|
|
439
|
-
|
|
440
|
-
const result = await workflow(async (step) => {
|
|
441
|
-
const user = await step(fetchUser('1'));
|
|
442
|
-
return user;
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
if (result.ok) {
|
|
446
|
-
console.log(result.value.name);
|
|
447
|
-
} else {
|
|
448
|
-
console.error(result.error); // 'NOT_FOUND' | UnexpectedError
|
|
449
|
-
}
|
|
450
|
-
```
|
|
617
|
+
## Advanced Features (when you need them)
|
|
451
618
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const data = await workflow(async (step) => {
|
|
458
|
-
const user = await step(fetchUser('1'));
|
|
459
|
-
|
|
460
|
-
const posts = await step.try(
|
|
461
|
-
() => fetch(`/api/users/${user.id}/posts`).then((r) => {
|
|
462
|
-
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
463
|
-
return r.json();
|
|
464
|
-
}),
|
|
465
|
-
{ error: 'FETCH_FAILED' as const }
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
return { user, posts };
|
|
469
|
-
});
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
That's the foundation. Now let's build on it.
|
|
619
|
+
- **Retries / timeouts / backoff** → [Reliability guide](https://jagreehal.github.io/awaitly/advanced/policies/)
|
|
620
|
+
- **Step caching with keys** → [Caching guide](https://jagreehal.github.io/awaitly/guides/caching/)
|
|
621
|
+
- **Save & resume** → [Persistence guide](https://jagreehal.github.io/awaitly/guides/persistence/)
|
|
622
|
+
- **Human-in-the-loop approvals** → [HITL guide](https://jagreehal.github.io/awaitly/guides/human-in-loop/)
|
|
623
|
+
- **Visualization via `onEvent`** → [Visualization guide](https://jagreehal.github.io/awaitly/guides/visualization/)
|
|
473
624
|
|
|
474
625
|
---
|
|
475
626
|
|
|
476
|
-
##
|
|
477
|
-
|
|
478
|
-
Save workflow state to a database and resume later. Perfect for crash recovery, long-running workflows, or pausing for approvals.
|
|
479
|
-
|
|
480
|
-
### Basic Save & Resume
|
|
627
|
+
## Common Patterns (quick reference)
|
|
481
628
|
|
|
482
629
|
```typescript
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
// 1. Collect state during execution
|
|
487
|
-
const collector = createResumeStateCollector();
|
|
488
|
-
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
489
|
-
onEvent: collector.handleEvent,
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
await workflow(async (step) => {
|
|
493
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" });
|
|
494
|
-
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
|
|
495
|
-
return { user, posts };
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
// 2. Save to database
|
|
499
|
-
const state = collector.getResumeState();
|
|
500
|
-
const json = stringifyState(state, { workflowId: "123" });
|
|
501
|
-
await db.workflowStates.create({ id: "123", state: json });
|
|
630
|
+
// Wrap throwing code
|
|
631
|
+
const data = await step.try(() => fetch(url).then(r => r.json()), { error: "HTTP_FAILED" as const });
|
|
502
632
|
|
|
503
|
-
//
|
|
504
|
-
const
|
|
505
|
-
const savedState = parseState(saved.state);
|
|
633
|
+
// Retries with backoff
|
|
634
|
+
const user = await step.retry(() => deps.fetchUser(id), { attempts: 3, backoff: "exponential" });
|
|
506
635
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
});
|
|
636
|
+
// Timeout protection
|
|
637
|
+
const result = await step.withTimeout(() => deps.slowOperation(), { ms: 5000 });
|
|
510
638
|
|
|
511
|
-
//
|
|
512
|
-
await
|
|
513
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
|
|
514
|
-
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
|
|
515
|
-
return { user, posts };
|
|
516
|
-
});
|
|
639
|
+
// Caching (use thunk + key)
|
|
640
|
+
const user = await step(() => deps.fetchUser(id), { key: `user:${id}` });
|
|
517
641
|
```
|
|
518
642
|
|
|
519
|
-
### With Database Adapter (Redis, DynamoDB, etc.)
|
|
520
|
-
|
|
521
|
-
```typescript
|
|
522
|
-
import { createStatePersistence } from 'awaitly/persistence';
|
|
523
|
-
import { createClient } from 'redis';
|
|
524
|
-
|
|
525
|
-
const redis = createClient();
|
|
526
|
-
await redis.connect();
|
|
527
|
-
|
|
528
|
-
// Create persistence adapter
|
|
529
|
-
const persistence = createStatePersistence({
|
|
530
|
-
get: (key) => redis.get(key),
|
|
531
|
-
set: (key, value) => redis.set(key, value),
|
|
532
|
-
delete: (key) => redis.del(key).then(n => n > 0),
|
|
533
|
-
exists: (key) => redis.exists(key).then(n => n > 0),
|
|
534
|
-
keys: (pattern) => redis.keys(pattern),
|
|
535
|
-
}, 'workflow:state:');
|
|
536
|
-
|
|
537
|
-
// Save
|
|
538
|
-
const collector = createResumeStateCollector();
|
|
539
|
-
const workflow = createWorkflow(deps, { onEvent: collector.handleEvent });
|
|
540
|
-
await workflow(async (step) => { /* ... */ });
|
|
541
|
-
|
|
542
|
-
await persistence.save('run-123', collector.getResumeState(), { userId: 'user-1' });
|
|
543
|
-
|
|
544
|
-
// Load and resume
|
|
545
|
-
const savedState = await persistence.load('run-123');
|
|
546
|
-
const resumed = createWorkflow(deps, { resumeState: savedState });
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
**See the [Save & Resume](#-save--resume-persist-workflows-across-restarts) section for more details.**
|
|
550
|
-
|
|
551
643
|
---
|
|
552
644
|
|
|
553
|
-
##
|
|
554
|
-
|
|
555
|
-
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.
|
|
556
|
-
|
|
557
|
-
### Stage 1 - Hello Workflow
|
|
558
|
-
|
|
559
|
-
1. Declare dependencies (`fetchUser`, `fetchPosts`).
|
|
560
|
-
2. Create the workflow: `const loadUserData = createWorkflow({ fetchUser, fetchPosts })`.
|
|
561
|
-
3. Use `step()` to fan out and gather results.
|
|
562
|
-
|
|
563
|
-
```typescript
|
|
564
|
-
const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
|
|
565
|
-
ok([{ id: 1, title: 'Hello World' }]);
|
|
566
|
-
|
|
567
|
-
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
|
|
568
|
-
id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
|
|
569
|
-
|
|
570
|
-
const loadUserData = createWorkflow({ fetchUser, fetchPosts });
|
|
571
|
-
|
|
572
|
-
const result = await loadUserData(async (step) => {
|
|
573
|
-
const user = await step(fetchUser('1'));
|
|
574
|
-
const posts = await step(fetchPosts(user.id));
|
|
575
|
-
return { user, posts };
|
|
576
|
-
});
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
### Stage 2 - Validation & Branching
|
|
580
|
-
|
|
581
|
-
Add validation helpers and watch the error union update automatically.
|
|
582
|
-
|
|
583
|
-
```typescript
|
|
584
|
-
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
|
|
585
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
586
|
-
|
|
587
|
-
const signUp = createWorkflow({ validateEmail, fetchUser });
|
|
588
|
-
|
|
589
|
-
const result = await signUp(async (step) => {
|
|
590
|
-
const email = await step(validateEmail('user@example.com'));
|
|
591
|
-
const user = await step(fetchUser(email));
|
|
592
|
-
return { email, user };
|
|
593
|
-
});
|
|
594
|
-
```
|
|
595
|
-
|
|
596
|
-
### Stage 3 - Reliability Features
|
|
597
|
-
|
|
598
|
-
Layer in retries, caching, and timeouts only around the calls that need them.
|
|
599
|
-
|
|
600
|
-
```typescript
|
|
601
|
-
const resilientWorkflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
602
|
-
cache: new Map(),
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
const result = await resilientWorkflow(async (step) => {
|
|
606
|
-
const user = await step.retry(
|
|
607
|
-
() => fetchUser('1'),
|
|
608
|
-
{
|
|
609
|
-
attempts: 3,
|
|
610
|
-
backoff: 'exponential',
|
|
611
|
-
name: 'Fetch user',
|
|
612
|
-
key: 'user:1'
|
|
613
|
-
}
|
|
614
|
-
);
|
|
615
|
-
|
|
616
|
-
const posts = await step.withTimeout(
|
|
617
|
-
() => fetchPosts(user.id),
|
|
618
|
-
{ ms: 5000, name: 'Fetch posts' }
|
|
619
|
-
);
|
|
620
|
-
|
|
621
|
-
return { user, posts };
|
|
622
|
-
});
|
|
623
|
-
```
|
|
624
|
-
|
|
625
|
-
### Stage 4 - Human-in-the-Loop & Resume
|
|
626
|
-
|
|
627
|
-
Pause long-running workflows until an operator approves, then resume using persisted step results.
|
|
628
|
-
|
|
629
|
-
```typescript
|
|
630
|
-
import { createWorkflow, createResumeStateCollector } from 'awaitly/workflow';
|
|
631
|
-
import {
|
|
632
|
-
createApprovalStep,
|
|
633
|
-
injectApproval,
|
|
634
|
-
isPendingApproval,
|
|
635
|
-
} from 'awaitly/hitl';
|
|
636
|
-
|
|
637
|
-
// Use collector to automatically capture state
|
|
638
|
-
const collector = createResumeStateCollector();
|
|
639
|
-
const requireApproval = createApprovalStep({
|
|
640
|
-
key: 'approval:deploy',
|
|
641
|
-
checkApproval: async () => ({ status: 'pending' }),
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
const gatedWorkflow = createWorkflow({ requireApproval }, {
|
|
645
|
-
onEvent: collector.handleEvent, // Automatically collects step results
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const result = await gatedWorkflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
|
|
649
|
-
|
|
650
|
-
if (!result.ok && isPendingApproval(result.error)) {
|
|
651
|
-
// Get collected state
|
|
652
|
-
const state = collector.getResumeState();
|
|
653
|
-
|
|
654
|
-
// Later, when approval is granted, inject it and resume
|
|
655
|
-
const updatedState = injectApproval(state, {
|
|
656
|
-
stepKey: 'approval:deploy',
|
|
657
|
-
value: { approvedBy: 'ops' },
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
// Resume with approval injected
|
|
661
|
-
const resumed = createWorkflow({ requireApproval }, { resumeState: updatedState });
|
|
662
|
-
await resumed(async (step) => step(requireApproval, { key: 'approval:deploy' })); // Uses injected approval
|
|
663
|
-
}
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
## Try It Yourself
|
|
667
|
-
|
|
668
|
-
- Open the [TypeScript Playground](https://www.typescriptlang.org/play) and paste any snippet from the tutorial.
|
|
669
|
-
- Prefer running locally? Save a file, run `npx tsx workflow-demo.ts`, and iterate with real dependencies.
|
|
670
|
-
- For interactive debugging, add `console.log` inside `onEvent` callbacks to visualize timing immediately.
|
|
671
|
-
|
|
672
|
-
## Key Concepts
|
|
673
|
-
|
|
674
|
-
| Concept | What it does |
|
|
675
|
-
|---------|--------------|
|
|
676
|
-
| **Result** | `ok(value)` or `err(error)` - typed success/failure, no exceptions |
|
|
677
|
-
| **Workflow** | Wraps your dependencies and tracks their error types automatically |
|
|
678
|
-
| **step()** | Unwraps a Result, short-circuits on failure, enables caching/retries |
|
|
679
|
-
| **step.try** | Catches throws and converts them to typed errors |
|
|
680
|
-
| **step.fromResult** | Preserves rich error objects from other Result-returning code |
|
|
681
|
-
| **Events** | `onEvent` streams everything - timing, retries, failures - for visualization or logging |
|
|
682
|
-
| **Resume** | Save completed steps, pick up later (great for approvals or crashes) |
|
|
683
|
-
| **UnexpectedError** | Safety net for throws outside your declared union; use `strict` mode to force explicit handling |
|
|
684
|
-
|
|
685
|
-
## Recipes & Patterns
|
|
686
|
-
|
|
687
|
-
### Core Recipes
|
|
688
|
-
|
|
689
|
-
#### Basic Workflow
|
|
690
|
-
|
|
691
|
-
```typescript
|
|
692
|
-
const result = await loadUserData(async (step) => {
|
|
693
|
-
const user = await step(fetchUser('1'));
|
|
694
|
-
const posts = await step(fetchPosts(user.id));
|
|
695
|
-
return { user, posts };
|
|
696
|
-
});
|
|
697
|
-
```
|
|
698
|
-
|
|
699
|
-
#### User Signup
|
|
700
|
-
|
|
701
|
-
```typescript
|
|
702
|
-
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
|
|
703
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
704
|
-
|
|
705
|
-
const checkDuplicate = async (email: string): AsyncResult<void, 'EMAIL_EXISTS'> =>
|
|
706
|
-
email === 'taken@example.com' ? err('EMAIL_EXISTS') : ok(undefined);
|
|
707
|
-
|
|
708
|
-
const createAccount = async (email: string): AsyncResult<{ id: string }, 'DB_ERROR'> =>
|
|
709
|
-
ok({ id: crypto.randomUUID() });
|
|
710
|
-
|
|
711
|
-
const sendWelcome = async (userId: string): AsyncResult<void, 'EMAIL_FAILED'> => ok(undefined);
|
|
712
|
-
|
|
713
|
-
const signUp = createWorkflow({ validateEmail, checkDuplicate, createAccount, sendWelcome });
|
|
714
|
-
|
|
715
|
-
const result = await signUp(async (step) => {
|
|
716
|
-
const email = await step(validateEmail('user@example.com'));
|
|
717
|
-
await step(checkDuplicate(email));
|
|
718
|
-
const account = await step(createAccount(email));
|
|
719
|
-
await step(sendWelcome(account.id));
|
|
720
|
-
return account;
|
|
721
|
-
});
|
|
722
|
-
// result.error: 'INVALID_EMAIL' | 'EMAIL_EXISTS' | 'DB_ERROR' | 'EMAIL_FAILED' | UnexpectedError
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
#### Checkout Flow
|
|
726
|
-
|
|
727
|
-
```typescript
|
|
728
|
-
const authenticate = async (token: string): AsyncResult<{ userId: string }, 'UNAUTHORIZED'> =>
|
|
729
|
-
token === 'valid' ? ok({ userId: 'user-1' }) : err('UNAUTHORIZED');
|
|
730
|
-
|
|
731
|
-
const fetchOrder = async (id: string): AsyncResult<{ total: number }, 'ORDER_NOT_FOUND'> =>
|
|
732
|
-
ok({ total: 99 });
|
|
733
|
-
|
|
734
|
-
const chargeCard = async (amount: number): AsyncResult<{ txId: string }, 'PAYMENT_FAILED'> =>
|
|
735
|
-
ok({ txId: 'tx-123' });
|
|
736
|
-
|
|
737
|
-
const checkout = createWorkflow({ authenticate, fetchOrder, chargeCard });
|
|
738
|
-
|
|
739
|
-
const result = await checkout(async (step) => {
|
|
740
|
-
const auth = await step(authenticate(token));
|
|
741
|
-
const order = await step(fetchOrder(orderId));
|
|
742
|
-
const payment = await step(chargeCard(order.total));
|
|
743
|
-
return { userId: auth.userId, txId: payment.txId };
|
|
744
|
-
});
|
|
745
|
-
// result.error: 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
#### Composing Workflows
|
|
749
|
-
|
|
750
|
-
You can combine multiple workflows together. The error types automatically aggregate:
|
|
751
|
-
|
|
752
|
-
```typescript
|
|
753
|
-
// Validation workflow
|
|
754
|
-
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> =>
|
|
755
|
-
email.includes('@') ? ok(email) : err('INVALID_EMAIL');
|
|
756
|
-
|
|
757
|
-
const validatePassword = async (pwd: string): AsyncResult<string, 'WEAK_PASSWORD'> =>
|
|
758
|
-
pwd.length >= 8 ? ok(pwd) : err('WEAK_PASSWORD');
|
|
759
|
-
|
|
760
|
-
const validationWorkflow = createWorkflow({ validateEmail, validatePassword });
|
|
761
|
-
|
|
762
|
-
// Checkout workflow
|
|
763
|
-
const checkoutWorkflow = createWorkflow({ authenticate, fetchOrder, chargeCard });
|
|
764
|
-
|
|
765
|
-
// Composed workflow: validation + checkout
|
|
766
|
-
// Include all dependencies from both workflows
|
|
767
|
-
const validateAndCheckout = createWorkflow({
|
|
768
|
-
validateEmail,
|
|
769
|
-
validatePassword,
|
|
770
|
-
authenticate,
|
|
771
|
-
fetchOrder,
|
|
772
|
-
chargeCard,
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
const result = await validateAndCheckout(async (step) => {
|
|
776
|
-
// Validation steps
|
|
777
|
-
const email = await step(validateEmail('user@example.com'));
|
|
778
|
-
const password = await step(validatePassword('secret123'));
|
|
779
|
-
|
|
780
|
-
// Checkout steps
|
|
781
|
-
const auth = await step(authenticate('valid'));
|
|
782
|
-
const order = await step(fetchOrder('order-1'));
|
|
783
|
-
const payment = await step(chargeCard(order.total));
|
|
784
|
-
|
|
785
|
-
return { email, password, userId: auth.userId, txId: payment.txId };
|
|
786
|
-
});
|
|
787
|
-
// result.error: 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'UNAUTHORIZED' | 'ORDER_NOT_FOUND' | 'PAYMENT_FAILED' | UnexpectedError
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
### Common Patterns
|
|
791
|
-
|
|
792
|
-
- **Validation & gating** - Run early workflows so later steps never execute for invalid data.
|
|
793
|
-
|
|
794
|
-
```typescript
|
|
795
|
-
const validated = await step(() => validationWorkflow(async (inner) => inner(deps.validateEmail(email))));
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
- **API calls with typed errors** - Wrap fetch/axios via `step.try` and switch on the union later.
|
|
799
|
-
|
|
800
|
-
```typescript
|
|
801
|
-
const payload = await step.try(() => fetch(url).then((r) => r.json()), { error: 'HTTP_FAILED' });
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
- **Wrapping Result-returning functions** - Use `step.fromResult` to preserve rich error types.
|
|
805
|
-
|
|
806
|
-
```typescript
|
|
807
|
-
const response = await step.fromResult(
|
|
808
|
-
() => callProvider(input),
|
|
809
|
-
{
|
|
810
|
-
onError: (e) => ({
|
|
811
|
-
type: 'PROVIDER_FAILED' as const,
|
|
812
|
-
provider: e.provider, // TypeScript knows e is ProviderError
|
|
813
|
-
code: e.code,
|
|
814
|
-
})
|
|
815
|
-
}
|
|
816
|
-
);
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
- **Retries, backoff, and timeouts** - Built into `step.retry()` and `step.withTimeout()`.
|
|
820
|
-
|
|
821
|
-
```typescript
|
|
822
|
-
const data = await step.retry(
|
|
823
|
-
() => step.withTimeout(() => fetchData(), { ms: 2000 }),
|
|
824
|
-
{ attempts: 3, backoff: 'exponential', retryOn: (error) => error !== 'FATAL' }
|
|
825
|
-
);
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
- **State save & resume** - Persist step completions and resume later.
|
|
829
|
-
|
|
830
|
-
```typescript
|
|
831
|
-
import { createWorkflow, createResumeStateCollector } from 'awaitly/workflow';
|
|
832
|
-
|
|
833
|
-
// Collect state during execution
|
|
834
|
-
const collector = createResumeStateCollector();
|
|
835
|
-
const workflow = createWorkflow(deps, {
|
|
836
|
-
onEvent: collector.handleEvent, // Automatically collects step_complete events
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
await workflow(async (step) => {
|
|
840
|
-
const user = await step(() => fetchUser("1"), { key: "user:1" });
|
|
841
|
-
return user;
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
// Get collected state
|
|
845
|
-
const state = collector.getResumeState();
|
|
846
|
-
|
|
847
|
-
// Resume later
|
|
848
|
-
const resumed = createWorkflow(deps, { resumeState: state });
|
|
849
|
-
```
|
|
850
|
-
|
|
851
|
-
- **Human-in-the-loop approvals** - Pause a workflow until someone approves.
|
|
852
|
-
|
|
853
|
-
```typescript
|
|
854
|
-
import { createApprovalStep, isPendingApproval, injectApproval } from 'awaitly/hitl';
|
|
855
|
-
|
|
856
|
-
const requireApproval = createApprovalStep({ key: 'approval:deploy', checkApproval: async () => {/* ... */} });
|
|
857
|
-
const result = await workflow(async (step) => step(requireApproval, { key: 'approval:deploy' }));
|
|
858
|
-
|
|
859
|
-
if (!result.ok && isPendingApproval(result.error)) {
|
|
860
|
-
// notify operators, later call injectApproval(savedState, { stepKey, value })
|
|
861
|
-
}
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
- **Caching & deduplication** - Give steps names + keys.
|
|
865
|
-
|
|
866
|
-
```typescript
|
|
867
|
-
const user = await step(() => fetchUser(id), { name: 'Fetch user', key: `user:${id}` });
|
|
868
|
-
```
|
|
869
|
-
|
|
870
|
-
- **Branching logic** - It's just JavaScript - use normal `if`/`switch`.
|
|
871
|
-
|
|
872
|
-
```typescript
|
|
873
|
-
const user = await step(fetchUser(id));
|
|
874
|
-
|
|
875
|
-
if (user.role === 'admin') {
|
|
876
|
-
return await step(fetchAdminDashboard(user.id));
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
if (user.subscription === 'free') {
|
|
880
|
-
return await step(fetchFreeTierData(user.id));
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
return await step(fetchPremiumData(user.id));
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
- **Parallel operations** - Use helpers when you truly need concurrency.
|
|
887
|
-
|
|
888
|
-
```typescript
|
|
889
|
-
import { allAsync, partition, map } from 'awaitly';
|
|
890
|
-
|
|
891
|
-
const result = await allAsync([
|
|
892
|
-
fetchUser('1'),
|
|
893
|
-
fetchPosts('1'),
|
|
894
|
-
]);
|
|
895
|
-
const data = map(result, ([user, posts]) => ({ user, posts }));
|
|
896
|
-
```
|
|
645
|
+
## When to use awaitly
|
|
897
646
|
|
|
898
|
-
|
|
647
|
+
**Use it when:**
|
|
648
|
+
- You want Result types with async/await (not method chains)
|
|
649
|
+
- You need automatic error inference from dependencies
|
|
650
|
+
- You're building workflows that benefit from caching, retries, or resume
|
|
899
651
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
Step keys + persistence solve this. Save state to a database, and if the workflow crashes, resume from the last successful step:
|
|
903
|
-
|
|
904
|
-
```typescript
|
|
905
|
-
import { createWorkflow, createResumeStateCollector } from 'awaitly/workflow';
|
|
906
|
-
import { stringifyState, parseState } from 'awaitly/persistence';
|
|
907
|
-
|
|
908
|
-
const processPayment = createWorkflow({ validateCard, chargeProvider, persistResult });
|
|
909
|
-
|
|
910
|
-
// Collect state for persistence
|
|
911
|
-
const collector = createResumeStateCollector();
|
|
912
|
-
const workflow = createWorkflow(
|
|
913
|
-
{ validateCard, chargeProvider, persistResult },
|
|
914
|
-
{ onEvent: collector.handleEvent }
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
const result = await workflow(async (step) => {
|
|
918
|
-
const card = await step(() => validateCard(input), { key: 'validate' });
|
|
919
|
-
|
|
920
|
-
// This is the dangerous step. Once it succeeds, never repeat it:
|
|
921
|
-
const charge = await step(() => chargeProvider(card), {
|
|
922
|
-
key: `charge:${input.idempotencyKey}`,
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
// If THIS fails (DB down), save state and rerun later.
|
|
926
|
-
// The charge step is cached - it won't execute again.
|
|
927
|
-
await step(() => persistResult(charge), { key: `persist:${charge.id}` });
|
|
928
|
-
|
|
929
|
-
return { paymentId: charge.id };
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
// Save state after each run (or on crash)
|
|
933
|
-
if (result.ok) {
|
|
934
|
-
const state = collector.getResumeState();
|
|
935
|
-
const json = stringifyState(state, { orderId: input.orderId });
|
|
936
|
-
await db.workflowStates.upsert({
|
|
937
|
-
where: { idempotencyKey: input.idempotencyKey },
|
|
938
|
-
update: { state: json, updatedAt: new Date() },
|
|
939
|
-
create: { idempotencyKey: input.idempotencyKey, state: json },
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
**Crash recovery:** If the workflow crashes after charging but before persisting:
|
|
945
|
-
|
|
946
|
-
```typescript
|
|
947
|
-
// On restart, load saved state
|
|
948
|
-
const saved = await db.workflowStates.findUnique({
|
|
949
|
-
where: { idempotencyKey: input.idempotencyKey },
|
|
950
|
-
});
|
|
951
|
-
|
|
952
|
-
if (saved) {
|
|
953
|
-
const savedState = parseState(saved.state);
|
|
954
|
-
const workflow = createWorkflow(
|
|
955
|
-
{ validateCard, chargeProvider, persistResult },
|
|
956
|
-
{ resumeState: savedState }
|
|
957
|
-
);
|
|
958
|
-
|
|
959
|
-
// Resume - charge step uses cached result, no double-billing!
|
|
960
|
-
const result = await workflow(async (step) => {
|
|
961
|
-
const card = await step(() => validateCard(input), { key: 'validate' }); // Cache hit
|
|
962
|
-
const charge = await step(() => chargeProvider(card), {
|
|
963
|
-
key: `charge:${input.idempotencyKey}`,
|
|
964
|
-
}); // Cache hit - returns previous charge result
|
|
965
|
-
await step(() => persistResult(charge), { key: `persist:${charge.id}` }); // Executes fresh
|
|
966
|
-
return { paymentId: charge.id };
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
```
|
|
970
|
-
|
|
971
|
-
Crash after charging but before persisting? Resume the workflow. The charge step returns its cached result. No double-billing.
|
|
972
|
-
|
|
973
|
-
## Is This Library Right for You?
|
|
974
|
-
|
|
975
|
-
```mermaid
|
|
976
|
-
flowchart TD
|
|
977
|
-
Start([Need typed errors?]) --> Simple{Simple use case?}
|
|
978
|
-
|
|
979
|
-
Simple -->|Yes| TryCatch["try/catch is fine"]
|
|
980
|
-
Simple -->|No| WantAsync{Want async/await syntax?}
|
|
981
|
-
|
|
982
|
-
WantAsync -->|Yes| NeedOrchestration{Need retries/caching/resume?}
|
|
983
|
-
WantAsync -->|No| Neverthrow["Consider neverthrow"]
|
|
984
|
-
|
|
985
|
-
NeedOrchestration -->|Yes| Workflow["✓ awaitly"]
|
|
986
|
-
NeedOrchestration -->|No| Either["Either works - awaitly adds room to grow"]
|
|
987
|
-
|
|
988
|
-
style Workflow fill:#E8F5E9
|
|
989
|
-
```
|
|
990
|
-
|
|
991
|
-
**Choose this library when:**
|
|
992
|
-
|
|
993
|
-
- You want Result types with familiar async/await syntax
|
|
994
|
-
- You need automatic error type inference
|
|
995
|
-
- You're building workflows that benefit from step caching or resume
|
|
996
|
-
- You want type-safe error handling without Effect's learning curve
|
|
997
|
-
|
|
998
|
-
## How It Compares
|
|
999
|
-
|
|
1000
|
-
**`try/catch` everywhere** - You lose error types. Every catch block sees `unknown`. Retries? Manual. Timeouts? Manual. Observability? Hope you remembered to add logging.
|
|
1001
|
-
|
|
1002
|
-
**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.
|
|
1003
|
-
|
|
1004
|
-
**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.
|
|
652
|
+
**Skip it when:**
|
|
653
|
+
- You prefer functional chaining (consider neverthrow)
|
|
1005
654
|
|
|
1006
655
|
### vs neverthrow
|
|
1007
656
|
|
|
1008
|
-
|
|
|
1009
|
-
|
|
1010
|
-
|
|
|
1011
|
-
|
|
|
1012
|
-
|
|
|
1013
|
-
| **Wrapping throws** | `ResultAsync.fromPromise(p, mapErr)` | `step.try(fn, { error })` or wrap in AsyncResult |
|
|
1014
|
-
| **Parallel ops** | `ResultAsync.combine([...])` | `allAsync([...])` |
|
|
1015
|
-
| **Retries** | DIY with recursive `.orElse()` | Built-in `step.retry({ attempts, backoff })` |
|
|
1016
|
-
| **Timeouts** | DIY with `Promise.race()` | Built-in `step.withTimeout({ ms })` |
|
|
1017
|
-
| **Caching** | DIY | Built-in with `{ key: 'cache-key' }` |
|
|
1018
|
-
| **Resume/persist** | DIY | Built-in with `resumeState` + `isStepComplete()` |
|
|
1019
|
-
| **Events** | DIY | 15+ event types via `onEvent` |
|
|
1020
|
-
|
|
1021
|
-
**When to use neverthrow:** You want typed Results with minimal bundle size and prefer functional chaining.
|
|
1022
|
-
|
|
1023
|
-
**When to use awaitly:** You want typed Results with async/await syntax, automatic error inference, and built-in reliability primitives.
|
|
1024
|
-
|
|
1025
|
-
See the [comparison table above](#vs-neverthrow) for pattern-by-pattern equivalents.
|
|
1026
|
-
|
|
1027
|
-
### Where awaitly shines
|
|
1028
|
-
|
|
1029
|
-
**Complex checkout flows:**
|
|
1030
|
-
```typescript
|
|
1031
|
-
// 5 different error types, all automatically inferred
|
|
1032
|
-
const checkout = createWorkflow({ validateCart, checkInventory, getPricing, processPayment, createOrder });
|
|
1033
|
-
|
|
1034
|
-
const result = await checkout(async (step) => {
|
|
1035
|
-
const cart = await step(() => validateCart(input));
|
|
657
|
+
| awaitly | neverthrow |
|
|
658
|
+
|---------|-----------|
|
|
659
|
+
| async/await with `step()` | `.andThen()` method chains |
|
|
660
|
+
| Automatic error inference | Manual error unions |
|
|
661
|
+
| Built-in retries, timeouts, caching | DIY |
|
|
1036
662
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
checkInventory(cart.items),
|
|
1040
|
-
getPricing(cart.items)
|
|
1041
|
-
]));
|
|
1042
|
-
|
|
1043
|
-
const payment = await step(() => processPayment(cart, pricing.total));
|
|
1044
|
-
return await step(() => createOrder(cart, payment));
|
|
1045
|
-
});
|
|
1046
|
-
// TypeScript knows: Result<Order, ValidationError | InventoryError | PricingError | PaymentError | OrderError>
|
|
1047
|
-
```
|
|
1048
|
-
|
|
1049
|
-
**Branching logic with native control flow:**
|
|
1050
|
-
```typescript
|
|
1051
|
-
// Just JavaScript - no functional gymnastics
|
|
1052
|
-
const tenant = await step(() => fetchTenant(id));
|
|
1053
|
-
|
|
1054
|
-
if (tenant.plan === 'free') {
|
|
1055
|
-
return await step(() => calculateFreeUsage(tenant));
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// Variables from earlier steps are in scope - no closure drilling
|
|
1059
|
-
const [users, resources] = await step(() => allAsync([fetchUsers(), fetchResources()]));
|
|
1060
|
-
|
|
1061
|
-
switch (tenant.plan) {
|
|
1062
|
-
case 'pro': await step(() => sendProNotification(tenant)); break;
|
|
1063
|
-
case 'enterprise': await step(() => sendEnterpriseNotification(tenant)); break;
|
|
1064
|
-
}
|
|
1065
|
-
```
|
|
1066
|
-
|
|
1067
|
-
**Data pipelines with caching and resume:**
|
|
1068
|
-
```typescript
|
|
1069
|
-
const pipeline = createWorkflow(deps, { cache: new Map() });
|
|
1070
|
-
|
|
1071
|
-
const result = await pipeline(async (step) => {
|
|
1072
|
-
// `key` enables caching and resume from last successful step
|
|
1073
|
-
const user = await step(() => fetchUser(id), { key: 'user' });
|
|
1074
|
-
const posts = await step(() => fetchPosts(user.id), { key: 'posts' });
|
|
1075
|
-
const comments = await step(() => fetchComments(posts), { key: 'comments' });
|
|
1076
|
-
return { user, posts, comments };
|
|
1077
|
-
}, { resumeState: savedState });
|
|
1078
|
-
```
|
|
663
|
+
**neverthrow:** Minimal bundle, functional chaining.
|
|
664
|
+
**awaitly:** async/await syntax + orchestration built in.
|
|
1079
665
|
|
|
1080
666
|
## Quick Reference
|
|
1081
667
|
|
|
1082
|
-
### Workflow Builders
|
|
1083
|
-
|
|
1084
668
|
| API | Description |
|
|
1085
669
|
|-----|-------------|
|
|
1086
|
-
| `createWorkflow(deps
|
|
1087
|
-
| `
|
|
1088
|
-
| `
|
|
1089
|
-
| `
|
|
670
|
+
| `createWorkflow(deps)` | Recommended. Auto-infers errors from deps. |
|
|
671
|
+
| `step(() => deps.fn())` | Run a step, unwrap result. |
|
|
672
|
+
| `step.retry(fn, opts)` | Retry with backoff. |
|
|
673
|
+
| `step.withTimeout(fn, { ms })` | Timeout protection. |
|
|
674
|
+
| `ok(value)` / `err(error)` | Construct Results. |
|
|
1090
675
|
|
|
1091
|
-
|
|
676
|
+
See [full API reference](https://jagreehal.github.io/awaitly/reference/api/) for `run()`, `step.try`, `step.fromResult`, combinators, circuit breakers, and more.
|
|
1092
677
|
|
|
1093
|
-
|
|
1094
|
-
|-----|-------------|
|
|
1095
|
-
| `step(op, meta?)` | Execute a dependency or thunk. Supports `{ key, name, retry, timeout }`. |
|
|
1096
|
-
| `step.try(fn, { error })` | Catch throws/rejections and emit a typed error. |
|
|
1097
|
-
| `step.fromResult(fn, { onError })` | Preserve rich error objects from other Result-returning code. |
|
|
1098
|
-
| `step.retry(fn, opts)` | Retries with fixed/linear/exponential backoff, jitter, and predicates. |
|
|
1099
|
-
| `step.withTimeout(fn, { ms, signal?, name? })` | Auto-timeout operations and optionally pass AbortSignal. |
|
|
678
|
+
### run()
|
|
1100
679
|
|
|
1101
|
-
|
|
680
|
+
Most users do NOT need `run()`.
|
|
1102
681
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
| `andThen`, `match` | Chain or pattern-match Results. |
|
|
1108
|
-
| `orElse`, `recover` | Error recovery and fallback patterns. |
|
|
1109
|
-
| `allAsync`, `partition` | Batch operations where the first error wins or you collect everything. |
|
|
1110
|
-
| `isStepTimeoutError(error)` | Runtime guard for timeout failures. |
|
|
1111
|
-
| `getStepTimeoutMeta(error)` | Inspect timeout metadata (attempt, ms, name). |
|
|
1112
|
-
| `createCircuitBreaker(name, config)` | Guard dependencies with open/close behavior. |
|
|
1113
|
-
| `createRateLimiter(name, config)` | Ensure steps respect throughput policies. |
|
|
1114
|
-
| `createWebhookHandler(workflow, fn, config)` | Turn workflows into HTTP handlers quickly. |
|
|
1115
|
-
|
|
1116
|
-
### Choosing Between run() and createWorkflow()
|
|
1117
|
-
|
|
1118
|
-
| Use Case | Recommendation |
|
|
1119
|
-
|----------|----------------|
|
|
1120
|
-
| Dependencies known at compile time | `createWorkflow()` |
|
|
1121
|
-
| Dependencies passed as parameters | `run()` |
|
|
1122
|
-
| Need step caching or resume | `createWorkflow()` |
|
|
1123
|
-
| One-off workflow invocation | `run()` |
|
|
1124
|
-
| Want automatic error inference | `createWorkflow()` |
|
|
1125
|
-
| Error types known upfront | `run()` |
|
|
1126
|
-
|
|
1127
|
-
**`run()`** - Best for dynamic dependencies, testing, or lightweight workflows where you know the error types:
|
|
682
|
+
Use it only when:
|
|
683
|
+
- dependencies are passed dynamically as parameters
|
|
684
|
+
- you want explicit control over the error union
|
|
685
|
+
- you're building abstractions on top of awaitly
|
|
1128
686
|
|
|
1129
687
|
```typescript
|
|
1130
|
-
import { run } from
|
|
688
|
+
import { run } from "awaitly";
|
|
1131
689
|
|
|
1132
|
-
const result = await run<Output,
|
|
690
|
+
const result = await run<Output, "NOT_FOUND" | "FETCH_ERROR">(
|
|
1133
691
|
async (step) => {
|
|
1134
|
-
const user = await step(fetchUser(userId)); //
|
|
692
|
+
const user = await step(() => fetchUser(userId)); // thunk for consistency
|
|
1135
693
|
return user;
|
|
1136
694
|
},
|
|
1137
|
-
{ onError: (e) => console.log(
|
|
695
|
+
{ onError: (e) => console.log("Failed:", e) }
|
|
1138
696
|
);
|
|
1139
697
|
```
|
|
1140
698
|
|
|
1141
|
-
|
|
699
|
+
For most cases, stick with `createWorkflow()` which infers error types automatically.
|
|
1142
700
|
|
|
1143
|
-
|
|
1144
|
-
const loadUser = createWorkflow({ fetchUser, fetchPosts });
|
|
1145
|
-
// Error type computed automatically from deps
|
|
1146
|
-
```
|
|
701
|
+
### Imports
|
|
1147
702
|
|
|
1148
|
-
|
|
703
|
+
Most apps only need:
|
|
1149
704
|
|
|
1150
705
|
```typescript
|
|
1151
|
-
|
|
1152
|
-
import {
|
|
1153
|
-
|
|
1154
|
-
// Core layer - Result primitives + tagged errors + pattern matching
|
|
1155
|
-
import { ok, err, map, TaggedError, Match } from 'awaitly/core';
|
|
1156
|
-
|
|
1157
|
-
// Workflow layer - orchestration, Duration, step collector
|
|
1158
|
-
import { createWorkflow, Duration, createResumeStateCollector, isStepComplete } from 'awaitly/workflow';
|
|
1159
|
-
|
|
1160
|
-
// Visualization tools
|
|
1161
|
-
import { createVisualizer } from 'awaitly/visualize';
|
|
1162
|
-
|
|
1163
|
-
// Batch processing
|
|
1164
|
-
import { processInBatches } from 'awaitly/batch';
|
|
1165
|
-
|
|
1166
|
-
// Resource management
|
|
1167
|
-
import { createResourceScope, withScope } from 'awaitly/resource';
|
|
1168
|
-
|
|
1169
|
-
// Retry strategies
|
|
1170
|
-
import { Schedule, Duration } from 'awaitly/retry';
|
|
1171
|
-
|
|
1172
|
-
// Reliability patterns (umbrella)
|
|
1173
|
-
import { createCircuitBreaker, createRateLimiter, createSagaWorkflow } from 'awaitly/reliability';
|
|
1174
|
-
|
|
1175
|
-
// Granular reliability imports
|
|
1176
|
-
import { createCircuitBreaker } from 'awaitly/circuit-breaker';
|
|
1177
|
-
import { createRateLimiter } from 'awaitly/ratelimit';
|
|
1178
|
-
import { createSagaWorkflow, runSaga } from 'awaitly/saga';
|
|
1179
|
-
import { servicePolicies, withPolicy } from 'awaitly/policies';
|
|
1180
|
-
|
|
1181
|
-
// Persistence and versioning
|
|
1182
|
-
import { createStatePersistence, createFileCache, migrateState } from 'awaitly/persistence';
|
|
1183
|
-
|
|
1184
|
-
// Human-in-the-loop orchestration
|
|
1185
|
-
import { createHITLOrchestrator, createApprovalWebhookHandler } from 'awaitly/hitl';
|
|
1186
|
-
|
|
1187
|
-
// HTTP webhook handlers
|
|
1188
|
-
import { createWebhookHandler, createExpressHandler } from 'awaitly/webhook';
|
|
1189
|
-
|
|
1190
|
-
// OpenTelemetry integration
|
|
1191
|
-
import { createAutotelAdapter, withAutotelTracing } from 'awaitly/otel';
|
|
1192
|
-
|
|
1193
|
-
// Development tools
|
|
1194
|
-
import { createDevtools, quickVisualize } from 'awaitly/devtools';
|
|
1195
|
-
|
|
1196
|
-
// Testing harness
|
|
1197
|
-
import { createWorkflowHarness, createMockFn } from 'awaitly/testing';
|
|
1198
|
-
|
|
1199
|
-
// Utility modules (optional granular imports)
|
|
1200
|
-
import { when, unless } from 'awaitly/conditional';
|
|
1201
|
-
import { Duration, millis, seconds } from 'awaitly/duration';
|
|
1202
|
-
import { Match, matchValue } from 'awaitly/match';
|
|
1203
|
-
import { TaggedError } from 'awaitly/tagged-error';
|
|
706
|
+
import { ok, err, type AsyncResult } from "awaitly";
|
|
707
|
+
import { createWorkflow, UNEXPECTED_ERROR } from "awaitly/workflow";
|
|
1204
708
|
```
|
|
1205
709
|
|
|
710
|
+
Everything else is optional and documented in the [guides](https://jagreehal.github.io/awaitly/).
|
|
711
|
+
|
|
1206
712
|
## Common Pitfalls
|
|
1207
713
|
|
|
1208
|
-
**Use thunks for caching.** `step(fetchUser('1'))` executes immediately. Use `step(() => fetchUser('1'), { key })` for caching to work.
|
|
714
|
+
**Use thunks for caching.** `step(deps.fetchUser('1'))` executes immediately. Use `step(() => deps.fetchUser('1'), { key })` for caching to work.
|
|
1209
715
|
|
|
1210
716
|
**Keys must be stable.** Use `user:${id}`, not `user:${Date.now()}`.
|
|
1211
717
|
|
|
@@ -1213,68 +719,32 @@ import { TaggedError } from 'awaitly/tagged-error';
|
|
|
1213
719
|
|
|
1214
720
|
## Troubleshooting & FAQ
|
|
1215
721
|
|
|
1216
|
-
- **Why is `UnexpectedError` in my
|
|
722
|
+
- **Why is `UnexpectedError` in my result?** It's a safety net for unexpected throws. Map it to HTTP 500 at the boundary.
|
|
1217
723
|
- **How do I inspect what ran?** Pass `onEvent` and log `step_*` / `workflow_*` events or feed them into `createVisualizer()` for diagrams.
|
|
1218
724
|
- **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.
|
|
1219
725
|
- **Cache is not used between runs.** Supply a stable `{ key }` per step and provide a cache/resume adapter in `createWorkflow(deps, { cache })`.
|
|
1220
726
|
- **I only need a single run with dynamic dependencies.** Use `run()` instead of `createWorkflow()` and pass dependencies directly to the executor.
|
|
1221
727
|
|
|
1222
|
-
##
|
|
1223
|
-
|
|
1224
|
-
Hook into the event stream and render diagrams for docs, PRs, or dashboards:
|
|
728
|
+
## Next Steps
|
|
1225
729
|
|
|
1226
|
-
|
|
1227
|
-
import { createVisualizer } from 'awaitly/visualize';
|
|
730
|
+
**If you only read one guide next:** [Retries & Timeouts](https://jagreehal.github.io/awaitly/guides/retries-timeouts/) - most apps need reliability.
|
|
1228
731
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
await workflow(async (step) => {
|
|
1235
|
-
const user = await step(() => fetchUser('1'), { name: 'Fetch user' });
|
|
1236
|
-
const posts = await step(() => fetchPosts(user.id), { name: 'Fetch posts' });
|
|
1237
|
-
return { user, posts };
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
// ASCII output for terminal/CLI
|
|
1241
|
-
console.log(viz.render());
|
|
1242
|
-
|
|
1243
|
-
// Mermaid diagram for Markdown/docs
|
|
1244
|
-
console.log(viz.renderAs('mermaid'));
|
|
732
|
+
**Other guides:**
|
|
733
|
+
- [Persistence](https://jagreehal.github.io/awaitly/guides/persistence/) - save & resume workflows
|
|
734
|
+
- [Testing](https://jagreehal.github.io/awaitly/guides/testing/) - deterministic harness
|
|
735
|
+
- [Full API Reference](https://jagreehal.github.io/awaitly/reference/api/)
|
|
1245
736
|
|
|
1246
|
-
|
|
1247
|
-
console.log(viz.renderAs('json'));
|
|
1248
|
-
```
|
|
1249
|
-
|
|
1250
|
-
Mermaid output drops directly into Markdown for documentation. The ASCII block is handy for CLI screenshots or incident runbooks.
|
|
1251
|
-
|
|
1252
|
-
**For post-execution visualization**, collect events and visualize later:
|
|
1253
|
-
|
|
1254
|
-
```typescript
|
|
1255
|
-
import { createEventCollector } from 'awaitly/visualize';
|
|
1256
|
-
|
|
1257
|
-
const collector = createEventCollector({ workflowName: 'my-workflow' });
|
|
1258
|
-
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
1259
|
-
onEvent: collector.handleEvent,
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
await workflow(async (step) => { /* ... */ });
|
|
1263
|
-
|
|
1264
|
-
// Visualize collected events
|
|
1265
|
-
console.log(collector.visualize());
|
|
1266
|
-
console.log(collector.visualizeAs('mermaid'));
|
|
1267
|
-
```
|
|
1268
|
-
|
|
1269
|
-
## Keep Going
|
|
1270
|
-
|
|
1271
|
-
**Already using neverthrow?** See the [comparison table above](#vs-neverthrow) for pattern-by-pattern equivalents - you'll feel at home quickly.
|
|
737
|
+
---
|
|
1272
738
|
|
|
1273
|
-
|
|
739
|
+
## You're done
|
|
1274
740
|
|
|
1275
|
-
|
|
741
|
+
If you understand:
|
|
742
|
+
- `ok` / `err`
|
|
743
|
+
- `createWorkflow`
|
|
744
|
+
- `step()`
|
|
745
|
+
- mapping Result at the boundary
|
|
1276
746
|
|
|
1277
|
-
|
|
747
|
+
You already know ~80% of awaitly.
|
|
1278
748
|
|
|
1279
749
|
---
|
|
1280
750
|
|