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.
- package/LICENSE +21 -0
- package/README.md +1278 -0
- package/dist/batch.cjs +2 -0
- package/dist/batch.cjs.map +1 -0
- package/dist/batch.d.cts +197 -0
- package/dist/batch.d.ts +197 -0
- package/dist/batch.js +2 -0
- package/dist/batch.js.map +1 -0
- package/dist/circuit-breaker.cjs +2 -0
- package/dist/circuit-breaker.cjs.map +1 -0
- package/dist/circuit-breaker.d.cts +208 -0
- package/dist/circuit-breaker.d.ts +208 -0
- package/dist/circuit-breaker.js +2 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/conditional.cjs +2 -0
- package/dist/conditional.cjs.map +1 -0
- package/dist/conditional.d.cts +249 -0
- package/dist/conditional.d.ts +249 -0
- package/dist/conditional.js +2 -0
- package/dist/conditional.js.map +1 -0
- package/dist/core-BuTBsR0x.d.cts +2325 -0
- package/dist/core-BuTBsR0x.d.ts +2325 -0
- package/dist/core.cjs +2 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +3 -0
- package/dist/core.d.ts +3 -0
- package/dist/core.js +2 -0
- package/dist/core.js.map +1 -0
- package/dist/devtools.cjs +11 -0
- package/dist/devtools.cjs.map +1 -0
- package/dist/devtools.d.cts +176 -0
- package/dist/devtools.d.ts +176 -0
- package/dist/devtools.js +11 -0
- package/dist/devtools.js.map +1 -0
- package/dist/duration.cjs +2 -0
- package/dist/duration.cjs.map +1 -0
- package/dist/duration.d.cts +246 -0
- package/dist/duration.d.ts +246 -0
- package/dist/duration.js +2 -0
- package/dist/duration.js.map +1 -0
- package/dist/hitl.cjs +2 -0
- package/dist/hitl.cjs.map +1 -0
- package/dist/hitl.d.cts +337 -0
- package/dist/hitl.d.ts +337 -0
- package/dist/hitl.js +2 -0
- package/dist/hitl.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/match.cjs +2 -0
- package/dist/match.cjs.map +1 -0
- package/dist/match.d.cts +209 -0
- package/dist/match.d.ts +209 -0
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -0
- package/dist/otel.cjs +2 -0
- package/dist/otel.cjs.map +1 -0
- package/dist/otel.d.cts +185 -0
- package/dist/otel.d.ts +185 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/persistence.cjs +2 -0
- package/dist/persistence.cjs.map +1 -0
- package/dist/persistence.d.cts +572 -0
- package/dist/persistence.d.ts +572 -0
- package/dist/persistence.js +2 -0
- package/dist/persistence.js.map +1 -0
- package/dist/policies.cjs +2 -0
- package/dist/policies.cjs.map +1 -0
- package/dist/policies.d.cts +378 -0
- package/dist/policies.d.ts +378 -0
- package/dist/policies.js +2 -0
- package/dist/policies.js.map +1 -0
- package/dist/ratelimit.cjs +2 -0
- package/dist/ratelimit.cjs.map +1 -0
- package/dist/ratelimit.d.cts +279 -0
- package/dist/ratelimit.d.ts +279 -0
- package/dist/ratelimit.js +2 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/reliability.cjs +2 -0
- package/dist/reliability.cjs.map +1 -0
- package/dist/reliability.d.cts +5 -0
- package/dist/reliability.d.ts +5 -0
- package/dist/reliability.js +2 -0
- package/dist/reliability.js.map +1 -0
- package/dist/resource.cjs +2 -0
- package/dist/resource.cjs.map +1 -0
- package/dist/resource.d.cts +171 -0
- package/dist/resource.d.ts +171 -0
- package/dist/resource.js +2 -0
- package/dist/resource.js.map +1 -0
- package/dist/retry.cjs +2 -0
- package/dist/retry.cjs.map +1 -0
- package/dist/retry.d.cts +2 -0
- package/dist/retry.d.ts +2 -0
- package/dist/retry.js +2 -0
- package/dist/retry.js.map +1 -0
- package/dist/saga.cjs +2 -0
- package/dist/saga.cjs.map +1 -0
- package/dist/saga.d.cts +231 -0
- package/dist/saga.d.ts +231 -0
- package/dist/saga.js +2 -0
- package/dist/saga.js.map +1 -0
- package/dist/schedule.cjs +2 -0
- package/dist/schedule.cjs.map +1 -0
- package/dist/schedule.d.cts +387 -0
- package/dist/schedule.d.ts +387 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/dist/tagged-error.cjs +2 -0
- package/dist/tagged-error.cjs.map +1 -0
- package/dist/tagged-error.d.cts +252 -0
- package/dist/tagged-error.d.ts +252 -0
- package/dist/tagged-error.js +2 -0
- package/dist/tagged-error.js.map +1 -0
- package/dist/testing.cjs +2 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +228 -0
- package/dist/testing.d.ts +228 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/visualize.cjs +1573 -0
- package/dist/visualize.cjs.map +1 -0
- package/dist/visualize.d.cts +1415 -0
- package/dist/visualize.d.ts +1415 -0
- package/dist/visualize.js +1573 -0
- package/dist/visualize.js.map +1 -0
- package/dist/webhook.cjs +2 -0
- package/dist/webhook.cjs.map +1 -0
- package/dist/webhook.d.cts +469 -0
- package/dist/webhook.d.ts +469 -0
- package/dist/webhook.js +2 -0
- package/dist/webhook.js.map +1 -0
- package/dist/workflow-entry-C6nH8ByN.d.ts +858 -0
- package/dist/workflow-entry-RRTlSg_4.d.cts +858 -0
- package/dist/workflow.cjs +2 -0
- package/dist/workflow.cjs.map +1 -0
- package/dist/workflow.d.cts +2 -0
- package/dist/workflow.d.ts +2 -0
- package/dist/workflow.js +2 -0
- package/dist/workflow.js.map +1 -0
- package/docs/advanced.md +1548 -0
- package/docs/api.md +513 -0
- package/docs/coming-from-neverthrow.md +1013 -0
- package/docs/match.md +417 -0
- package/docs/pino-logging-example.md +396 -0
- package/docs/policies.md +508 -0
- package/docs/resource-management.md +509 -0
- package/docs/schedule.md +467 -0
- package/docs/tagged-error.md +785 -0
- package/docs/visualization.md +430 -0
- package/docs/visualize-examples.md +330 -0
- package/package.json +227 -0
package/docs/advanced.md
ADDED
|
@@ -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
|
+
```
|