@jagreehal/workflow 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -19
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +60 -7
- package/dist/core.d.ts +60 -7
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/visualize.cjs +1188 -6
- package/dist/visualize.cjs.map +1 -1
- package/dist/visualize.d.cts +467 -1
- package/dist/visualize.d.ts +467 -1
- package/dist/visualize.js +1188 -6
- package/dist/visualize.js.map +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +50 -0
- package/dist/workflow.d.ts +50 -0
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- package/docs/advanced.md +368 -3
- package/package.json +1 -1
package/docs/advanced.md
CHANGED
|
@@ -26,6 +26,30 @@ const { values, errors } = partition(results);
|
|
|
26
26
|
|
|
27
27
|
Async versions: `allAsync`, `allSettledAsync`, `anyAsync`.
|
|
28
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
|
+
|
|
29
53
|
## Dynamic error mapping
|
|
30
54
|
|
|
31
55
|
Use `{ onError }` instead of `{ error }` to create errors from the caught value:
|
|
@@ -233,6 +257,214 @@ const result = await run.strict<User, AppError>(
|
|
|
233
257
|
|
|
234
258
|
Prefer `createWorkflow` for automatic error type inference.
|
|
235
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
|
+
|
|
236
468
|
## Circuit Breaker
|
|
237
469
|
|
|
238
470
|
Prevent cascading failures by tracking step failure rates and short-circuiting calls when a threshold is exceeded:
|
|
@@ -737,12 +969,86 @@ servicePolicies.fileSystem // 2min timeout, 3 retries
|
|
|
737
969
|
servicePolicies.rateLimited // 10s timeout, 5 linear retries
|
|
738
970
|
```
|
|
739
971
|
|
|
972
|
+
## Save & Resume Workflows
|
|
973
|
+
|
|
974
|
+
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.
|
|
975
|
+
|
|
976
|
+
### Quick Start: Collect, Save, Resume
|
|
977
|
+
|
|
978
|
+
The easiest way to save and resume workflows is using `createStepCollector()`:
|
|
979
|
+
|
|
980
|
+
```typescript
|
|
981
|
+
import { createWorkflow, createStepCollector, stringifyState, parseState } from '@jagreehal/workflow';
|
|
982
|
+
|
|
983
|
+
// 1. Collect state during execution
|
|
984
|
+
const collector = createStepCollector();
|
|
985
|
+
const workflow = createWorkflow({ fetchUser, fetchPosts }, {
|
|
986
|
+
onEvent: collector.handleEvent, // Automatically collects step_complete events
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
await workflow(async (step) => {
|
|
990
|
+
// Only steps with keys are saved
|
|
991
|
+
const user = await step(() => fetchUser("1"), { key: "user:1" });
|
|
992
|
+
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` });
|
|
993
|
+
return { user, posts };
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// 2. Get collected state
|
|
997
|
+
const state = collector.getState();
|
|
998
|
+
|
|
999
|
+
// 3. Save to database
|
|
1000
|
+
const json = stringifyState(state, { workflowId: "123", timestamp: Date.now() });
|
|
1001
|
+
await db.workflowStates.create({ id: "123", state: json });
|
|
1002
|
+
|
|
1003
|
+
// 4. Resume later
|
|
1004
|
+
const saved = await db.workflowStates.findUnique({ where: { id: "123" } });
|
|
1005
|
+
const savedState = parseState(saved.state);
|
|
1006
|
+
|
|
1007
|
+
const resumed = createWorkflow({ fetchUser, fetchPosts }, {
|
|
1008
|
+
resumeState: savedState, // Pre-populates cache from saved state
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// Cached steps skip execution automatically
|
|
1012
|
+
await resumed(async (step) => {
|
|
1013
|
+
const user = await step(() => fetchUser("1"), { key: "user:1" }); // ✅ Cache hit
|
|
1014
|
+
const posts = await step(() => fetchPosts(user.id), { key: `posts:${user.id}` }); // ✅ Cache hit
|
|
1015
|
+
return { user, posts };
|
|
1016
|
+
});
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
### Why Use `createStepCollector()`?
|
|
1020
|
+
|
|
1021
|
+
- **Automatic filtering**: Only collects `step_complete` events (ignores other events)
|
|
1022
|
+
- **Metadata preservation**: Captures both result and meta for proper error replay
|
|
1023
|
+
- **Type-safe**: Returns properly typed `ResumeState`
|
|
1024
|
+
- **Convenient API**: Simple `handleEvent` → `getState` pattern
|
|
1025
|
+
|
|
1026
|
+
### Manual Collection (Advanced)
|
|
1027
|
+
|
|
1028
|
+
If you need custom filtering or processing, you can collect events manually:
|
|
1029
|
+
|
|
1030
|
+
```typescript
|
|
1031
|
+
import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
|
|
1032
|
+
|
|
1033
|
+
const savedSteps = new Map<string, ResumeStateEntry>();
|
|
1034
|
+
const workflow = createWorkflow(deps, {
|
|
1035
|
+
onEvent: (event) => {
|
|
1036
|
+
if (isStepComplete(event)) {
|
|
1037
|
+
// Custom filtering or processing
|
|
1038
|
+
if (event.stepKey.startsWith('important:')) {
|
|
1039
|
+
savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
```
|
|
1045
|
+
|
|
740
1046
|
## Pluggable Persistence Adapters
|
|
741
1047
|
|
|
742
1048
|
First-class adapters for `StepCache` and `ResumeState` with JSON-safe serialization:
|
|
743
1049
|
|
|
744
1050
|
```typescript
|
|
745
|
-
import {
|
|
1051
|
+
import {
|
|
746
1052
|
createMemoryCache,
|
|
747
1053
|
createFileCache,
|
|
748
1054
|
createKVCache,
|
|
@@ -791,9 +1097,22 @@ const kvCache = createKVCache({
|
|
|
791
1097
|
});
|
|
792
1098
|
|
|
793
1099
|
// State persistence for workflow resumption
|
|
794
|
-
const persistence = createStatePersistence(
|
|
1100
|
+
const persistence = createStatePersistence({
|
|
1101
|
+
get: (key) => redis.get(key),
|
|
1102
|
+
set: (key, value) => redis.set(key, value),
|
|
1103
|
+
delete: (key) => redis.del(key).then(n => n > 0),
|
|
1104
|
+
exists: (key) => redis.exists(key).then(n => n > 0),
|
|
1105
|
+
keys: (pattern) => redis.keys(pattern),
|
|
1106
|
+
}, 'workflow:state:');
|
|
1107
|
+
|
|
1108
|
+
// Save workflow state
|
|
1109
|
+
const collector = createStepCollector();
|
|
1110
|
+
const workflow = createWorkflow(deps, { onEvent: collector.handleEvent });
|
|
1111
|
+
await workflow(async (step) => { /* ... */ });
|
|
1112
|
+
|
|
1113
|
+
await persistence.save('run-123', collector.getState(), { userId: 'user-1' });
|
|
795
1114
|
|
|
796
|
-
|
|
1115
|
+
// Load and resume
|
|
797
1116
|
const loaded = await persistence.load('run-123');
|
|
798
1117
|
const allRuns = await persistence.list();
|
|
799
1118
|
|
|
@@ -827,6 +1146,52 @@ const json = stringifyState(resumeState, { userId: 'user-1' });
|
|
|
827
1146
|
const state = parseState(json);
|
|
828
1147
|
```
|
|
829
1148
|
|
|
1149
|
+
### Database Integration Patterns
|
|
1150
|
+
|
|
1151
|
+
**PostgreSQL/MySQL with Prisma:**
|
|
1152
|
+
|
|
1153
|
+
```typescript
|
|
1154
|
+
// Save
|
|
1155
|
+
const state = collector.getState();
|
|
1156
|
+
const json = stringifyState(state, { workflowId: runId });
|
|
1157
|
+
await prisma.workflowState.upsert({
|
|
1158
|
+
where: { runId },
|
|
1159
|
+
update: { state: json, updatedAt: new Date() },
|
|
1160
|
+
create: { runId, state: json },
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// Load
|
|
1164
|
+
const saved = await prisma.workflowState.findUnique({ where: { runId } });
|
|
1165
|
+
const savedState = parseState(saved.state);
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
**DynamoDB:**
|
|
1169
|
+
|
|
1170
|
+
```typescript
|
|
1171
|
+
const persistence = createStatePersistence({
|
|
1172
|
+
get: async (key) => {
|
|
1173
|
+
const result = await dynamodb.get({ TableName: 'workflows', Key: { id: key } });
|
|
1174
|
+
return result.Item?.state || null;
|
|
1175
|
+
},
|
|
1176
|
+
set: async (key, value) => {
|
|
1177
|
+
await dynamodb.put({ TableName: 'workflows', Item: { id: key, state: value } });
|
|
1178
|
+
},
|
|
1179
|
+
delete: async (key) => {
|
|
1180
|
+
await dynamodb.delete({ TableName: 'workflows', Key: { id: key } });
|
|
1181
|
+
return true;
|
|
1182
|
+
},
|
|
1183
|
+
exists: async (key) => {
|
|
1184
|
+
const result = await dynamodb.get({ TableName: 'workflows', Key: { id: key } });
|
|
1185
|
+
return !!result.Item;
|
|
1186
|
+
},
|
|
1187
|
+
keys: async (pattern) => {
|
|
1188
|
+
// Implement pattern matching for your use case
|
|
1189
|
+
const result = await dynamodb.scan({ TableName: 'workflows' });
|
|
1190
|
+
return result.Items?.map(item => item.id) || [];
|
|
1191
|
+
},
|
|
1192
|
+
}, 'workflow:state:');
|
|
1193
|
+
```
|
|
1194
|
+
|
|
830
1195
|
## Devtools
|
|
831
1196
|
|
|
832
1197
|
Developer tools for workflow debugging, visualization, and analysis:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jagreehal/workflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed async workflows with automatic error inference. Build type-safe workflows with Result types, step caching, resume state, and human-in-the-loop support.",
|
|
6
6
|
"main": "./dist/index.cjs",
|