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.
Files changed (110) hide show
  1. package/README.md +397 -927
  2. package/dist/adapters.cjs +1 -1
  3. package/dist/adapters.cjs.map +1 -1
  4. package/dist/adapters.d.cts +1 -1
  5. package/dist/adapters.d.ts +1 -1
  6. package/dist/adapters.js +1 -1
  7. package/dist/adapters.js.map +1 -1
  8. package/dist/batch.cjs +1 -1
  9. package/dist/batch.cjs.map +1 -1
  10. package/dist/batch.d.cts +1 -1
  11. package/dist/batch.d.ts +1 -1
  12. package/dist/batch.js +1 -1
  13. package/dist/batch.js.map +1 -1
  14. package/dist/circuit-breaker.cjs +1 -1
  15. package/dist/circuit-breaker.cjs.map +1 -1
  16. package/dist/circuit-breaker.d.cts +1 -1
  17. package/dist/circuit-breaker.d.ts +1 -1
  18. package/dist/circuit-breaker.js +1 -1
  19. package/dist/circuit-breaker.js.map +1 -1
  20. package/dist/conditional.d.cts +1 -1
  21. package/dist/conditional.d.ts +1 -1
  22. package/dist/{core-CxdbubQK.d.cts → core-BKqkPqOF.d.cts} +20 -3
  23. package/dist/{core-CxdbubQK.d.ts → core-BKqkPqOF.d.ts} +20 -3
  24. package/dist/core.cjs +1 -1
  25. package/dist/core.cjs.map +1 -1
  26. package/dist/core.d.cts +1 -1
  27. package/dist/core.d.ts +1 -1
  28. package/dist/core.js +1 -1
  29. package/dist/core.js.map +1 -1
  30. package/dist/devtools.d.cts +1 -1
  31. package/dist/devtools.d.ts +1 -1
  32. package/dist/durable.cjs +1 -1
  33. package/dist/durable.cjs.map +1 -1
  34. package/dist/durable.d.cts +5 -5
  35. package/dist/durable.d.ts +5 -5
  36. package/dist/durable.js +1 -1
  37. package/dist/durable.js.map +1 -1
  38. package/dist/hitl.cjs +1 -1
  39. package/dist/hitl.cjs.map +1 -1
  40. package/dist/hitl.d.cts +3 -3
  41. package/dist/hitl.d.ts +3 -3
  42. package/dist/hitl.js +1 -1
  43. package/dist/hitl.js.map +1 -1
  44. package/dist/index.cjs +1 -1
  45. package/dist/index.cjs.map +1 -1
  46. package/dist/index.d.cts +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/otel.d.cts +1 -1
  51. package/dist/otel.d.ts +1 -1
  52. package/dist/{persistence-Bps_-g2-.d.ts → persistence-BmaP38VB.d.ts} +2 -2
  53. package/dist/{persistence-Co4_SpYv.d.cts → persistence-DBs3SyqK.d.cts} +2 -2
  54. package/dist/persistence.cjs +1 -1
  55. package/dist/persistence.cjs.map +1 -1
  56. package/dist/persistence.d.cts +3 -3
  57. package/dist/persistence.d.ts +3 -3
  58. package/dist/persistence.js +1 -1
  59. package/dist/persistence.js.map +1 -1
  60. package/dist/policies.d.cts +1 -1
  61. package/dist/policies.d.ts +1 -1
  62. package/dist/ratelimit.cjs +1 -1
  63. package/dist/ratelimit.cjs.map +1 -1
  64. package/dist/ratelimit.d.cts +1 -1
  65. package/dist/ratelimit.d.ts +1 -1
  66. package/dist/ratelimit.js +1 -1
  67. package/dist/ratelimit.js.map +1 -1
  68. package/dist/reliability.cjs +1 -1
  69. package/dist/reliability.cjs.map +1 -1
  70. package/dist/reliability.d.cts +1 -1
  71. package/dist/reliability.d.ts +1 -1
  72. package/dist/reliability.js +1 -1
  73. package/dist/reliability.js.map +1 -1
  74. package/dist/resource.cjs +1 -1
  75. package/dist/resource.cjs.map +1 -1
  76. package/dist/resource.d.cts +1 -1
  77. package/dist/resource.d.ts +1 -1
  78. package/dist/resource.js +1 -1
  79. package/dist/resource.js.map +1 -1
  80. package/dist/saga.cjs +1 -1
  81. package/dist/saga.cjs.map +1 -1
  82. package/dist/saga.d.cts +1 -1
  83. package/dist/saga.d.ts +1 -1
  84. package/dist/saga.js +1 -1
  85. package/dist/saga.js.map +1 -1
  86. package/dist/singleflight.d.cts +1 -1
  87. package/dist/singleflight.d.ts +1 -1
  88. package/dist/testing.cjs +2 -2
  89. package/dist/testing.cjs.map +1 -1
  90. package/dist/testing.d.cts +2 -2
  91. package/dist/testing.d.ts +2 -2
  92. package/dist/testing.js +2 -2
  93. package/dist/testing.js.map +1 -1
  94. package/dist/visualize.d.cts +1 -1
  95. package/dist/visualize.d.ts +1 -1
  96. package/dist/webhook.cjs +1 -1
  97. package/dist/webhook.cjs.map +1 -1
  98. package/dist/webhook.d.cts +2 -2
  99. package/dist/webhook.d.ts +2 -2
  100. package/dist/webhook.js +1 -1
  101. package/dist/webhook.js.map +1 -1
  102. package/dist/{workflow-C1Pt6ZaO.d.cts → workflow-884tiHWH.d.cts} +1 -1
  103. package/dist/{workflow-DJd6N9dy.d.ts → workflow-x4TnDXYW.d.ts} +1 -1
  104. package/dist/workflow.cjs +1 -1
  105. package/dist/workflow.cjs.map +1 -1
  106. package/dist/workflow.d.cts +2 -2
  107. package/dist/workflow.d.ts +2 -2
  108. package/dist/workflow.js +1 -1
  109. package/dist/workflow.js.map +1 -1
  110. package/package.json +1 -1
