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
@@ -0,0 +1,1548 @@
1
+ # Advanced Usage
2
+
3
+ For core concepts (`createWorkflow`, `step`, `step.try`), see the [README](../README.md).
4
+
5
+ ## Batch operations
6
+
7
+ ```typescript
8
+ import { all, allSettled, any, partition } from 'awaitly';
9
+
10
+ // All must succeed (short-circuits on first error)
11
+ const combined = all([ok(1), ok(2), ok(3)]); // ok([1, 2, 3])
12
+
13
+ // Collect ALL errors (great for form validation)
14
+ const validated = allSettled([
15
+ validateEmail(email),
16
+ validatePassword(password),
17
+ ]);
18
+ // If any fail: err(['INVALID_EMAIL', 'WEAK_PASSWORD'])
19
+
20
+ // First success wins
21
+ const first = any([err('A'), ok('success'), err('B')]); // ok('success')
22
+
23
+ // Split successes and failures
24
+ const { values, errors } = partition(results);
25
+ ```
26
+
27
+ Async versions: `allAsync`, `allSettledAsync`, `anyAsync`.
28
+
29
+ ## Named Parallel Operations
30
+
31
+ Use `step.parallel()` with a named object for cleaner parallel execution with typed results:
32
+
33
+ ```typescript
34
+ const result = await workflow(async (step, { fetchUser, fetchPosts, fetchComments }) => {
35
+ // Named object form - each key gets its typed result
36
+ const { user, posts, comments } = await step.parallel({
37
+ user: () => fetchUser(id),
38
+ posts: () => fetchPosts(id),
39
+ comments: () => fetchComments(id),
40
+ }, { name: 'Fetch user data' });
41
+
42
+ // user: User, posts: Post[], comments: Comment[] - all typed!
43
+ return { user, posts, comments };
44
+ });
45
+ ```
46
+
47
+ Benefits:
48
+ - **Named results**: Destructure by name instead of array index
49
+ - **Type inference**: Each key preserves its specific type
50
+ - **Scope events**: Emits `scope_start`/`scope_end` for visualization
51
+ - **Fail-fast**: Short-circuits on first error (like `allAsync`)
52
+
53
+ ## Dynamic error mapping
54
+
55
+ Use `{ onError }` instead of `{ error }` to create errors from the caught value:
56
+
57
+ ```typescript
58
+ const result = await workflow(async (step) => {
59
+ const data = await step.try(
60
+ () => fetchExternalApi(),
61
+ { onError: (e) => ({ type: 'API_ERROR' as const, message: String(e) }) }
62
+ );
63
+
64
+ // Or extract specific error info
65
+ const parsed = await step.try(
66
+ () => schema.parse(data),
67
+ { onError: (e) => ({ type: 'VALIDATION_ERROR' as const, issues: e.issues }) }
68
+ );
69
+
70
+ return parsed;
71
+ });
72
+ ```
73
+
74
+ ## Type utilities
75
+
76
+ ```typescript
77
+ import { type ErrorOf, type Errors, type ErrorsOfDeps } from 'awaitly';
78
+
79
+ // Extract error type from a function
80
+ type UserError = ErrorOf<typeof fetchUser>; // 'NOT_FOUND'
81
+
82
+ // Combine errors from multiple functions
83
+ type AppError = Errors<[typeof fetchUser, typeof fetchPosts]>;
84
+ // 'NOT_FOUND' | 'FETCH_ERROR'
85
+
86
+ // Extract from a deps object (same as createWorkflow uses)
87
+ type WorkflowErrors = ErrorsOfDeps<{ fetchUser: typeof fetchUser }>;
88
+ ```
89
+
90
+ ## Wrapping existing code
91
+
92
+ ```typescript
93
+ import { from, fromPromise, tryAsync, fromNullable } from 'awaitly';
94
+
95
+ // Sync throwing function
96
+ const parsed = from(
97
+ () => JSON.parse(input),
98
+ (cause) => ({ type: 'PARSE_ERROR' as const, cause })
99
+ );
100
+
101
+ // Existing promise (remember: fetch needs r.ok check!)
102
+ const result = await fromPromise(
103
+ fetch('/api').then(r => {
104
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
105
+ return r.json();
106
+ }),
107
+ () => 'FETCH_FAILED' as const
108
+ );
109
+
110
+ // Nullable → Result
111
+ const element = fromNullable(
112
+ document.getElementById('app'),
113
+ () => 'NOT_FOUND' as const
114
+ );
115
+ ```
116
+
117
+ ## Transformers
118
+
119
+ ```typescript
120
+ import { map, mapError, match, andThen, tap } from 'awaitly';
121
+
122
+ const doubled = map(ok(21), n => n * 2); // ok(42)
123
+
124
+ const mapped = mapError(err('not_found'), e => e.toUpperCase()); // err('NOT_FOUND')
125
+
126
+ const message = match(result, {
127
+ ok: (user) => `Hello ${user.name}`,
128
+ err: (error) => `Error: ${error}`,
129
+ });
130
+
131
+ // Chain results (flatMap)
132
+ const userPosts = andThen(fetchUser('1'), user => fetchPosts(user.id));
133
+
134
+ // Side effects without changing result
135
+ const logged = tap(result, user => console.log('Got user:', user.name));
136
+ ```
137
+
138
+ ## Human-in-the-loop (HITL)
139
+
140
+ Build workflows that pause for human approval:
141
+
142
+ ```typescript
143
+ import {
144
+ createWorkflow,
145
+ createApprovalStep,
146
+ createHITLCollector,
147
+ isPendingApproval,
148
+ injectApproval,
149
+ } from 'awaitly';
150
+
151
+ // 1. Create an approval step
152
+ const requireManagerApproval = createApprovalStep<{ approvedBy: string }>({
153
+ key: 'manager-approval',
154
+ checkApproval: async () => {
155
+ const approval = await db.getApproval('manager-approval');
156
+ if (!approval) return { status: 'pending' };
157
+ if (approval.rejected) return { status: 'rejected', reason: approval.reason };
158
+ return { status: 'approved', value: { approvedBy: approval.manager } };
159
+ },
160
+ pendingReason: 'Waiting for manager approval',
161
+ });
162
+
163
+ // 2. Use in workflow with collector
164
+ const collector = createHITLCollector();
165
+ const workflow = createWorkflow(
166
+ { fetchData, requireManagerApproval },
167
+ { onEvent: collector.handleEvent }
168
+ );
169
+
170
+ const result = await workflow(async (step) => {
171
+ const data = await step(() => fetchData('123'), { key: 'data' });
172
+ const approval = await step(requireManagerApproval, { key: 'manager-approval' });
173
+ return { data, approvedBy: approval.approvedBy };
174
+ });
175
+
176
+ // 3. Handle pending state
177
+ if (!result.ok && isPendingApproval(result.error)) {
178
+ // Save state for later resume
179
+ await saveToDatabase(collector.getState());
180
+ console.log(`Workflow paused: ${result.error.reason}`);
181
+ }
182
+
183
+ // 4. Resume after approval granted
184
+ const savedState = await loadFromDatabase();
185
+ const resumeState = injectApproval(savedState, {
186
+ stepKey: 'manager-approval',
187
+ value: { approvedBy: 'alice@example.com' }
188
+ });
189
+
190
+ const workflow2 = createWorkflow(
191
+ { fetchData, requireManagerApproval },
192
+ { resumeState }
193
+ );
194
+ // Re-run same workflow body -- cached steps skip, approval injected
195
+ ```
196
+
197
+ ### HITL utilities
198
+
199
+ ```typescript
200
+ // Check approval state
201
+ hasPendingApproval(state, 'approval-key') // boolean
202
+ getPendingApprovals(state) // string[]
203
+
204
+ // Modify state
205
+ clearStep(state, 'step-key') // Remove step from state
206
+ injectApproval(state, { stepKey, value }) // Add approval result
207
+ ```
208
+
209
+ ## Interop with neverthrow
210
+
211
+ ```typescript
212
+ import { Result as NTResult } from 'neverthrow';
213
+ import { ok, err, type Result } from 'awaitly';
214
+
215
+ function fromNeverthrow<T, E>(ntResult: NTResult<T, E>): Result<T, E> {
216
+ return ntResult.isOk() ? ok(ntResult.value) : err(ntResult.error);
217
+ }
218
+
219
+ // Use in workflow
220
+ const result = await workflow(async (step) => {
221
+ const validated = await step(fromNeverthrow(validateInput(data)));
222
+ return validated;
223
+ });
224
+ ```
225
+
226
+ ## Low-level: run()
227
+
228
+ `createWorkflow` is built on `run()`. Use it for one-off workflows:
229
+
230
+ ```typescript
231
+ import { run } from 'awaitly';
232
+
233
+ const result = await run(async (step) => {
234
+ const user = await step(fetchUser(id));
235
+ return user;
236
+ });
237
+ ```
238
+
239
+ ### run.strict()
240
+
241
+ For closed error unions without `UnexpectedError`:
242
+
243
+ ```typescript
244
+ import { run } from 'awaitly';
245
+
246
+ type AppError = 'NOT_FOUND' | 'UNAUTHORIZED' | 'UNEXPECTED';
247
+
248
+ const result = await run.strict<User, AppError>(
249
+ async (step) => {
250
+ return await step(fetchUser(id));
251
+ },
252
+ { catchUnexpected: () => 'UNEXPECTED' as const }
253
+ );
254
+
255
+ // result.error: 'NOT_FOUND' | 'UNAUTHORIZED' | 'UNEXPECTED' (exactly)
256
+ ```
257
+
258
+ Prefer `createWorkflow` for automatic error type inference.
259
+
260
+ ## Workflow Hooks
261
+
262
+ `createWorkflow` supports hooks for distributed systems integration:
263
+
264
+ ```typescript
265
+ const workflow = createWorkflow({ processOrder }, {
266
+ // Called first - check if workflow should run (concurrency control)
267
+ shouldRun: async (workflowId, context) => {
268
+ const lock = await acquireDistributedLock(workflowId);
269
+ return lock.acquired; // false skips workflow execution
270
+ },
271
+
272
+ // Called after shouldRun - additional pre-flight checks
273
+ onBeforeStart: async (workflowId, context) => {
274
+ await extendMessageVisibility(context.messageId);
275
+ return true; // false skips workflow execution
276
+ },
277
+
278
+ // Called after each keyed step completes - for checkpointing
279
+ onAfterStep: async (stepKey, result, workflowId, context) => {
280
+ await checkpointStep(workflowId, stepKey, result);
281
+ },
282
+ });
283
+ ```
284
+
285
+ ### Hook execution order
286
+
287
+ 1. `shouldRun` - Return `false` to skip (e.g., rate limiting, duplicate detection)
288
+ 2. `onBeforeStart` - Return `false` to skip (e.g., distributed locking)
289
+ 3. Workflow executes, calling `onAfterStep` after each keyed step
290
+ 4. `onEvent` receives all workflow events
291
+
292
+ ### Use cases
293
+
294
+ | Hook | Use Case |
295
+ |------|----------|
296
+ | `shouldRun` | Distributed locking, rate limiting, duplicate detection |
297
+ | `onBeforeStart` | Queue message visibility, acquire resources |
298
+ | `onAfterStep` | Checkpoint to external store, extend message visibility |
299
+
300
+ #### `shouldRun` - Concurrency Control
301
+
302
+ Use for early gating before workflow execution starts:
303
+
304
+ **Distributed locking** - Prevent duplicate execution across instances:
305
+ ```typescript
306
+ shouldRun: async (workflowId) => {
307
+ const lock = await redis.set(`lock:${workflowId}`, '1', 'EX', 3600, 'NX');
308
+ return lock === 'OK'; // false = another instance is running
309
+ }
310
+ ```
311
+
312
+ **Rate limiting** - Skip if too many workflows are running:
313
+ ```typescript
314
+ shouldRun: async () => {
315
+ const count = await getActiveWorkflowCount();
316
+ return count < MAX_CONCURRENT_WORKFLOWS;
317
+ }
318
+ ```
319
+
320
+ **Duplicate detection** - Skip if already processed:
321
+ ```typescript
322
+ shouldRun: async (workflowId) => {
323
+ const exists = await db.workflows.findUnique({ where: { id: workflowId } });
324
+ return !exists; // Skip if already processed
325
+ }
326
+ ```
327
+
328
+ #### `onBeforeStart` - Pre-flight Setup
329
+
330
+ Use for setup operations that must happen before execution:
331
+
332
+ **Queue message visibility** - Extend visibility timeout (SQS, RabbitMQ):
333
+ ```typescript
334
+ onBeforeStart: async (workflowId, ctx) => {
335
+ await sqs.changeMessageVisibility({
336
+ ReceiptHandle: ctx.messageHandle,
337
+ VisibilityTimeout: 300 // 5 minutes
338
+ });
339
+ return true;
340
+ }
341
+ ```
342
+
343
+ **Resource acquisition** - Acquire database connections, file locks:
344
+ ```typescript
345
+ onBeforeStart: async (workflowId) => {
346
+ const connection = await acquireDbConnection();
347
+ if (!connection) return false; // Skip if no resources available
348
+ return true;
349
+ }
350
+ ```
351
+
352
+ **Pre-flight validation** - Check prerequisites before starting:
353
+ ```typescript
354
+ onBeforeStart: async (workflowId, ctx) => {
355
+ const order = await db.orders.findUnique({ where: { id: ctx.orderId } });
356
+ return order?.status === 'PENDING'; // Only process pending orders
357
+ }
358
+ ```
359
+
360
+ #### `onAfterStep` - Checkpointing & Observability
361
+
362
+ Use for incremental persistence and monitoring after each step:
363
+
364
+ **Incremental checkpointing** - Save progress after each step:
365
+ ```typescript
366
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
367
+ // Save progress even if workflow crashes later
368
+ await db.checkpoints.upsert({
369
+ where: { workflowId_stepKey: { workflowId, stepKey } },
370
+ update: { result: JSON.stringify(result), updatedAt: new Date() },
371
+ create: { workflowId, stepKey, result: JSON.stringify(result) }
372
+ });
373
+ }
374
+ ```
375
+
376
+ **Queue message visibility extension** - Keep message alive during long workflows:
377
+ ```typescript
378
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
379
+ // Extend visibility every step to prevent timeout
380
+ await sqs.changeMessageVisibility({
381
+ ReceiptHandle: ctx.messageHandle,
382
+ VisibilityTimeout: 300
383
+ });
384
+ }
385
+ ```
386
+
387
+ **Progress notifications** - Send updates to users/operators:
388
+ ```typescript
389
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
390
+ if (result.ok) {
391
+ await notifyUser(ctx.userId, {
392
+ workflowId,
393
+ step: stepKey,
394
+ status: 'completed',
395
+ progress: calculateProgress(stepKey)
396
+ });
397
+ }
398
+ }
399
+ ```
400
+
401
+ **Metrics & monitoring** - Track step performance:
402
+ ```typescript
403
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
404
+ await metrics.record({
405
+ workflowId,
406
+ stepKey,
407
+ success: result.ok,
408
+ duration: Date.now() - ctx.stepStartTime
409
+ });
410
+ }
411
+ ```
412
+
413
+ **Dead letter queue management** - Handle persistent failures:
414
+ ```typescript
415
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
416
+ if (!result.ok && ctx.retryCount >= MAX_RETRIES) {
417
+ await sendToDeadLetterQueue(workflowId, stepKey, result);
418
+ }
419
+ }
420
+ ```
421
+
422
+ **Workflow state snapshots** - Create resumable checkpoints:
423
+ ```typescript
424
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
425
+ // Create snapshot for crash recovery
426
+ const snapshot = await createSnapshot(workflowId, stepKey, result);
427
+ await db.snapshots.create({ data: snapshot });
428
+ }
429
+ ```
430
+
431
+ **Stream/event publishing** - Emit step completion events:
432
+ ```typescript
433
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
434
+ await eventStream.publish({
435
+ type: 'step_completed',
436
+ workflowId,
437
+ stepKey,
438
+ success: result.ok,
439
+ timestamp: Date.now()
440
+ });
441
+ }
442
+ ```
443
+
444
+ **Important notes:**
445
+ - `onAfterStep` is called for both success and error results
446
+ - Only called for steps with a `key` option
447
+ - Works even without a cache (useful for checkpointing-only scenarios)
448
+ - Called after each step completes, not for cached steps
449
+
450
+ ### With context
451
+
452
+ Combine hooks with `createContext` for request-scoped data:
453
+
454
+ ```typescript
455
+ type Context = { messageId: string; traceId: string };
456
+
457
+ const workflow = createWorkflow<Deps, Context>({ processOrder }, {
458
+ createContext: () => ({
459
+ messageId: getCurrentMessageId(),
460
+ traceId: generateTraceId(),
461
+ }),
462
+ onAfterStep: async (stepKey, result, workflowId, ctx) => {
463
+ console.log(`[${ctx.traceId}] Step ${stepKey} completed`);
464
+ },
465
+ });
466
+ ```
467
+
468
+ ## Circuit Breaker
469
+
470
+ Prevent cascading failures by tracking step failure rates and short-circuiting calls when a threshold is exceeded:
471
+
472
+ ```typescript
473
+ import { ok } from 'awaitly';
474
+ import {
475
+ createCircuitBreaker,
476
+ isCircuitOpenError,
477
+ circuitBreakerPresets,
478
+ } from 'awaitly/circuit-breaker';
479
+
480
+ // Create a circuit breaker with custom config (name is required)
481
+ const breaker = createCircuitBreaker('external-api', {
482
+ failureThreshold: 5, // Open after 5 failures
483
+ resetTimeout: 30000, // Try again after 30 seconds
484
+ halfOpenMax: 3, // Allow 3 test requests in half-open state
485
+ windowSize: 60000, // Count failures within this window (1 minute)
486
+ });
487
+
488
+ // Or use a preset
489
+ const criticalBreaker = createCircuitBreaker('critical-service', circuitBreakerPresets.critical);
490
+ const lenientBreaker = createCircuitBreaker('lenient-service', circuitBreakerPresets.lenient);
491
+
492
+ // Option 1: execute() throws CircuitOpenError if circuit is open
493
+ try {
494
+ const data = await breaker.execute(async () => {
495
+ return await fetchFromExternalApi();
496
+ });
497
+ console.log('Got data:', data);
498
+ } catch (error) {
499
+ if (isCircuitOpenError(error)) {
500
+ console.log(`Circuit is open, retry after ${error.retryAfterMs}ms`);
501
+ } else {
502
+ console.log('Operation failed:', error);
503
+ }
504
+ }
505
+
506
+ // Option 2: executeResult() returns a Result instead of throwing
507
+ const result = await breaker.executeResult(async () => {
508
+ // Your Result-returning operation
509
+ return ok(await fetchFromExternalApi());
510
+ });
511
+
512
+ if (!result.ok) {
513
+ if (isCircuitOpenError(result.error)) {
514
+ console.log('Circuit is open, try again later');
515
+ }
516
+ }
517
+
518
+ // Check circuit state (no arguments needed)
519
+ const stats = breaker.getStats();
520
+ console.log(stats.state); // 'CLOSED' | 'OPEN' | 'HALF_OPEN'
521
+ console.log(stats.failureCount);
522
+ console.log(stats.successCount);
523
+ console.log(stats.halfOpenSuccesses);
524
+ ```
525
+
526
+ ## Saga / Compensation Pattern
527
+
528
+ Define compensating actions for steps that need rollback on downstream failures:
529
+
530
+ ```typescript
531
+ import { createSagaWorkflow, isSagaCompensationError } from 'awaitly/saga';
532
+
533
+ // Create saga with deps (like createWorkflow) - error types inferred automatically
534
+ const checkoutSaga = createSagaWorkflow(
535
+ { reserveInventory, chargeCard, sendConfirmation },
536
+ { onEvent: (event) => console.log(event) }
537
+ );
538
+
539
+ const result = await checkoutSaga(async (saga, deps) => {
540
+ // Reserve inventory with compensation
541
+ const reservation = await saga.step(
542
+ () => deps.reserveInventory(items),
543
+ {
544
+ name: 'reserve-inventory',
545
+ compensate: (res) => releaseInventory(res.reservationId),
546
+ }
547
+ );
548
+
549
+ // Charge card with compensation
550
+ const payment = await saga.step(
551
+ () => deps.chargeCard(amount),
552
+ {
553
+ name: 'charge-card',
554
+ compensate: (p) => refundPayment(p.transactionId),
555
+ }
556
+ );
557
+
558
+ // If sendConfirmation fails, compensations run in reverse order:
559
+ // 1. refundPayment(payment.transactionId)
560
+ // 2. releaseInventory(reservation.reservationId)
561
+ await saga.step(
562
+ () => deps.sendConfirmation(email),
563
+ { name: 'send-confirmation' }
564
+ );
565
+
566
+ return { reservation, payment };
567
+ });
568
+
569
+ // Check for compensation errors
570
+ if (!result.ok && isSagaCompensationError(result.error)) {
571
+ console.log('Saga failed, compensations may have partially succeeded');
572
+ console.log(result.error.compensationErrors);
573
+ }
574
+ ```
575
+
576
+ ### Low-level runSaga
577
+
578
+ For explicit error typing without deps-based inference:
579
+
580
+ ```typescript
581
+ import { runSaga } from 'awaitly/saga';
582
+
583
+ const result = await runSaga<CheckoutResult, CheckoutError>(async (saga) => {
584
+ const reservation = await saga.step(
585
+ () => reserveInventory(items),
586
+ { compensate: (res) => releaseInventory(res.id) }
587
+ );
588
+
589
+ // tryStep for catching throws
590
+ const payment = await saga.tryStep(
591
+ () => externalPaymentApi.charge(amount),
592
+ {
593
+ error: 'PAYMENT_FAILED' as const,
594
+ compensate: (p) => externalPaymentApi.refund(p.txId),
595
+ }
596
+ );
597
+
598
+ return { reservation, payment };
599
+ });
600
+ ```
601
+
602
+ ## Rate Limiting / Concurrency Control
603
+
604
+ Control throughput for steps that hit rate-limited APIs:
605
+
606
+ ```typescript
607
+ import {
608
+ createRateLimiter,
609
+ createConcurrencyLimiter,
610
+ createCombinedLimiter,
611
+ rateLimiterPresets,
612
+ } from 'awaitly/ratelimit';
613
+
614
+ // Token bucket rate limiter (requires name and config)
615
+ const rateLimiter = createRateLimiter('api-calls', {
616
+ maxPerSecond: 10, // Maximum operations per second
617
+ burstCapacity: 20, // Allow brief spikes (default: maxPerSecond * 2)
618
+ strategy: 'wait', // 'wait' (default) or 'reject'
619
+ });
620
+
621
+ // Concurrency limiter
622
+ const concurrencyLimiter = createConcurrencyLimiter('db-pool', {
623
+ maxConcurrent: 5, // Max 5 concurrent operations
624
+ maxQueueSize: 100, // Queue up to 100 waiting requests
625
+ strategy: 'queue', // 'queue' (default) or 'reject'
626
+ });
627
+
628
+ // Use presets for common scenarios
629
+ const apiLimiter = createRateLimiter('external-api', rateLimiterPresets.api);
630
+ // rateLimiterPresets.api: { maxPerSecond: 10, burstCapacity: 20, strategy: 'wait' }
631
+ // rateLimiterPresets.external: { maxPerSecond: 5, burstCapacity: 10, strategy: 'wait' }
632
+ // rateLimiterPresets.database: for ConcurrencyLimiter - { maxConcurrent: 10, strategy: 'queue', maxQueueSize: 100 }
633
+
634
+ // Wrap operations with execute() method
635
+ const data = await rateLimiter.execute(async () => {
636
+ return await callExternalApi();
637
+ });
638
+
639
+ // For Result-returning operations
640
+ const result = await rateLimiter.executeResult(async () => {
641
+ return ok(await callExternalApi());
642
+ });
643
+
644
+ // Use with batch operations
645
+ const results = await concurrencyLimiter.executeAll(
646
+ ids.map(id => async () => fetchItem(id))
647
+ );
648
+
649
+ // Combined limiter (both rate and concurrency)
650
+ const combined = createCombinedLimiter('api', {
651
+ rate: { maxPerSecond: 10 },
652
+ concurrency: { maxConcurrent: 3 },
653
+ });
654
+
655
+ const data = await combined.execute(async () => callApi());
656
+
657
+ // Get limiter statistics
658
+ const stats = rateLimiter.getStats();
659
+ console.log(stats.availableTokens); // Current available tokens
660
+ console.log(stats.waitingCount); // Requests waiting for tokens
661
+ ```
662
+
663
+ ## Workflow Versioning and Migration
664
+
665
+ Handle schema changes when resuming workflows persisted with older step shapes:
666
+
667
+ ```typescript
668
+ import {
669
+ createVersionedStateLoader,
670
+ createVersionedState,
671
+ parseVersionedState,
672
+ stringifyVersionedState,
673
+ migrateState,
674
+ createKeyRenameMigration,
675
+ createKeyRemoveMigration,
676
+ createValueTransformMigration,
677
+ composeMigrations,
678
+ } from 'awaitly/persistence';
679
+
680
+ // Define migrations from each version to the next
681
+ // Key is source version, migration transforms to version + 1
682
+ const migrations = {
683
+ // Migrate from v1 to v2: rename keys
684
+ 1: createKeyRenameMigration({
685
+ 'user:fetch': 'user:load',
686
+ 'order:create': 'order:submit',
687
+ }),
688
+
689
+ // Migrate from v2 to v3: multiple transformations
690
+ 2: composeMigrations([
691
+ createKeyRemoveMigration(['deprecated:step']),
692
+ createValueTransformMigration({
693
+ 'user:load': (entry) => ({
694
+ ...entry,
695
+ result: entry.result.ok
696
+ ? { ok: true, value: { ...entry.result.value, newField: 'default' } }
697
+ : entry.result,
698
+ }),
699
+ }),
700
+ ]),
701
+ };
702
+
703
+ // Create a versioned state loader
704
+ const loader = createVersionedStateLoader({
705
+ version: 3, // Current workflow version
706
+ migrations,
707
+ strictVersioning: true, // Fail if state is from newer version
708
+ });
709
+
710
+ // Load state from storage and parse it
711
+ const json = await db.loadWorkflowState(runId);
712
+ const versionedState = parseVersionedState(json);
713
+
714
+ // Migrate to current version
715
+ const result = await loader(versionedState);
716
+ if (!result.ok) {
717
+ // Handle migration error or version incompatibility
718
+ console.error(result.error);
719
+ }
720
+
721
+ // Use migrated state with workflow
722
+ const workflow = createWorkflow(deps, { resumeState: result.value });
723
+
724
+ // When saving state, create versioned state
725
+ import { createStepCollector } from 'awaitly';
726
+
727
+ const collector = createStepCollector();
728
+ // ... run workflow with collector ...
729
+
730
+ const versionedState = createVersionedState(collector.getState(), 3);
731
+ const serialized = stringifyVersionedState(versionedState);
732
+ await db.saveWorkflowState(runId, serialized);
733
+ ```
734
+
735
+ ## Conditional Step Execution
736
+
737
+ Declarative guards for steps that should only run under certain conditions:
738
+
739
+ ```typescript
740
+ import { when, unless, whenOr, unlessOr, createConditionalHelpers } from 'awaitly/conditional';
741
+
742
+ const result = await workflow(async (step) => {
743
+ const user = await step(() => fetchUser(id), { key: 'user' });
744
+
745
+ // Only runs if condition is true, returns undefined if skipped
746
+ const premium = await when(
747
+ user.isPremium,
748
+ () => step(() => fetchPremiumData(user.id), { key: 'premium' }),
749
+ { name: 'check-premium', reason: 'User is not premium' }
750
+ );
751
+
752
+ // Skips if condition is true, returns undefined if skipped
753
+ const trial = await unless(
754
+ user.isPremium,
755
+ () => step(() => fetchTrialLimits(user.id), { key: 'trial' }),
756
+ { name: 'check-trial', reason: 'User is premium' }
757
+ );
758
+
759
+ // With default value instead of undefined
760
+ const limits = await whenOr(
761
+ user.isPremium,
762
+ () => step(() => fetchPremiumLimits(user.id), { key: 'premium-limits' }),
763
+ { maxRequests: 100, maxStorage: 1000 }, // default for non-premium
764
+ { name: 'check-premium-limits', reason: 'Using default limits' }
765
+ );
766
+
767
+ return { user, premium, trial, limits };
768
+ });
769
+ ```
770
+
771
+ ### With Event Emission
772
+
773
+ Use `createConditionalHelpers` with `run()` or `createWorkflow` to emit `step_skipped` events for visualization and debugging. **Context is automatically included in skipped events when provided**, maintaining correlation with other workflow events.
774
+
775
+ **Note**: Both `run()` and `createWorkflow` support conditional helpers with event emission. With `createWorkflow`, use the `ctx` parameter (always provided) to access `workflowId`, `onEvent`, and `context` for conditional helpers.
776
+
777
+ ```typescript
778
+ // With run() - full support for conditional helpers with context
779
+ type RequestContext = { requestId: string; userId: string };
780
+
781
+ const requestContext: RequestContext = { requestId: 'req-123', userId: 'user-456' };
782
+
783
+ // Define workflowId and onEvent before calling run()
784
+ const workflowId = 'my-workflow-id';
785
+ const onEvent = (event: WorkflowEvent<unknown, RequestContext>) => {
786
+ console.log('Event:', event.type, 'Context:', event.context);
787
+ };
788
+
789
+ const result = await run(async (step) => {
790
+ // Pass workflowId, onEvent, and context to conditional helpers
791
+ const ctx = {
792
+ workflowId, // Captured from outer scope
793
+ onEvent, // Captured from outer scope
794
+ context: requestContext // Context automatically added to step_skipped events
795
+ };
796
+ const { when, whenOr } = createConditionalHelpers(ctx);
797
+
798
+ const user = await step(fetchUser(id));
799
+
800
+ // Emits step_skipped event with context when condition is false
801
+ const premium = await when(
802
+ user.isPremium,
803
+ () => step(() => fetchPremiumData(user.id)),
804
+ { name: 'premium-data', reason: 'User is not premium' }
805
+ );
806
+
807
+ return { user, premium };
808
+ }, { onEvent, workflowId, context: requestContext });
809
+ ```
810
+
811
+ ```typescript
812
+ // With createWorkflow - use WorkflowContext parameter for conditional helpers
813
+ // The callback receives a third parameter `ctx` (always provided) with workflowId, onEvent, and context
814
+
815
+ const workflow = createWorkflow({ fetchUser }, {
816
+ createContext: () => ({ requestId: 'req-123', userId: 'user-456' }),
817
+ onEvent: (event, ctx) => {
818
+ // All workflow events automatically include context, including step_skipped from conditional helpers
819
+ console.log('Event context:', event.context);
820
+ }
821
+ });
822
+
823
+ // Use conditional helpers with ctx parameter (ctx is always provided)
824
+ await workflow(async (step, deps, ctx) => {
825
+ const user = await step(fetchUser(id));
826
+
827
+ // ctx can be passed directly to createConditionalHelpers (same shape)
828
+ const { when } = createConditionalHelpers(ctx);
829
+
830
+ // Emits step_skipped event with context when condition is false
831
+ const premium = await when(
832
+ user.isPremium,
833
+ () => step(() => fetchPremiumData(user.id)),
834
+ { name: 'premium-data', reason: 'User is not premium' }
835
+ );
836
+
837
+ return { user, premium };
838
+ });
839
+ ```
840
+
841
+ **Important Notes**:
842
+ - **With `run()`**: Full support for conditional helpers with event emission and context correlation. Capture `workflowId`, `onEvent`, and `context` from options before calling `run()`, then pass them to `createConditionalHelpers`.
843
+ - **With `createWorkflow`**: The `ctx` parameter (third parameter) is always provided in your callback. It contains `workflowId`, `onEvent`, and `context`. Pass these to `createConditionalHelpers` for full event emission support.
844
+ - **Backward Compatibility**: Existing code that doesn't use the `ctx` parameter continues to work (TypeScript allows unused parameters). You can ignore `ctx` if you don't need conditional helpers with event emission.
845
+
846
+ ## Webhook / Event Trigger Adapters
847
+
848
+ Expose workflows as HTTP endpoints or event consumers:
849
+
850
+ ```typescript
851
+ import {
852
+ createWebhookHandler,
853
+ createSimpleHandler,
854
+ createResultMapper,
855
+ createExpressHandler,
856
+ validationError,
857
+ requireFields,
858
+ } from 'awaitly/webhook';
859
+
860
+ // Create a webhook handler for a workflow
861
+ const handler = createWebhookHandler(
862
+ checkoutWorkflow,
863
+ async (step, deps, input: CheckoutInput) => {
864
+ const charge = await step(() => deps.chargeCard(input.amount));
865
+ await step(() => deps.sendEmail(input.email, charge.receiptUrl));
866
+ return { chargeId: charge.id };
867
+ },
868
+ {
869
+ validateInput: (req) => {
870
+ const validation = requireFields(['amount', 'email'])(req.body);
871
+ if (!validation.ok) return validation;
872
+ return ok({ amount: req.body.amount, email: req.body.email });
873
+ },
874
+ mapResult: createResultMapper([
875
+ { error: 'CARD_DECLINED', status: 402, message: 'Payment failed' },
876
+ { error: 'INVALID_EMAIL', status: 400, message: 'Invalid email address' },
877
+ ]),
878
+ }
879
+ );
880
+
881
+ // Use with Express
882
+ import express from 'express';
883
+ const app = express();
884
+ app.post('/checkout', createExpressHandler(handler));
885
+
886
+ // Or manually
887
+ app.post('/checkout', async (req, res) => {
888
+ const response = await handler({
889
+ method: req.method,
890
+ path: req.path,
891
+ headers: req.headers,
892
+ body: req.body,
893
+ query: req.query,
894
+ params: req.params,
895
+ });
896
+ res.status(response.status).json(response.body);
897
+ });
898
+ ```
899
+
900
+ ### Event Triggers (for message queues)
901
+
902
+ ```typescript
903
+ import { createEventHandler } from 'awaitly/webhook';
904
+
905
+ const handler = createEventHandler(
906
+ checkoutWorkflow,
907
+ async (step, deps, payload: CheckoutPayload) => {
908
+ const charge = await step(() => deps.chargeCard(payload.amount));
909
+ return { chargeId: charge.id };
910
+ },
911
+ {
912
+ validatePayload: (event) => {
913
+ if (!event.payload.amount) {
914
+ return err(validationError('Missing amount'));
915
+ }
916
+ return ok(event.payload);
917
+ },
918
+ mapResult: (result) => ({
919
+ success: result.ok,
920
+ ack: result.ok || !isRetryableError(result.error),
921
+ error: result.ok ? undefined : { type: String(result.error) },
922
+ }),
923
+ }
924
+ );
925
+
926
+ // Use with SQS, RabbitMQ, etc.
927
+ queue.consume(async (message) => {
928
+ const result = await handler({
929
+ id: message.id,
930
+ type: message.type,
931
+ payload: message.body,
932
+ });
933
+ if (result.ack) await message.ack();
934
+ else await message.nack();
935
+ });
936
+ ```
937
+
938
+ ## Policy-Driven Step Middleware
939
+
940
+ Reusable bundles of `StepOptions` (retry, timeout, cache keys) that can be composed and applied per-workflow or per-step:
941
+
942
+ ```typescript
943
+ import {
944
+ mergePolicies,
945
+ createPolicyApplier,
946
+ withPolicy,
947
+ withPolicies,
948
+ retryPolicies,
949
+ timeoutPolicies,
950
+ servicePolicies,
951
+ createPolicyRegistry,
952
+ stepOptions,
953
+ } from 'awaitly/policies';
954
+
955
+ // Use pre-built service policies
956
+ const user = await step(
957
+ () => fetchUser(id),
958
+ withPolicy(servicePolicies.httpApi, { name: 'fetch-user' })
959
+ );
960
+
961
+ // Combine multiple policies
962
+ const data = await step(
963
+ () => fetchData(),
964
+ withPolicies([timeoutPolicies.api, retryPolicies.standard], 'fetch-data')
965
+ );
966
+
967
+ // Create a policy applier for consistent defaults
968
+ const applyPolicy = createPolicyApplier(
969
+ timeoutPolicies.api,
970
+ retryPolicies.transient
971
+ );
972
+
973
+ const result = await step(
974
+ () => callApi(),
975
+ applyPolicy({ name: 'api-call', key: 'cache:api' })
976
+ );
977
+
978
+ // Use the fluent builder API
979
+ const options = stepOptions()
980
+ .name('fetch-user')
981
+ .key('user:123')
982
+ .timeout(5000)
983
+ .retries(3)
984
+ .build();
985
+
986
+ // Create a policy registry for organization-wide policies
987
+ const registry = createPolicyRegistry();
988
+ registry.register('api', servicePolicies.httpApi);
989
+ registry.register('db', servicePolicies.database);
990
+ registry.register('cache', servicePolicies.cache);
991
+
992
+ const user = await step(
993
+ () => fetchUser(id),
994
+ registry.apply('api', { name: 'fetch-user' })
995
+ );
996
+ ```
997
+
998
+ ### Available Presets
999
+
1000
+ ```typescript
1001
+ // Retry policies
1002
+ retryPolicies.none // No retry
1003
+ retryPolicies.transient // 3 attempts, fast backoff
1004
+ retryPolicies.standard // 3 attempts, moderate backoff
1005
+ retryPolicies.aggressive // 5 attempts, longer backoff
1006
+ retryPolicies.fixed(3, 1000) // 3 attempts, 1s fixed delay
1007
+ retryPolicies.linear(3, 100) // 3 attempts, linear backoff
1008
+
1009
+ // Timeout policies
1010
+ timeoutPolicies.fast // 1 second
1011
+ timeoutPolicies.api // 5 seconds
1012
+ timeoutPolicies.extended // 30 seconds
1013
+ timeoutPolicies.long // 2 minutes
1014
+ timeoutPolicies.ms(3000) // Custom milliseconds
1015
+
1016
+ // Service policies (combined retry + timeout)
1017
+ servicePolicies.httpApi // 5s timeout, 3 retries
1018
+ servicePolicies.database // 30s timeout, 2 retries
1019
+ servicePolicies.cache // 1s timeout, no retry
1020
+ servicePolicies.messageQueue // 30s timeout, 5 retries
1021
+ servicePolicies.fileSystem // 2min timeout, 3 retries
1022
+ servicePolicies.rateLimited // 10s timeout, 5 linear retries
1023
+ ```
1024
+
1025
+ ## Save & Resume Workflows
1026
+
1027
+ Persist workflow state to a database and resume later from exactly where you left off. Perfect for crash recovery, long-running workflows, or pausing for approvals.
1028
+
1029
+ ### Quick Start: Collect, Save, Resume
1030
+
1031
+ The easiest way to save and resume workflows is using `createStepCollector()`:
1032
+
1033
+ ```typescript
1034
+ import { createWorkflow, createStepCollector } from 'awaitly';
1035
+ import { stringifyState, parseState } from 'awaitly/persistence';
1036
+
1037
+ // 1. Collect state during execution
1038
+ const collector = createStepCollector();
1039
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
1040
+ onEvent: collector.handleEvent, // Automatically collects step_complete events
1041
+ });
1042
+
1043
+ await workflow(async (step) => {
1044
+ // Only steps with keys are saved
1045
+ const user = await step(() => fetchUser("1"), { key: "user:1" });
1046
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
1047
+ return { user, posts };
1048
+ });
1049
+
1050
+ // 2. Get collected state
1051
+ const state = collector.getState();
1052
+
1053
+ // 3. Save to database
1054
+ const json = stringifyState(state, { workflowId: "123", timestamp: Date.now() });
1055
+ await db.workflowStates.create({ id: "123", state: json });
1056
+
1057
+ // 4. Resume later
1058
+ const saved = await db.workflowStates.findUnique({ where: { id: "123" } });
1059
+ const savedState = parseState(saved.state);
1060
+
1061
+ const resumed = createWorkflow({ fetchUser, fetchPosts }, {
1062
+ resumeState: savedState, // Pre-populates cache from saved state
1063
+ });
1064
+
1065
+ // Cached steps skip execution automatically
1066
+ await resumed(async (step) => {
1067
+ const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
1068
+ const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
1069
+ return { user, posts };
1070
+ });
1071
+ ```
1072
+
1073
+ ### Why Use `createStepCollector()`?
1074
+
1075
+ - **Automatic filtering**: Only collects `step_complete` events (ignores other events)
1076
+ - **Metadata preservation**: Captures both result and meta for proper error replay
1077
+ - **Type-safe**: Returns properly typed `ResumeState`
1078
+ - **Convenient API**: Simple `handleEvent` → `getState` pattern
1079
+
1080
+ ### Manual Collection (Advanced)
1081
+
1082
+ If you need custom filtering or processing, you can collect events manually:
1083
+
1084
+ ```typescript
1085
+ import { createWorkflow, isStepComplete, type ResumeStateEntry } from 'awaitly';
1086
+
1087
+ const savedSteps = new Map<string, ResumeStateEntry>();
1088
+ const workflow = createWorkflow(deps, {
1089
+ onEvent: (event) => {
1090
+ if (isStepComplete(event)) {
1091
+ // Custom filtering or processing
1092
+ if (event.stepKey.startsWith('important:')) {
1093
+ savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
1094
+ }
1095
+ }
1096
+ },
1097
+ });
1098
+ ```
1099
+
1100
+ ## Pluggable Persistence Adapters
1101
+
1102
+ First-class adapters for `StepCache` and `ResumeState` with JSON-safe serialization:
1103
+
1104
+ ```typescript
1105
+ import {
1106
+ createMemoryCache,
1107
+ createFileCache,
1108
+ createKVCache,
1109
+ createStatePersistence,
1110
+ createHydratingCache,
1111
+ stringifyState,
1112
+ parseState,
1113
+ } from 'awaitly/persistence';
1114
+
1115
+ // In-memory cache with TTL and LRU eviction
1116
+ const cache = createMemoryCache({
1117
+ maxSize: 1000, // Max entries
1118
+ ttl: 60000, // 1 minute TTL
1119
+ });
1120
+
1121
+ const workflow = createWorkflow(deps, { cache });
1122
+
1123
+ // File-based persistence
1124
+ import * as fs from 'fs/promises';
1125
+
1126
+ const fileCache = createFileCache({
1127
+ directory: './workflow-cache',
1128
+ fs: {
1129
+ readFile: (path) => fs.readFile(path, 'utf-8'),
1130
+ writeFile: (path, data) => fs.writeFile(path, data, 'utf-8'),
1131
+ unlink: fs.unlink,
1132
+ exists: async (path) => fs.access(path).then(() => true).catch(() => false),
1133
+ readdir: fs.readdir,
1134
+ mkdir: fs.mkdir,
1135
+ },
1136
+ });
1137
+
1138
+ await fileCache.init();
1139
+
1140
+ // Redis/DynamoDB adapter
1141
+ const kvCache = createKVCache({
1142
+ store: {
1143
+ get: (key) => redis.get(key),
1144
+ set: (key, value, opts) => redis.set(key, value, { EX: opts?.ttl }),
1145
+ delete: (key) => redis.del(key).then(n => n > 0),
1146
+ exists: (key) => redis.exists(key).then(n => n > 0),
1147
+ keys: (pattern) => redis.keys(pattern),
1148
+ },
1149
+ prefix: 'myapp:workflow:',
1150
+ ttl: 3600, // 1 hour
1151
+ });
1152
+
1153
+ // State persistence for workflow resumption
1154
+ const persistence = createStatePersistence({
1155
+ get: (key) => redis.get(key),
1156
+ set: (key, value) => redis.set(key, value),
1157
+ delete: (key) => redis.del(key).then(n => n > 0),
1158
+ exists: (key) => redis.exists(key).then(n => n > 0),
1159
+ keys: (pattern) => redis.keys(pattern),
1160
+ }, 'workflow:state:');
1161
+
1162
+ // Save workflow state
1163
+ const collector = createStepCollector();
1164
+ const workflow = createWorkflow(deps, { onEvent: collector.handleEvent });
1165
+ await workflow(async (step) => { /* ... */ });
1166
+
1167
+ await persistence.save('run-123', collector.getState(), { userId: 'user-1' });
1168
+
1169
+ // Load and resume
1170
+ const loaded = await persistence.load('run-123');
1171
+ const allRuns = await persistence.list();
1172
+
1173
+ // Hydrating cache (loads from persistent storage on first access)
1174
+ const hydratingCache = createHydratingCache(
1175
+ createMemoryCache(),
1176
+ persistence,
1177
+ 'run-123'
1178
+ );
1179
+ await hydratingCache.hydrate();
1180
+ ```
1181
+
1182
+ ### JSON-safe Serialization
1183
+
1184
+ ```typescript
1185
+ import {
1186
+ serializeResult,
1187
+ deserializeResult,
1188
+ serializeState,
1189
+ deserializeState,
1190
+ stringifyState,
1191
+ parseState,
1192
+ } from 'awaitly/persistence';
1193
+
1194
+ // Serialize Results with Error causes preserved
1195
+ const serialized = serializeResult(result);
1196
+ const restored = deserializeResult(serialized);
1197
+
1198
+ // Serialize entire workflow state
1199
+ const json = stringifyState(resumeState, { userId: 'user-1' });
1200
+ const state = parseState(json);
1201
+ ```
1202
+
1203
+ ### Database Integration Patterns
1204
+
1205
+ **PostgreSQL/MySQL with Prisma:**
1206
+
1207
+ ```typescript
1208
+ // Save
1209
+ const state = collector.getState();
1210
+ const json = stringifyState(state, { workflowId: runId });
1211
+ await prisma.workflowState.upsert({
1212
+ where: { runId },
1213
+ update: { state: json, updatedAt: new Date() },
1214
+ create: { runId, state: json },
1215
+ });
1216
+
1217
+ // Load
1218
+ const saved = await prisma.workflowState.findUnique({ where: { runId } });
1219
+ const savedState = parseState(saved.state);
1220
+ ```
1221
+
1222
+ **DynamoDB:**
1223
+
1224
+ ```typescript
1225
+ const persistence = createStatePersistence({
1226
+ get: async (key) => {
1227
+ const result = await dynamodb.get({ TableName: 'workflows', Key: { id: key } });
1228
+ return result.Item?.state || null;
1229
+ },
1230
+ set: async (key, value) => {
1231
+ await dynamodb.put({ TableName: 'workflows', Item: { id: key, state: value } });
1232
+ },
1233
+ delete: async (key) => {
1234
+ await dynamodb.delete({ TableName: 'workflows', Key: { id: key } });
1235
+ return true;
1236
+ },
1237
+ exists: async (key) => {
1238
+ const result = await dynamodb.get({ TableName: 'workflows', Key: { id: key } });
1239
+ return !!result.Item;
1240
+ },
1241
+ keys: async (pattern) => {
1242
+ // Implement pattern matching for your use case
1243
+ const result = await dynamodb.scan({ TableName: 'workflows' });
1244
+ return result.Items?.map(item => item.id) || [];
1245
+ },
1246
+ }, 'workflow:state:');
1247
+ ```
1248
+
1249
+ ## Devtools
1250
+
1251
+ Developer tools for workflow debugging, visualization, and analysis:
1252
+
1253
+ ```typescript
1254
+ import {
1255
+ createDevtools,
1256
+ renderDiff,
1257
+ createConsoleLogger,
1258
+ quickVisualize,
1259
+ } from 'awaitly/devtools';
1260
+
1261
+ // Create devtools instance
1262
+ const devtools = createDevtools({
1263
+ workflowName: 'checkout',
1264
+ logEvents: true,
1265
+ maxHistory: 10,
1266
+ });
1267
+
1268
+ // Use with workflow
1269
+ const workflow = createWorkflow(deps, {
1270
+ onEvent: devtools.handleEvent,
1271
+ });
1272
+
1273
+ await workflow(async (step) => {
1274
+ const user = await step(() => fetchUser(id), { name: 'fetch-user' });
1275
+ const charge = await step(() => chargeCard(100), { name: 'charge-card' });
1276
+ return { user, charge };
1277
+ });
1278
+
1279
+ // Render visualizations
1280
+ console.log(devtools.render()); // ASCII visualization
1281
+ console.log(devtools.renderMermaid()); // Mermaid diagram
1282
+ console.log(devtools.renderTimeline()); // Timeline view
1283
+
1284
+ // Get timeline data
1285
+ const timeline = devtools.getTimeline();
1286
+ // [{ name: 'fetch-user', startMs: 0, endMs: 50, status: 'success' }, ...]
1287
+
1288
+ // Compare runs
1289
+ const diff = devtools.diffWithPrevious();
1290
+ if (diff) {
1291
+ console.log(renderDiff(diff));
1292
+ }
1293
+
1294
+ // Export/import runs
1295
+ const json = devtools.exportRun();
1296
+ devtools.importRun(json);
1297
+
1298
+ // Simple console logging
1299
+ const workflow2 = createWorkflow(deps, {
1300
+ onEvent: createConsoleLogger({ prefix: '[checkout]', colors: true }),
1301
+ });
1302
+ ```
1303
+
1304
+ ### Timeline Output Example
1305
+
1306
+ ```
1307
+ Timeline:
1308
+ ────────────────────────────────────────────────────────────────
1309
+ fetch-user |██████ | 50ms
1310
+ charge-card | ████████████ | 120ms
1311
+ send-email | ████ | 30ms
1312
+ ────────────────────────────────────────────────────────────────
1313
+ ```
1314
+
1315
+ ## HITL Orchestration Helpers
1316
+
1317
+ Production-ready helpers for human-in-the-loop approval workflows:
1318
+
1319
+ ```typescript
1320
+ import {
1321
+ createHITLOrchestrator,
1322
+ createMemoryApprovalStore,
1323
+ createMemoryWorkflowStateStore,
1324
+ createApprovalWebhookHandler,
1325
+ createApprovalChecker,
1326
+ } from 'awaitly/hitl';
1327
+
1328
+ // Create orchestrator with stores
1329
+ const orchestrator = createHITLOrchestrator({
1330
+ approvalStore: createMemoryApprovalStore(),
1331
+ workflowStateStore: createMemoryWorkflowStateStore(),
1332
+ defaultExpirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
1333
+ });
1334
+
1335
+ // Execute workflow that may pause for approval
1336
+ // IMPORTANT: The factory must pass onEvent to createWorkflow for HITL tracking!
1337
+ const result = await orchestrator.execute(
1338
+ 'order-approval',
1339
+ ({ resumeState, onEvent }) => createWorkflow(deps, { resumeState, onEvent }),
1340
+ async (step, deps, input) => {
1341
+ const order = await step(() => deps.createOrder(input));
1342
+ const approval = await step(
1343
+ () => deps.requireApproval(order.id),
1344
+ { key: `approval:${order.id}` }
1345
+ );
1346
+ await step(() => deps.processOrder(order.id));
1347
+ return { orderId: order.id };
1348
+ },
1349
+ { items: [...], total: 500 }
1350
+ );
1351
+
1352
+ if (result.status === 'paused') {
1353
+ console.log(`Workflow paused, waiting for: ${result.pendingApprovals}`);
1354
+ console.log(`Run ID: ${result.runId}`);
1355
+ }
1356
+
1357
+ // Grant approval (with optional auto-resume)
1358
+ const { resumedWorkflows } = await orchestrator.grantApproval(
1359
+ `approval:${orderId}`,
1360
+ { approvedBy: 'manager@example.com' },
1361
+ { autoResume: true }
1362
+ );
1363
+
1364
+ // Or poll for approval
1365
+ const status = await orchestrator.pollApproval(`approval:${orderId}`, {
1366
+ intervalMs: 1000,
1367
+ timeoutMs: 60000,
1368
+ });
1369
+
1370
+ // Resume manually
1371
+ const resumed = await orchestrator.resume(
1372
+ runId,
1373
+ (resumeState) => createWorkflow(deps, { resumeState }),
1374
+ workflowFn
1375
+ );
1376
+ ```
1377
+
1378
+ ### Webhook Handler for Approvals
1379
+
1380
+ ```typescript
1381
+ import { createApprovalWebhookHandler } from 'awaitly/hitl';
1382
+ import express from 'express';
1383
+
1384
+ const handleApproval = createApprovalWebhookHandler(approvalStore);
1385
+
1386
+ const app = express();
1387
+ app.post('/api/approvals', async (req, res) => {
1388
+ const result = await handleApproval({
1389
+ key: req.body.key,
1390
+ action: req.body.action, // 'approve' | 'reject' | 'cancel'
1391
+ value: req.body.value,
1392
+ reason: req.body.reason,
1393
+ actorId: req.user.id,
1394
+ });
1395
+ res.json(result);
1396
+ });
1397
+ ```
1398
+
1399
+ ## Deterministic Workflow Testing Harness
1400
+
1401
+ Test workflows with scripted step outcomes:
1402
+
1403
+ ```typescript
1404
+ import {
1405
+ createWorkflowHarness,
1406
+ createMockFn,
1407
+ createTestClock,
1408
+ createSnapshot,
1409
+ compareSnapshots,
1410
+ okOutcome,
1411
+ errOutcome,
1412
+ } from 'awaitly/testing';
1413
+
1414
+ // Create test harness
1415
+ const harness = createWorkflowHarness(
1416
+ { fetchUser, chargeCard },
1417
+ { clock: createTestClock().now }
1418
+ );
1419
+
1420
+ // Script step outcomes
1421
+ harness.script([
1422
+ okOutcome({ id: '1', name: 'Alice' }),
1423
+ okOutcome({ transactionId: 'tx_123' }),
1424
+ ]);
1425
+
1426
+ // Or script specific steps by name
1427
+ harness.scriptStep('fetch-user', okOutcome({ id: '1', name: 'Alice' }));
1428
+ harness.scriptStep('charge-card', errOutcome('CARD_DECLINED'));
1429
+
1430
+ // Run workflow
1431
+ const result = await harness.run(async (step, { fetchUser, chargeCard }) => {
1432
+ const user = await step(() => fetchUser('1'), 'fetch-user');
1433
+ const charge = await step(() => chargeCard(100), 'charge-card');
1434
+ return { user, charge };
1435
+ });
1436
+
1437
+ // Assert results
1438
+ expect(result.ok).toBe(false);
1439
+ expect(harness.assertSteps(['fetch-user', 'charge-card']).passed).toBe(true);
1440
+ expect(harness.assertStepCalled('fetch-user').passed).toBe(true);
1441
+ expect(harness.assertStepNotCalled('refund').passed).toBe(true);
1442
+
1443
+ // Get invocation details
1444
+ const invocations = harness.getInvocations();
1445
+ console.log(invocations[0].name); // 'fetch-user'
1446
+ console.log(invocations[0].durationMs); // 0 (deterministic clock)
1447
+ console.log(invocations[0].result); // { ok: true, value: { id: '1', name: 'Alice' } }
1448
+
1449
+ // Reset for next test
1450
+ harness.reset();
1451
+ ```
1452
+
1453
+ ### Mock Functions
1454
+
1455
+ ```typescript
1456
+ import { createMockFn } from 'awaitly/testing'; import { ok, err } from 'awaitly';
1457
+
1458
+ const fetchUser = createMockFn<User, 'NOT_FOUND'>();
1459
+
1460
+ // Set default return
1461
+ fetchUser.returns(ok({ id: '1', name: 'Alice' }));
1462
+
1463
+ // Or queue return values
1464
+ fetchUser.returnsOnce(ok({ id: '1', name: 'Alice' }));
1465
+ fetchUser.returnsOnce(err('NOT_FOUND'));
1466
+
1467
+ // Check calls
1468
+ console.log(fetchUser.getCallCount()); // 2
1469
+ console.log(fetchUser.getCalls()); // [[arg1], [arg2]]
1470
+
1471
+ fetchUser.reset();
1472
+ ```
1473
+
1474
+ ### Snapshot Testing
1475
+
1476
+ ```typescript
1477
+ import { createSnapshot, compareSnapshots } from 'awaitly/testing';
1478
+
1479
+ // Create snapshot from a run (events are optional, from external sources)
1480
+ const snapshot1 = createSnapshot(
1481
+ harness.getInvocations(),
1482
+ result
1483
+ );
1484
+
1485
+ // Run again and compare
1486
+ harness.reset();
1487
+ harness.script([...newOutcomes]); // script() resets state automatically
1488
+ const result2 = await harness.run(workflowFn);
1489
+ const snapshot2 = createSnapshot(harness.getInvocations(), result2);
1490
+
1491
+ const { equal, differences } = compareSnapshots(snapshot1, snapshot2);
1492
+ if (!equal) {
1493
+ console.log('Differences:', differences);
1494
+ }
1495
+ ```
1496
+
1497
+ ## OpenTelemetry Integration (Autotel)
1498
+
1499
+ First-class OpenTelemetry metrics from the event stream:
1500
+
1501
+ ```typescript
1502
+ import { createAutotelAdapter, createAutotelEventHandler, withAutotelTracing } from 'awaitly/otel';
1503
+
1504
+ // Create an adapter that tracks metrics
1505
+ const autotel = createAutotelAdapter({
1506
+ serviceName: 'checkout-service',
1507
+ createStepSpans: true, // Create spans for each step
1508
+ recordMetrics: true, // Record step metrics
1509
+ recordRetryEvents: true, // Record retry events
1510
+ markErrorsOnSpan: true, // Mark errors on spans
1511
+ defaultAttributes: { // Custom attributes for all spans
1512
+ environment: 'production',
1513
+ },
1514
+ });
1515
+
1516
+ // Use adapter's handleEvent directly with workflow
1517
+ const workflow = createWorkflow(deps, {
1518
+ onEvent: autotel.handleEvent,
1519
+ });
1520
+
1521
+ // Access collected metrics
1522
+ const metrics = autotel.getMetrics();
1523
+ console.log(metrics.stepDurations); // Array of { name, durationMs, success }
1524
+ console.log(metrics.retryCount); // Total retry count
1525
+ console.log(metrics.errorCount); // Total error count
1526
+ console.log(metrics.cacheHits); // Cache hit count
1527
+ console.log(metrics.cacheMisses); // Cache miss count
1528
+
1529
+ // Or use the simpler event handler for debug logging
1530
+ const workflow2 = createWorkflow(deps, {
1531
+ onEvent: createAutotelEventHandler({
1532
+ serviceName: 'checkout',
1533
+ includeStepDetails: true,
1534
+ }),
1535
+ });
1536
+ // Set AUTOTEL_DEBUG=true to see console output
1537
+
1538
+ // Wrap with autotel tracing for actual OpenTelemetry spans
1539
+ import { trace } from 'autotel';
1540
+
1541
+ const traced = withAutotelTracing(trace, { serviceName: 'checkout' });
1542
+
1543
+ const result = await traced('process-order', async () => {
1544
+ return workflow(async (step) => {
1545
+ // ... workflow logic
1546
+ });
1547
+ }, { orderId: '123' }); // Optional attributes
1548
+ ```