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