@jagreehal/workflow 1.4.0 → 1.6.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 +664 -332
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +179 -1
- package/dist/core.d.ts +179 -1
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +9 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3266 -2
- package/dist/index.d.ts +3266 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/visualize.cjs +6 -6
- package/dist/visualize.cjs.map +1 -1
- package/dist/visualize.js +6 -6
- package/dist/visualize.js.map +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- package/docs/advanced.md +895 -0
- package/docs/api.md +257 -0
- package/docs/coming-from-neverthrow.md +920 -0
- package/docs/visualize-examples.md +330 -0
- package/package.json +7 -6
package/docs/advanced.md
CHANGED
|
@@ -232,3 +232,898 @@ const result = await run.strict<User, AppError>(
|
|
|
232
232
|
```
|
|
233
233
|
|
|
234
234
|
Prefer `createWorkflow` for automatic error type inference.
|
|
235
|
+
|
|
236
|
+
## Circuit Breaker
|
|
237
|
+
|
|
238
|
+
Prevent cascading failures by tracking step failure rates and short-circuiting calls when a threshold is exceeded:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import {
|
|
242
|
+
createCircuitBreaker,
|
|
243
|
+
isCircuitOpenError,
|
|
244
|
+
circuitBreakerPresets,
|
|
245
|
+
ok, // Import ok for Result-returning operations
|
|
246
|
+
} from '@jagreehal/workflow';
|
|
247
|
+
|
|
248
|
+
// Create a circuit breaker with custom config (name is required)
|
|
249
|
+
const breaker = createCircuitBreaker('external-api', {
|
|
250
|
+
failureThreshold: 5, // Open after 5 failures
|
|
251
|
+
resetTimeout: 30000, // Try again after 30 seconds
|
|
252
|
+
halfOpenMax: 3, // Allow 3 test requests in half-open state
|
|
253
|
+
windowSize: 60000, // Count failures within this window (1 minute)
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Or use a preset
|
|
257
|
+
const criticalBreaker = createCircuitBreaker('critical-service', circuitBreakerPresets.critical);
|
|
258
|
+
const lenientBreaker = createCircuitBreaker('lenient-service', circuitBreakerPresets.lenient);
|
|
259
|
+
|
|
260
|
+
// Option 1: execute() throws CircuitOpenError if circuit is open
|
|
261
|
+
try {
|
|
262
|
+
const data = await breaker.execute(async () => {
|
|
263
|
+
return await fetchFromExternalApi();
|
|
264
|
+
});
|
|
265
|
+
console.log('Got data:', data);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (isCircuitOpenError(error)) {
|
|
268
|
+
console.log(`Circuit is open, retry after ${error.retryAfterMs}ms`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log('Operation failed:', error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Option 2: executeResult() returns a Result instead of throwing
|
|
275
|
+
const result = await breaker.executeResult(async () => {
|
|
276
|
+
// Your Result-returning operation
|
|
277
|
+
return ok(await fetchFromExternalApi());
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
if (isCircuitOpenError(result.error)) {
|
|
282
|
+
console.log('Circuit is open, try again later');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check circuit state (no arguments needed)
|
|
287
|
+
const stats = breaker.getStats();
|
|
288
|
+
console.log(stats.state); // 'CLOSED' | 'OPEN' | 'HALF_OPEN'
|
|
289
|
+
console.log(stats.failureCount);
|
|
290
|
+
console.log(stats.successCount);
|
|
291
|
+
console.log(stats.halfOpenSuccesses);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Saga / Compensation Pattern
|
|
295
|
+
|
|
296
|
+
Define compensating actions for steps that need rollback on downstream failures:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
import { createSagaWorkflow, isSagaCompensationError } from '@jagreehal/workflow';
|
|
300
|
+
|
|
301
|
+
// Create saga with deps (like createWorkflow) - error types inferred automatically
|
|
302
|
+
const checkoutSaga = createSagaWorkflow(
|
|
303
|
+
{ reserveInventory, chargeCard, sendConfirmation },
|
|
304
|
+
{ onEvent: (event) => console.log(event) }
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const result = await checkoutSaga(async (saga, deps) => {
|
|
308
|
+
// Reserve inventory with compensation
|
|
309
|
+
const reservation = await saga.step(
|
|
310
|
+
() => deps.reserveInventory(items),
|
|
311
|
+
{
|
|
312
|
+
name: 'reserve-inventory',
|
|
313
|
+
compensate: (res) => releaseInventory(res.reservationId),
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Charge card with compensation
|
|
318
|
+
const payment = await saga.step(
|
|
319
|
+
() => deps.chargeCard(amount),
|
|
320
|
+
{
|
|
321
|
+
name: 'charge-card',
|
|
322
|
+
compensate: (p) => refundPayment(p.transactionId),
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// If sendConfirmation fails, compensations run in reverse order:
|
|
327
|
+
// 1. refundPayment(payment.transactionId)
|
|
328
|
+
// 2. releaseInventory(reservation.reservationId)
|
|
329
|
+
await saga.step(
|
|
330
|
+
() => deps.sendConfirmation(email),
|
|
331
|
+
{ name: 'send-confirmation' }
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return { reservation, payment };
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Check for compensation errors
|
|
338
|
+
if (!result.ok && isSagaCompensationError(result.error)) {
|
|
339
|
+
console.log('Saga failed, compensations may have partially succeeded');
|
|
340
|
+
console.log(result.error.compensationErrors);
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Low-level runSaga
|
|
345
|
+
|
|
346
|
+
For explicit error typing without deps-based inference:
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
import { runSaga } from '@jagreehal/workflow';
|
|
350
|
+
|
|
351
|
+
const result = await runSaga<CheckoutResult, CheckoutError>(async (saga) => {
|
|
352
|
+
const reservation = await saga.step(
|
|
353
|
+
() => reserveInventory(items),
|
|
354
|
+
{ compensate: (res) => releaseInventory(res.id) }
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// tryStep for catching throws
|
|
358
|
+
const payment = await saga.tryStep(
|
|
359
|
+
() => externalPaymentApi.charge(amount),
|
|
360
|
+
{
|
|
361
|
+
error: 'PAYMENT_FAILED' as const,
|
|
362
|
+
compensate: (p) => externalPaymentApi.refund(p.txId),
|
|
363
|
+
}
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
return { reservation, payment };
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Rate Limiting / Concurrency Control
|
|
371
|
+
|
|
372
|
+
Control throughput for steps that hit rate-limited APIs:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import {
|
|
376
|
+
createRateLimiter,
|
|
377
|
+
createConcurrencyLimiter,
|
|
378
|
+
createCombinedLimiter,
|
|
379
|
+
rateLimiterPresets,
|
|
380
|
+
} from '@jagreehal/workflow';
|
|
381
|
+
|
|
382
|
+
// Token bucket rate limiter (requires name and config)
|
|
383
|
+
const rateLimiter = createRateLimiter('api-calls', {
|
|
384
|
+
maxPerSecond: 10, // Maximum operations per second
|
|
385
|
+
burstCapacity: 20, // Allow brief spikes (default: maxPerSecond * 2)
|
|
386
|
+
strategy: 'wait', // 'wait' (default) or 'reject'
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Concurrency limiter
|
|
390
|
+
const concurrencyLimiter = createConcurrencyLimiter('db-pool', {
|
|
391
|
+
maxConcurrent: 5, // Max 5 concurrent operations
|
|
392
|
+
maxQueueSize: 100, // Queue up to 100 waiting requests
|
|
393
|
+
strategy: 'queue', // 'queue' (default) or 'reject'
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Use presets for common scenarios
|
|
397
|
+
const apiLimiter = createRateLimiter('external-api', rateLimiterPresets.api);
|
|
398
|
+
// rateLimiterPresets.api: { maxPerSecond: 10, burstCapacity: 20, strategy: 'wait' }
|
|
399
|
+
// rateLimiterPresets.external: { maxPerSecond: 5, burstCapacity: 10, strategy: 'wait' }
|
|
400
|
+
// rateLimiterPresets.database: for ConcurrencyLimiter - { maxConcurrent: 10, strategy: 'queue', maxQueueSize: 100 }
|
|
401
|
+
|
|
402
|
+
// Wrap operations with execute() method
|
|
403
|
+
const data = await rateLimiter.execute(async () => {
|
|
404
|
+
return await callExternalApi();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// For Result-returning operations
|
|
408
|
+
const result = await rateLimiter.executeResult(async () => {
|
|
409
|
+
return ok(await callExternalApi());
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Use with batch operations
|
|
413
|
+
const results = await concurrencyLimiter.executeAll(
|
|
414
|
+
ids.map(id => async () => fetchItem(id))
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Combined limiter (both rate and concurrency)
|
|
418
|
+
const combined = createCombinedLimiter('api', {
|
|
419
|
+
rate: { maxPerSecond: 10 },
|
|
420
|
+
concurrency: { maxConcurrent: 3 },
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const data = await combined.execute(async () => callApi());
|
|
424
|
+
|
|
425
|
+
// Get limiter statistics
|
|
426
|
+
const stats = rateLimiter.getStats();
|
|
427
|
+
console.log(stats.availableTokens); // Current available tokens
|
|
428
|
+
console.log(stats.waitingCount); // Requests waiting for tokens
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Workflow Versioning and Migration
|
|
432
|
+
|
|
433
|
+
Handle schema changes when resuming workflows persisted with older step shapes:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
import {
|
|
437
|
+
createVersionedStateLoader,
|
|
438
|
+
createVersionedState,
|
|
439
|
+
parseVersionedState,
|
|
440
|
+
stringifyVersionedState,
|
|
441
|
+
migrateState,
|
|
442
|
+
createKeyRenameMigration,
|
|
443
|
+
createKeyRemoveMigration,
|
|
444
|
+
createValueTransformMigration,
|
|
445
|
+
composeMigrations,
|
|
446
|
+
} from '@jagreehal/workflow';
|
|
447
|
+
|
|
448
|
+
// Define migrations from each version to the next
|
|
449
|
+
// Key is source version, migration transforms to version + 1
|
|
450
|
+
const migrations = {
|
|
451
|
+
// Migrate from v1 to v2: rename keys
|
|
452
|
+
1: createKeyRenameMigration({
|
|
453
|
+
'user:fetch': 'user:load',
|
|
454
|
+
'order:create': 'order:submit',
|
|
455
|
+
}),
|
|
456
|
+
|
|
457
|
+
// Migrate from v2 to v3: multiple transformations
|
|
458
|
+
2: composeMigrations([
|
|
459
|
+
createKeyRemoveMigration(['deprecated:step']),
|
|
460
|
+
createValueTransformMigration({
|
|
461
|
+
'user:load': (entry) => ({
|
|
462
|
+
...entry,
|
|
463
|
+
result: entry.result.ok
|
|
464
|
+
? { ok: true, value: { ...entry.result.value, newField: 'default' } }
|
|
465
|
+
: entry.result,
|
|
466
|
+
}),
|
|
467
|
+
}),
|
|
468
|
+
]),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Create a versioned state loader
|
|
472
|
+
const loader = createVersionedStateLoader({
|
|
473
|
+
version: 3, // Current workflow version
|
|
474
|
+
migrations,
|
|
475
|
+
strictVersioning: true, // Fail if state is from newer version
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Load state from storage and parse it
|
|
479
|
+
const json = await db.loadWorkflowState(runId);
|
|
480
|
+
const versionedState = parseVersionedState(json);
|
|
481
|
+
|
|
482
|
+
// Migrate to current version
|
|
483
|
+
const result = await loader(versionedState);
|
|
484
|
+
if (!result.ok) {
|
|
485
|
+
// Handle migration error or version incompatibility
|
|
486
|
+
console.error(result.error);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Use migrated state with workflow
|
|
490
|
+
const workflow = createWorkflow(deps, { resumeState: result.value });
|
|
491
|
+
|
|
492
|
+
// When saving state, create versioned state
|
|
493
|
+
import { createStepCollector } from '@jagreehal/workflow';
|
|
494
|
+
|
|
495
|
+
const collector = createStepCollector();
|
|
496
|
+
// ... run workflow with collector ...
|
|
497
|
+
|
|
498
|
+
const versionedState = createVersionedState(collector.getState(), 3);
|
|
499
|
+
const serialized = stringifyVersionedState(versionedState);
|
|
500
|
+
await db.saveWorkflowState(runId, serialized);
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
## Conditional Step Execution
|
|
504
|
+
|
|
505
|
+
Declarative guards for steps that should only run under certain conditions:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
import { when, unless, whenOr, unlessOr, createConditionalHelpers } from '@jagreehal/workflow';
|
|
509
|
+
|
|
510
|
+
const result = await workflow(async (step) => {
|
|
511
|
+
const user = await step(() => fetchUser(id), { key: 'user' });
|
|
512
|
+
|
|
513
|
+
// Only runs if condition is true, returns undefined if skipped
|
|
514
|
+
const premium = await when(
|
|
515
|
+
user.isPremium,
|
|
516
|
+
() => step(() => fetchPremiumData(user.id), { key: 'premium' }),
|
|
517
|
+
{ name: 'check-premium', reason: 'User is not premium' }
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Skips if condition is true, returns undefined if skipped
|
|
521
|
+
const trial = await unless(
|
|
522
|
+
user.isPremium,
|
|
523
|
+
() => step(() => fetchTrialLimits(user.id), { key: 'trial' }),
|
|
524
|
+
{ name: 'check-trial', reason: 'User is premium' }
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// With default value instead of undefined
|
|
528
|
+
const limits = await whenOr(
|
|
529
|
+
user.isPremium,
|
|
530
|
+
() => step(() => fetchPremiumLimits(user.id), { key: 'premium-limits' }),
|
|
531
|
+
{ maxRequests: 100, maxStorage: 1000 }, // default for non-premium
|
|
532
|
+
{ name: 'check-premium-limits', reason: 'Using default limits' }
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return { user, premium, trial, limits };
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### With Event Emission
|
|
540
|
+
|
|
541
|
+
Use `createConditionalHelpers` to emit `step_skipped` events for visualization and debugging:
|
|
542
|
+
|
|
543
|
+
```typescript
|
|
544
|
+
const result = await run(async (step) => {
|
|
545
|
+
const ctx = { workflowId: 'my-workflow', onEvent: console.log };
|
|
546
|
+
const { when, whenOr } = createConditionalHelpers(ctx);
|
|
547
|
+
|
|
548
|
+
const user = await step(fetchUser(id));
|
|
549
|
+
|
|
550
|
+
// Emits step_skipped event when condition is false
|
|
551
|
+
const premium = await when(
|
|
552
|
+
user.isPremium,
|
|
553
|
+
() => step(() => fetchPremiumData(user.id)),
|
|
554
|
+
{ name: 'premium-data', reason: 'User is not premium' }
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
return { user, premium };
|
|
558
|
+
}, { onEvent, workflowId });
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## Webhook / Event Trigger Adapters
|
|
562
|
+
|
|
563
|
+
Expose workflows as HTTP endpoints or event consumers:
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import {
|
|
567
|
+
createWebhookHandler,
|
|
568
|
+
createSimpleHandler,
|
|
569
|
+
createResultMapper,
|
|
570
|
+
createExpressHandler,
|
|
571
|
+
validationError,
|
|
572
|
+
requireFields,
|
|
573
|
+
} from '@jagreehal/workflow';
|
|
574
|
+
|
|
575
|
+
// Create a webhook handler for a workflow
|
|
576
|
+
const handler = createWebhookHandler(
|
|
577
|
+
checkoutWorkflow,
|
|
578
|
+
async (step, deps, input: CheckoutInput) => {
|
|
579
|
+
const charge = await step(() => deps.chargeCard(input.amount));
|
|
580
|
+
await step(() => deps.sendEmail(input.email, charge.receiptUrl));
|
|
581
|
+
return { chargeId: charge.id };
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
validateInput: (req) => {
|
|
585
|
+
const validation = requireFields(['amount', 'email'])(req.body);
|
|
586
|
+
if (!validation.ok) return validation;
|
|
587
|
+
return ok({ amount: req.body.amount, email: req.body.email });
|
|
588
|
+
},
|
|
589
|
+
mapResult: createResultMapper([
|
|
590
|
+
{ error: 'CARD_DECLINED', status: 402, message: 'Payment failed' },
|
|
591
|
+
{ error: 'INVALID_EMAIL', status: 400, message: 'Invalid email address' },
|
|
592
|
+
]),
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Use with Express
|
|
597
|
+
import express from 'express';
|
|
598
|
+
const app = express();
|
|
599
|
+
app.post('/checkout', createExpressHandler(handler));
|
|
600
|
+
|
|
601
|
+
// Or manually
|
|
602
|
+
app.post('/checkout', async (req, res) => {
|
|
603
|
+
const response = await handler({
|
|
604
|
+
method: req.method,
|
|
605
|
+
path: req.path,
|
|
606
|
+
headers: req.headers,
|
|
607
|
+
body: req.body,
|
|
608
|
+
query: req.query,
|
|
609
|
+
params: req.params,
|
|
610
|
+
});
|
|
611
|
+
res.status(response.status).json(response.body);
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Event Triggers (for message queues)
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
import { createEventHandler } from '@jagreehal/workflow';
|
|
619
|
+
|
|
620
|
+
const handler = createEventHandler(
|
|
621
|
+
checkoutWorkflow,
|
|
622
|
+
async (step, deps, payload: CheckoutPayload) => {
|
|
623
|
+
const charge = await step(() => deps.chargeCard(payload.amount));
|
|
624
|
+
return { chargeId: charge.id };
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
validatePayload: (event) => {
|
|
628
|
+
if (!event.payload.amount) {
|
|
629
|
+
return err(validationError('Missing amount'));
|
|
630
|
+
}
|
|
631
|
+
return ok(event.payload);
|
|
632
|
+
},
|
|
633
|
+
mapResult: (result) => ({
|
|
634
|
+
success: result.ok,
|
|
635
|
+
ack: result.ok || !isRetryableError(result.error),
|
|
636
|
+
error: result.ok ? undefined : { type: String(result.error) },
|
|
637
|
+
}),
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// Use with SQS, RabbitMQ, etc.
|
|
642
|
+
queue.consume(async (message) => {
|
|
643
|
+
const result = await handler({
|
|
644
|
+
id: message.id,
|
|
645
|
+
type: message.type,
|
|
646
|
+
payload: message.body,
|
|
647
|
+
});
|
|
648
|
+
if (result.ack) await message.ack();
|
|
649
|
+
else await message.nack();
|
|
650
|
+
});
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
## Policy-Driven Step Middleware
|
|
654
|
+
|
|
655
|
+
Reusable bundles of `StepOptions` (retry, timeout, cache keys) that can be composed and applied per-workflow or per-step:
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
import {
|
|
659
|
+
mergePolicies,
|
|
660
|
+
createPolicyApplier,
|
|
661
|
+
withPolicy,
|
|
662
|
+
withPolicies,
|
|
663
|
+
retryPolicies,
|
|
664
|
+
timeoutPolicies,
|
|
665
|
+
servicePolicies,
|
|
666
|
+
createPolicyRegistry,
|
|
667
|
+
stepOptions,
|
|
668
|
+
} from '@jagreehal/workflow';
|
|
669
|
+
|
|
670
|
+
// Use pre-built service policies
|
|
671
|
+
const user = await step(
|
|
672
|
+
() => fetchUser(id),
|
|
673
|
+
withPolicy(servicePolicies.httpApi, { name: 'fetch-user' })
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
// Combine multiple policies
|
|
677
|
+
const data = await step(
|
|
678
|
+
() => fetchData(),
|
|
679
|
+
withPolicies([timeoutPolicies.api, retryPolicies.standard], 'fetch-data')
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// Create a policy applier for consistent defaults
|
|
683
|
+
const applyPolicy = createPolicyApplier(
|
|
684
|
+
timeoutPolicies.api,
|
|
685
|
+
retryPolicies.transient
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
const result = await step(
|
|
689
|
+
() => callApi(),
|
|
690
|
+
applyPolicy({ name: 'api-call', key: 'cache:api' })
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
// Use the fluent builder API
|
|
694
|
+
const options = stepOptions()
|
|
695
|
+
.name('fetch-user')
|
|
696
|
+
.key('user:123')
|
|
697
|
+
.timeout(5000)
|
|
698
|
+
.retries(3)
|
|
699
|
+
.build();
|
|
700
|
+
|
|
701
|
+
// Create a policy registry for organization-wide policies
|
|
702
|
+
const registry = createPolicyRegistry();
|
|
703
|
+
registry.register('api', servicePolicies.httpApi);
|
|
704
|
+
registry.register('db', servicePolicies.database);
|
|
705
|
+
registry.register('cache', servicePolicies.cache);
|
|
706
|
+
|
|
707
|
+
const user = await step(
|
|
708
|
+
() => fetchUser(id),
|
|
709
|
+
registry.apply('api', { name: 'fetch-user' })
|
|
710
|
+
);
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
### Available Presets
|
|
714
|
+
|
|
715
|
+
```typescript
|
|
716
|
+
// Retry policies
|
|
717
|
+
retryPolicies.none // No retry
|
|
718
|
+
retryPolicies.transient // 3 attempts, fast backoff
|
|
719
|
+
retryPolicies.standard // 3 attempts, moderate backoff
|
|
720
|
+
retryPolicies.aggressive // 5 attempts, longer backoff
|
|
721
|
+
retryPolicies.fixed(3, 1000) // 3 attempts, 1s fixed delay
|
|
722
|
+
retryPolicies.linear(3, 100) // 3 attempts, linear backoff
|
|
723
|
+
|
|
724
|
+
// Timeout policies
|
|
725
|
+
timeoutPolicies.fast // 1 second
|
|
726
|
+
timeoutPolicies.api // 5 seconds
|
|
727
|
+
timeoutPolicies.extended // 30 seconds
|
|
728
|
+
timeoutPolicies.long // 2 minutes
|
|
729
|
+
timeoutPolicies.ms(3000) // Custom milliseconds
|
|
730
|
+
|
|
731
|
+
// Service policies (combined retry + timeout)
|
|
732
|
+
servicePolicies.httpApi // 5s timeout, 3 retries
|
|
733
|
+
servicePolicies.database // 30s timeout, 2 retries
|
|
734
|
+
servicePolicies.cache // 1s timeout, no retry
|
|
735
|
+
servicePolicies.messageQueue // 30s timeout, 5 retries
|
|
736
|
+
servicePolicies.fileSystem // 2min timeout, 3 retries
|
|
737
|
+
servicePolicies.rateLimited // 10s timeout, 5 linear retries
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
## Pluggable Persistence Adapters
|
|
741
|
+
|
|
742
|
+
First-class adapters for `StepCache` and `ResumeState` with JSON-safe serialization:
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
import {
|
|
746
|
+
createMemoryCache,
|
|
747
|
+
createFileCache,
|
|
748
|
+
createKVCache,
|
|
749
|
+
createStatePersistence,
|
|
750
|
+
createHydratingCache,
|
|
751
|
+
stringifyState,
|
|
752
|
+
parseState,
|
|
753
|
+
} from '@jagreehal/workflow';
|
|
754
|
+
|
|
755
|
+
// In-memory cache with TTL and LRU eviction
|
|
756
|
+
const cache = createMemoryCache({
|
|
757
|
+
maxSize: 1000, // Max entries
|
|
758
|
+
ttl: 60000, // 1 minute TTL
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const workflow = createWorkflow(deps, { cache });
|
|
762
|
+
|
|
763
|
+
// File-based persistence
|
|
764
|
+
import * as fs from 'fs/promises';
|
|
765
|
+
|
|
766
|
+
const fileCache = createFileCache({
|
|
767
|
+
directory: './workflow-cache',
|
|
768
|
+
fs: {
|
|
769
|
+
readFile: (path) => fs.readFile(path, 'utf-8'),
|
|
770
|
+
writeFile: (path, data) => fs.writeFile(path, data, 'utf-8'),
|
|
771
|
+
unlink: fs.unlink,
|
|
772
|
+
exists: async (path) => fs.access(path).then(() => true).catch(() => false),
|
|
773
|
+
readdir: fs.readdir,
|
|
774
|
+
mkdir: fs.mkdir,
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await fileCache.init();
|
|
779
|
+
|
|
780
|
+
// Redis/DynamoDB adapter
|
|
781
|
+
const kvCache = createKVCache({
|
|
782
|
+
store: {
|
|
783
|
+
get: (key) => redis.get(key),
|
|
784
|
+
set: (key, value, opts) => redis.set(key, value, { EX: opts?.ttl }),
|
|
785
|
+
delete: (key) => redis.del(key).then(n => n > 0),
|
|
786
|
+
exists: (key) => redis.exists(key).then(n => n > 0),
|
|
787
|
+
keys: (pattern) => redis.keys(pattern),
|
|
788
|
+
},
|
|
789
|
+
prefix: 'myapp:workflow:',
|
|
790
|
+
ttl: 3600, // 1 hour
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// State persistence for workflow resumption
|
|
794
|
+
const persistence = createStatePersistence(kvStore, 'workflow:state:');
|
|
795
|
+
|
|
796
|
+
await persistence.save('run-123', resumeState, { userId: 'user-1' });
|
|
797
|
+
const loaded = await persistence.load('run-123');
|
|
798
|
+
const allRuns = await persistence.list();
|
|
799
|
+
|
|
800
|
+
// Hydrating cache (loads from persistent storage on first access)
|
|
801
|
+
const hydratingCache = createHydratingCache(
|
|
802
|
+
createMemoryCache(),
|
|
803
|
+
persistence,
|
|
804
|
+
'run-123'
|
|
805
|
+
);
|
|
806
|
+
await hydratingCache.hydrate();
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### JSON-safe Serialization
|
|
810
|
+
|
|
811
|
+
```typescript
|
|
812
|
+
import {
|
|
813
|
+
serializeResult,
|
|
814
|
+
deserializeResult,
|
|
815
|
+
serializeState,
|
|
816
|
+
deserializeState,
|
|
817
|
+
stringifyState,
|
|
818
|
+
parseState,
|
|
819
|
+
} from '@jagreehal/workflow';
|
|
820
|
+
|
|
821
|
+
// Serialize Results with Error causes preserved
|
|
822
|
+
const serialized = serializeResult(result);
|
|
823
|
+
const restored = deserializeResult(serialized);
|
|
824
|
+
|
|
825
|
+
// Serialize entire workflow state
|
|
826
|
+
const json = stringifyState(resumeState, { userId: 'user-1' });
|
|
827
|
+
const state = parseState(json);
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
## Devtools
|
|
831
|
+
|
|
832
|
+
Developer tools for workflow debugging, visualization, and analysis:
|
|
833
|
+
|
|
834
|
+
```typescript
|
|
835
|
+
import {
|
|
836
|
+
createDevtools,
|
|
837
|
+
renderDiff,
|
|
838
|
+
createConsoleLogger,
|
|
839
|
+
quickVisualize,
|
|
840
|
+
} from '@jagreehal/workflow';
|
|
841
|
+
|
|
842
|
+
// Create devtools instance
|
|
843
|
+
const devtools = createDevtools({
|
|
844
|
+
workflowName: 'checkout',
|
|
845
|
+
logEvents: true,
|
|
846
|
+
maxHistory: 10,
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// Use with workflow
|
|
850
|
+
const workflow = createWorkflow(deps, {
|
|
851
|
+
onEvent: devtools.handleEvent,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
await workflow(async (step) => {
|
|
855
|
+
const user = await step(() => fetchUser(id), { name: 'fetch-user' });
|
|
856
|
+
const charge = await step(() => chargeCard(100), { name: 'charge-card' });
|
|
857
|
+
return { user, charge };
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Render visualizations
|
|
861
|
+
console.log(devtools.render()); // ASCII visualization
|
|
862
|
+
console.log(devtools.renderMermaid()); // Mermaid diagram
|
|
863
|
+
console.log(devtools.renderTimeline()); // Timeline view
|
|
864
|
+
|
|
865
|
+
// Get timeline data
|
|
866
|
+
const timeline = devtools.getTimeline();
|
|
867
|
+
// [{ name: 'fetch-user', startMs: 0, endMs: 50, status: 'success' }, ...]
|
|
868
|
+
|
|
869
|
+
// Compare runs
|
|
870
|
+
const diff = devtools.diffWithPrevious();
|
|
871
|
+
if (diff) {
|
|
872
|
+
console.log(renderDiff(diff));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Export/import runs
|
|
876
|
+
const json = devtools.exportRun();
|
|
877
|
+
devtools.importRun(json);
|
|
878
|
+
|
|
879
|
+
// Simple console logging
|
|
880
|
+
const workflow2 = createWorkflow(deps, {
|
|
881
|
+
onEvent: createConsoleLogger({ prefix: '[checkout]', colors: true }),
|
|
882
|
+
});
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
### Timeline Output Example
|
|
886
|
+
|
|
887
|
+
```
|
|
888
|
+
Timeline:
|
|
889
|
+
────────────────────────────────────────────────────────────────
|
|
890
|
+
fetch-user |██████ | 50ms
|
|
891
|
+
charge-card | ████████████ | 120ms
|
|
892
|
+
send-email | ████ | 30ms
|
|
893
|
+
────────────────────────────────────────────────────────────────
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
## HITL Orchestration Helpers
|
|
897
|
+
|
|
898
|
+
Production-ready helpers for human-in-the-loop approval workflows:
|
|
899
|
+
|
|
900
|
+
```typescript
|
|
901
|
+
import {
|
|
902
|
+
createHITLOrchestrator,
|
|
903
|
+
createMemoryApprovalStore,
|
|
904
|
+
createMemoryWorkflowStateStore,
|
|
905
|
+
createApprovalWebhookHandler,
|
|
906
|
+
createApprovalChecker,
|
|
907
|
+
} from '@jagreehal/workflow';
|
|
908
|
+
|
|
909
|
+
// Create orchestrator with stores
|
|
910
|
+
const orchestrator = createHITLOrchestrator({
|
|
911
|
+
approvalStore: createMemoryApprovalStore(),
|
|
912
|
+
workflowStateStore: createMemoryWorkflowStateStore(),
|
|
913
|
+
defaultExpirationMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// Execute workflow that may pause for approval
|
|
917
|
+
// IMPORTANT: The factory must pass onEvent to createWorkflow for HITL tracking!
|
|
918
|
+
const result = await orchestrator.execute(
|
|
919
|
+
'order-approval',
|
|
920
|
+
({ resumeState, onEvent }) => createWorkflow(deps, { resumeState, onEvent }),
|
|
921
|
+
async (step, deps, input) => {
|
|
922
|
+
const order = await step(() => deps.createOrder(input));
|
|
923
|
+
const approval = await step(
|
|
924
|
+
() => deps.requireApproval(order.id),
|
|
925
|
+
{ key: `approval:${order.id}` }
|
|
926
|
+
);
|
|
927
|
+
await step(() => deps.processOrder(order.id));
|
|
928
|
+
return { orderId: order.id };
|
|
929
|
+
},
|
|
930
|
+
{ items: [...], total: 500 }
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
if (result.status === 'paused') {
|
|
934
|
+
console.log(`Workflow paused, waiting for: ${result.pendingApprovals}`);
|
|
935
|
+
console.log(`Run ID: ${result.runId}`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Grant approval (with optional auto-resume)
|
|
939
|
+
const { resumedWorkflows } = await orchestrator.grantApproval(
|
|
940
|
+
`approval:${orderId}`,
|
|
941
|
+
{ approvedBy: 'manager@example.com' },
|
|
942
|
+
{ autoResume: true }
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
// Or poll for approval
|
|
946
|
+
const status = await orchestrator.pollApproval(`approval:${orderId}`, {
|
|
947
|
+
intervalMs: 1000,
|
|
948
|
+
timeoutMs: 60000,
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Resume manually
|
|
952
|
+
const resumed = await orchestrator.resume(
|
|
953
|
+
runId,
|
|
954
|
+
(resumeState) => createWorkflow(deps, { resumeState }),
|
|
955
|
+
workflowFn
|
|
956
|
+
);
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
### Webhook Handler for Approvals
|
|
960
|
+
|
|
961
|
+
```typescript
|
|
962
|
+
import { createApprovalWebhookHandler } from '@jagreehal/workflow';
|
|
963
|
+
import express from 'express';
|
|
964
|
+
|
|
965
|
+
const handleApproval = createApprovalWebhookHandler(approvalStore);
|
|
966
|
+
|
|
967
|
+
const app = express();
|
|
968
|
+
app.post('/api/approvals', async (req, res) => {
|
|
969
|
+
const result = await handleApproval({
|
|
970
|
+
key: req.body.key,
|
|
971
|
+
action: req.body.action, // 'approve' | 'reject' | 'cancel'
|
|
972
|
+
value: req.body.value,
|
|
973
|
+
reason: req.body.reason,
|
|
974
|
+
actorId: req.user.id,
|
|
975
|
+
});
|
|
976
|
+
res.json(result);
|
|
977
|
+
});
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
## Deterministic Workflow Testing Harness
|
|
981
|
+
|
|
982
|
+
Test workflows with scripted step outcomes:
|
|
983
|
+
|
|
984
|
+
```typescript
|
|
985
|
+
import {
|
|
986
|
+
createWorkflowHarness,
|
|
987
|
+
createMockFn,
|
|
988
|
+
createTestClock,
|
|
989
|
+
createSnapshot,
|
|
990
|
+
compareSnapshots,
|
|
991
|
+
okOutcome,
|
|
992
|
+
errOutcome,
|
|
993
|
+
} from '@jagreehal/workflow';
|
|
994
|
+
|
|
995
|
+
// Create test harness
|
|
996
|
+
const harness = createWorkflowHarness(
|
|
997
|
+
{ fetchUser, chargeCard },
|
|
998
|
+
{ clock: createTestClock().now }
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
// Script step outcomes
|
|
1002
|
+
harness.script([
|
|
1003
|
+
okOutcome({ id: '1', name: 'Alice' }),
|
|
1004
|
+
okOutcome({ transactionId: 'tx_123' }),
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
// Or script specific steps by name
|
|
1008
|
+
harness.scriptStep('fetch-user', okOutcome({ id: '1', name: 'Alice' }));
|
|
1009
|
+
harness.scriptStep('charge-card', errOutcome('CARD_DECLINED'));
|
|
1010
|
+
|
|
1011
|
+
// Run workflow
|
|
1012
|
+
const result = await harness.run(async (step, { fetchUser, chargeCard }) => {
|
|
1013
|
+
const user = await step(() => fetchUser('1'), 'fetch-user');
|
|
1014
|
+
const charge = await step(() => chargeCard(100), 'charge-card');
|
|
1015
|
+
return { user, charge };
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
// Assert results
|
|
1019
|
+
expect(result.ok).toBe(false);
|
|
1020
|
+
expect(harness.assertSteps(['fetch-user', 'charge-card']).passed).toBe(true);
|
|
1021
|
+
expect(harness.assertStepCalled('fetch-user').passed).toBe(true);
|
|
1022
|
+
expect(harness.assertStepNotCalled('refund').passed).toBe(true);
|
|
1023
|
+
|
|
1024
|
+
// Get invocation details
|
|
1025
|
+
const invocations = harness.getInvocations();
|
|
1026
|
+
console.log(invocations[0].name); // 'fetch-user'
|
|
1027
|
+
console.log(invocations[0].durationMs); // 0 (deterministic clock)
|
|
1028
|
+
console.log(invocations[0].result); // { ok: true, value: { id: '1', name: 'Alice' } }
|
|
1029
|
+
|
|
1030
|
+
// Reset for next test
|
|
1031
|
+
harness.reset();
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
### Mock Functions
|
|
1035
|
+
|
|
1036
|
+
```typescript
|
|
1037
|
+
import { createMockFn, ok, err } from '@jagreehal/workflow';
|
|
1038
|
+
|
|
1039
|
+
const fetchUser = createMockFn<User, 'NOT_FOUND'>();
|
|
1040
|
+
|
|
1041
|
+
// Set default return
|
|
1042
|
+
fetchUser.returns(ok({ id: '1', name: 'Alice' }));
|
|
1043
|
+
|
|
1044
|
+
// Or queue return values
|
|
1045
|
+
fetchUser.returnsOnce(ok({ id: '1', name: 'Alice' }));
|
|
1046
|
+
fetchUser.returnsOnce(err('NOT_FOUND'));
|
|
1047
|
+
|
|
1048
|
+
// Check calls
|
|
1049
|
+
console.log(fetchUser.getCallCount()); // 2
|
|
1050
|
+
console.log(fetchUser.getCalls()); // [[arg1], [arg2]]
|
|
1051
|
+
|
|
1052
|
+
fetchUser.reset();
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
### Snapshot Testing
|
|
1056
|
+
|
|
1057
|
+
```typescript
|
|
1058
|
+
import { createSnapshot, compareSnapshots } from '@jagreehal/workflow';
|
|
1059
|
+
|
|
1060
|
+
// Create snapshot from a run (events are optional, from external sources)
|
|
1061
|
+
const snapshot1 = createSnapshot(
|
|
1062
|
+
harness.getInvocations(),
|
|
1063
|
+
result
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
// Run again and compare
|
|
1067
|
+
harness.reset();
|
|
1068
|
+
harness.script([...newOutcomes]); // script() resets state automatically
|
|
1069
|
+
const result2 = await harness.run(workflowFn);
|
|
1070
|
+
const snapshot2 = createSnapshot(harness.getInvocations(), result2);
|
|
1071
|
+
|
|
1072
|
+
const { equal, differences } = compareSnapshots(snapshot1, snapshot2);
|
|
1073
|
+
if (!equal) {
|
|
1074
|
+
console.log('Differences:', differences);
|
|
1075
|
+
}
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
## OpenTelemetry Integration (Autotel)
|
|
1079
|
+
|
|
1080
|
+
First-class OpenTelemetry metrics from the event stream:
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
import { createAutotelAdapter, createAutotelEventHandler, withAutotelTracing } from '@jagreehal/workflow';
|
|
1084
|
+
|
|
1085
|
+
// Create an adapter that tracks metrics
|
|
1086
|
+
const autotel = createAutotelAdapter({
|
|
1087
|
+
serviceName: 'checkout-service',
|
|
1088
|
+
createStepSpans: true, // Create spans for each step
|
|
1089
|
+
recordMetrics: true, // Record step metrics
|
|
1090
|
+
recordRetryEvents: true, // Record retry events
|
|
1091
|
+
markErrorsOnSpan: true, // Mark errors on spans
|
|
1092
|
+
defaultAttributes: { // Custom attributes for all spans
|
|
1093
|
+
environment: 'production',
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// Use adapter's handleEvent directly with workflow
|
|
1098
|
+
const workflow = createWorkflow(deps, {
|
|
1099
|
+
onEvent: autotel.handleEvent,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// Access collected metrics
|
|
1103
|
+
const metrics = autotel.getMetrics();
|
|
1104
|
+
console.log(metrics.stepDurations); // Array of { name, durationMs, success }
|
|
1105
|
+
console.log(metrics.retryCount); // Total retry count
|
|
1106
|
+
console.log(metrics.errorCount); // Total error count
|
|
1107
|
+
console.log(metrics.cacheHits); // Cache hit count
|
|
1108
|
+
console.log(metrics.cacheMisses); // Cache miss count
|
|
1109
|
+
|
|
1110
|
+
// Or use the simpler event handler for debug logging
|
|
1111
|
+
const workflow2 = createWorkflow(deps, {
|
|
1112
|
+
onEvent: createAutotelEventHandler({
|
|
1113
|
+
serviceName: 'checkout',
|
|
1114
|
+
includeStepDetails: true,
|
|
1115
|
+
}),
|
|
1116
|
+
});
|
|
1117
|
+
// Set AUTOTEL_DEBUG=true to see console output
|
|
1118
|
+
|
|
1119
|
+
// Wrap with autotel tracing for actual OpenTelemetry spans
|
|
1120
|
+
import { trace } from 'autotel';
|
|
1121
|
+
|
|
1122
|
+
const traced = withAutotelTracing(trace, { serviceName: 'checkout' });
|
|
1123
|
+
|
|
1124
|
+
const result = await traced('process-order', async () => {
|
|
1125
|
+
return workflow(async (step) => {
|
|
1126
|
+
// ... workflow logic
|
|
1127
|
+
});
|
|
1128
|
+
}, { orderId: '123' }); // Optional attributes
|
|
1129
|
+
```
|