@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/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(kvStore, 'workflow:state:');
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
- await persistence.save('run-123', resumeState, { userId: 'user-1' });
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.6.0",
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",