package/README.md CHANGED
@@ -1,25 +1,256 @@
1
1
  # awaitly
2
2
 
3
- *basically, async done right*
3
+ Stop writing `try/catch` in every async handler.
4
4
 
5
- Typed async workflows with automatic error inference. Build type-safe workflows with Result types, step caching, resume state, and human-in-the-loop support.
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
- **You've been here before:** You're debugging a production issue at 2am. The error says "Failed to load user data." But *why* did it fail? Was it the database? The cache? The API? TypeScript can't help you - it just sees `unknown` in every catch block.
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/)** guides, API reference, and examples.
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
- **What you get:**
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
- - **Automatic error inference** - Error types flow from your dependencies. Add a step? The union updates. Remove one? It updates. Zero manual tracking.
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 'awaitly';
90
- import { createWorkflow } from 'awaitly/workflow';
320
+ import { ok, err, type AsyncResult } from "awaitly";
321
+ import { createWorkflow, UNEXPECTED_ERROR } from "awaitly/workflow";
91
322
 
92
- // Define typed error types (explicit and type-safe!)
93
- type UserNotFound = { type: 'USER_NOT_FOUND'; userId: string };
94
- type InsufficientFunds = { type: 'INSUFFICIENT_FUNDS'; required: number; available: number };
95
- type TransferFailed = { type: 'TRANSFER_FAILED'; reason: string };
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<{ id: string; balance: number }, UserNotFound> => {
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: { balance: number }, amount: number): AsyncResult<void, InsufficientFunds> => {
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
- // TypeScript knows ALL possible error types at compile time!
127
- const transferWorkflow = createWorkflow(deps);
128
-
129
- const result = await transferWorkflow(async (step) => {
130
- // step() automatically unwraps success or exits early on error
131
- // No try/catch needed! No manual null checks!
132
- const fromUser = await step(deps.getUser(fromUserId));
133
- const toUser = await step(deps.getUser(toUserId));
134
- await step(deps.validateBalance(fromUser, amount));
135
- const transaction = await step(deps.executeTransfer(fromUser, toUser, amount));
136
- return transaction;
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 'USER_NOT_FOUND':
146
- console.log("Error: User not found:", result.error.userId); // Full context!
147
- break;
148
- case 'INSUFFICIENT_FUNDS':
149
- console.log(`Error: Need $${result.error.required} but only have $${result.error.available}`);
150
- break;
151
- case 'TRANSFER_FAILED':
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
- **The magic:** Error types are **inferred from your dependencies**. Add a new step? The error union updates automatically. Remove one? It updates. You'll never `switch` on an error that can't happen, or miss one that can. TypeScript enforces it at compile time.
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
- ## Quickstart (60 Seconds)
374
+ ---
161
375
 
162
- ### 1. Define your operations
376
+ ## Mental model
163
377
 
164
- Return `ok(value)` or `err(errorCode)` instead of throwing.
378
+ Think of awaitly like this:
165
379
 
166
- ```typescript
167
- import { ok, err, type AsyncResult } from 'awaitly';
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
- const fetchOrder = async (id: string): AsyncResult<Order, 'ORDER_NOT_FOUND'> =>
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
- ### 2. Create and run
389
+ ## Mapping errors at the boundary
177
390
 
178
- `createWorkflow` handles the type magic. `step()` unwraps results or exits early on failure.
391
+ The final result maps cleanly to HTTP responses, job statuses, or CLI exit codes:
179
392
 
180
393
  ```typescript
181
- import { createWorkflow } from 'awaitly/workflow';
394
+ import { UNEXPECTED_ERROR } from "awaitly/workflow";
182
395
 
183
- const checkout = createWorkflow({ fetchOrder, chargeCard });
184
-
185
- const result = await checkout(async (step) => {
186
- const order = await step(fetchOrder('order_456'));
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
- That's it! TypeScript knows exactly what can fail. Now let's see the full power.
401
+ switch (result.error.type) {
402
+ case "TASK_NOT_FOUND":
403
+ return { statusCode: 404, body: { message: "Task not found" } };
194
404
 
195
- ## How It Works
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
- ```mermaid
198
- flowchart TD
199
- subgraph "step() unwraps Results, exits early on error"
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
- 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.
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 user = await step.retry(
224
- () => fetchUser('1'),
225
- { attempts: 3, backoff: 'exponential', timeout: { ms: 5000 } }
432
+ const task = await step.retry(
433
+ () => deps.loadTask("t-1"),
434
+ { attempts: 3, backoff: "exponential", timeout: { ms: 5000 } }
226
435
  );
227
- return user;
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 - no fetchUser call
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: 'approve:refund' });
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
- ## Start Here
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
- ### Step 4 - Run & Inspect Results
611
+ You have the foundation. Pick one:
436
612
 
437
- Use `step()` inside the executor. It unwraps results, exits early on failure, and gives a typed `result` back to you.
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
- ```typescript
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
- ### Step 5 - Add Safeguards
453
-
454
- Introduce retries, timeout protection, or wrappers for throwing code only when you need them.
455
-
456
- ```typescript
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
- ## Persistence Quickstart
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
- import { createWorkflow, createResumeStateCollector } from 'awaitly/workflow';
484
- import { stringifyState, parseState } from 'awaitly/persistence';
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
- // 3. Resume later
504
- const saved = await db.workflowStates.findUnique({ where: { id: "123" } });
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
- const resumed = createWorkflow({ fetchUser, fetchPosts }, {
508
- resumeState: savedState,
509
- });
636
+ // Timeout protection
637
+ const result = await step.withTimeout(() => deps.slowOperation(), { ms: 5000 });
510
638
 
511
- // Cached steps skip execution automatically
512
- await resumed(async (step) => {
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
- ## Guided Tutorial
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
- ## Real-World Example: Safe Payment Retries with Persistence
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
- The scariest failure mode in payments: **charge succeeded, but persistence failed**. If you retry naively, you charge the customer twice.
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
- | Aspect | neverthrow | awaitly |
1009
- |--------|-----------|---------|
1010
- | **Chaining style** | `.andThen()` method chains (nest with 3+ ops) | `step()` with async/await (stays flat) |
1011
- | **Error inference** | Manual: `type Errors = 'A' \| 'B' \| 'C'` | Automatic from `createWorkflow({ deps })` |
1012
- | **Result access** | `.isOk()`, `.isErr()` methods | `.ok` boolean property |
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
- // Parallel execution stays clean
1038
- const [inventory, pricing] = await step(() => allAsync([
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, opts?)` | Reusable workflow with automatic error unions, caching, resume, events, strict mode. |
1087
- | `run(executor, opts?)` | One-off workflow; you supply `Output` and `Error` generics manually. |
1088
- | `createSagaWorkflow(deps, opts?)` | Workflow with automatic compensation handlers. |
1089
- | `createWorkflowHarness(deps, opts?)` | Testing harness with deterministic step control. |
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
- ### Step Helpers
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
- | API | Description |
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
- ### Result & Utility Helpers
680
+ Most users do NOT need `run()`.
1102
681
 
1103
- | API | Description |
1104
- |-----|-------------|
1105
- | `ok(value)` / `err(error)` | Construct Results. |
1106
- | `map`, `mapError`, `bimap` | Transform values or errors. |
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 'awaitly';
688
+ import { run } from "awaitly";
1131
689
 
1132
- const result = await run<Output, 'NOT_FOUND' | 'FETCH_ERROR'>(
690
+ const result = await run<Output, "NOT_FOUND" | "FETCH_ERROR">(
1133
691
  async (step) => {
1134
- const user = await step(fetchUser(userId)); // userId from parameter
692
+ const user = await step(() => fetchUser(userId)); // thunk for consistency
1135
693
  return user;
1136
694
  },
1137
- { onError: (e) => console.log('Failed:', e) }
695
+ { onError: (e) => console.log("Failed:", e) }
1138
696
  );
1139
697
  ```
1140
698
 
1141
- **`createWorkflow()`** - Best for reusable workflows with static dependencies. Provides automatic error type inference:
699
+ For most cases, stick with `createWorkflow()` which infers error types automatically.
1142
700
 
1143
- ```typescript
1144
- const loadUser = createWorkflow({ fetchUser, fetchPosts });
1145
- // Error type computed automatically from deps
1146
- ```
701
+ ### Imports
1147
702
 
1148
- ### Import paths
703
+ Most apps only need:
1149
704
 
1150
705
  ```typescript
1151
- // Main entry - result primitives + run() for composition
1152
- import { ok, err, map, all, run } from 'awaitly';
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 union?** Add `{ strict: true, catchUnexpected: () => 'UNEXPECTED' }` when creating the workflow to map unknown errors explicitly.
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
- ## Visualizing Workflows
1223
-
1224
- Hook into the event stream and render diagrams for docs, PRs, or dashboards:
728
+ ## Next Steps
1225
729
 
1226
- ```typescript
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
- const viz = createVisualizer({ workflowName: 'user-posts-flow' });
1230
- const workflow = createWorkflow({ fetchUser, fetchPosts }, {
1231
- onEvent: viz.handleEvent,
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
- // JSON IR for programmatic access
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
- **Ready for production features?** The [advanced guides](https://jagreehal.github.io/awaitly/advanced/saga-compensation/) cover sagas, circuit breakers, rate limiting, persistence adapters, and HITL orchestration.
739
+ ## You're done
1274
740
 
1275
- **Need the full API?** [API reference](https://jagreehal.github.io/awaitly/reference/api/) has everything in one place.
741
+ If you understand:
742
+ - `ok` / `err`
743
+ - `createWorkflow`
744
+ - `step()`
745
+ - mapping Result at the boundary
1276
746
 
1277
- **Explore all guides:** [Documentation site](https://jagreehal.github.io/awaitly/) has the complete set of guides, tutorials, and reference material.
747
+ You already know ~80% of awaitly.
1278
748
 
1279
749
  ---
1280
750