@jagreehal/workflow 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1197 -20
  2. package/dist/duration.cjs +2 -0
  3. package/dist/duration.cjs.map +1 -0
  4. package/dist/duration.d.cts +246 -0
  5. package/dist/duration.d.ts +246 -0
  6. package/dist/duration.js +2 -0
  7. package/dist/duration.js.map +1 -0
  8. package/dist/index.cjs +5 -5
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +3 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +5 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/match.cjs +2 -0
  15. package/dist/match.cjs.map +1 -0
  16. package/dist/match.d.cts +216 -0
  17. package/dist/match.d.ts +216 -0
  18. package/dist/match.js +2 -0
  19. package/dist/match.js.map +1 -0
  20. package/dist/schedule.cjs +2 -0
  21. package/dist/schedule.cjs.map +1 -0
  22. package/dist/schedule.d.cts +387 -0
  23. package/dist/schedule.d.ts +387 -0
  24. package/dist/schedule.js +2 -0
  25. package/dist/schedule.js.map +1 -0
  26. package/docs/api.md +30 -0
  27. package/docs/coming-from-neverthrow.md +103 -10
  28. package/docs/effect-features-to-port.md +210 -0
  29. package/docs/match-examples.test.ts +558 -0
  30. package/docs/match.md +417 -0
  31. package/docs/policies-examples.test.ts +750 -0
  32. package/docs/policies.md +508 -0
  33. package/docs/resource-management-examples.test.ts +729 -0
  34. package/docs/resource-management.md +509 -0
  35. package/docs/schedule-examples.test.ts +736 -0
  36. package/docs/schedule.md +467 -0
  37. package/docs/tagged-error-examples.test.ts +494 -0
  38. package/docs/tagged-error.md +730 -0
  39. package/docs/visualization-examples.test.ts +663 -0
  40. package/docs/visualization.md +395 -0
  41. package/docs/visualize-examples.md +1 -1
  42. package/package.json +17 -2
@@ -0,0 +1,467 @@
1
+ # Schedule
2
+
3
+ Composable scheduling primitives for retry and polling strategies. Build complex schedules by combining simple building blocks.
4
+
5
+ ## The Problem: Hard-Coded Retry Logic
6
+
7
+ Retry strategies end up scattered and inflexible:
8
+
9
+ ```typescript
10
+ // ❌ Hard-coded, not reusable
11
+ async function fetchWithRetry(url: string) {
12
+ for (let i = 0; i < 3; i++) {
13
+ try {
14
+ return await fetch(url);
15
+ } catch {
16
+ await sleep(100 * Math.pow(2, i)); // Exponential backoff
17
+ }
18
+ }
19
+ throw new Error('Failed after 3 attempts');
20
+ }
21
+ ```
22
+
23
+ When you need different strategies for different operations, you copy-paste and tweak.
24
+
25
+ ## The Solution: Composable Schedules
26
+
27
+ Define scheduling strategies as composable values:
28
+
29
+ ```typescript
30
+ import { Schedule, Duration } from '@jagreehal/workflow';
31
+
32
+ // ✓ Composable, reusable
33
+ const retryStrategy = Schedule.exponential(Duration.millis(100))
34
+ .pipe(Schedule.jittered(0.2))
35
+ .pipe(Schedule.upTo(5))
36
+ .pipe(Schedule.andThen(Schedule.spaced(Duration.minutes(1))));
37
+
38
+ // Use with step.retry or other retry utilities
39
+ ```
40
+
41
+ ## Base Schedules
42
+
43
+ ### Forever & Recurs
44
+
45
+ ```typescript
46
+ import { Schedule } from '@jagreehal/workflow';
47
+
48
+ Schedule.forever() // Repeats indefinitely with no delay
49
+ Schedule.recurs(5) // Repeats exactly 5 times
50
+ Schedule.once() // Repeats exactly once
51
+ Schedule.stop() // Never runs (immediately done)
52
+ ```
53
+
54
+ ### Delay-Based Schedules
55
+
56
+ ```typescript
57
+ import { Schedule, Duration } from '@jagreehal/workflow';
58
+
59
+ Schedule.spaced(Duration.seconds(1)) // Fixed 1s interval
60
+ Schedule.exponential(Duration.millis(100)) // 100ms, 200ms, 400ms, 800ms...
61
+ Schedule.exponential(Duration.millis(100), 3) // Custom factor: 100ms, 300ms, 900ms...
62
+ Schedule.linear(Duration.millis(100)) // 100ms, 200ms, 300ms, 400ms...
63
+ Schedule.fibonacci(Duration.millis(100)) // 100ms, 100ms, 200ms, 300ms, 500ms...
64
+ ```
65
+
66
+ ## Combinators
67
+
68
+ ### Limits
69
+
70
+ ```typescript
71
+ // Limit by count
72
+ const limited = Schedule.exponential(Duration.millis(100))
73
+ .pipe(Schedule.upTo(5)); // Max 5 iterations
74
+
75
+ // Limit by elapsed time
76
+ const timeLimited = Schedule.spaced(Duration.seconds(1))
77
+ .pipe(Schedule.upToElapsed(Duration.minutes(5))); // Stop after 5 minutes
78
+
79
+ // Cap delays
80
+ const capped = Schedule.exponential(Duration.millis(100))
81
+ .pipe(Schedule.maxDelay(Duration.seconds(30))) // Never exceed 30s
82
+ .pipe(Schedule.minDelay(Duration.millis(50))); // Never below 50ms
83
+ ```
84
+
85
+ ### Jitter
86
+
87
+ ```typescript
88
+ // Add randomness to prevent thundering herd
89
+ const jittered = Schedule.exponential(Duration.millis(100))
90
+ .pipe(Schedule.jittered(0.2)); // ±20% random variation
91
+ ```
92
+
93
+ ## Composition Combinators
94
+
95
+ ### `andThen` - Sequential Composition
96
+
97
+ Chain two schedules: run the first until done, then run the second.
98
+
99
+ ```typescript
100
+ import { Schedule, Duration } from '@jagreehal/workflow';
101
+
102
+ // 5 fast retries, then switch to slow polling
103
+ const strategy = Schedule.exponential(Duration.millis(100))
104
+ .pipe(Schedule.upTo(5))
105
+ .pipe(Schedule.andThen(Schedule.spaced(Duration.minutes(1))));
106
+
107
+ // Run the schedule
108
+ const runner = Schedule.run(strategy);
109
+ runner.next(undefined); // 100ms (exponential phase)
110
+ runner.next(undefined); // 200ms
111
+ runner.next(undefined); // 400ms
112
+ runner.next(undefined); // 800ms
113
+ runner.next(undefined); // 1600ms (last of 5)
114
+ runner.next(undefined); // 60000ms (switched to spaced)
115
+ runner.next(undefined); // 60000ms (continues forever)
116
+ ```
117
+
118
+ **Key Feature: Reusability**
119
+
120
+ Schedules are reusable - each `run()` call starts fresh:
121
+
122
+ ```typescript
123
+ const schedule = Schedule.recurs(2)
124
+ .pipe(Schedule.andThen(Schedule.recurs(2)));
125
+
126
+ // First run
127
+ const delays1 = Schedule.delays(schedule, 10); // [0ms, 0ms, 0ms, 0ms]
128
+
129
+ // Second run on SAME instance - works correctly
130
+ const delays2 = Schedule.delays(schedule, 10); // [0ms, 0ms, 0ms, 0ms]
131
+ ```
132
+
133
+ **Composability**
134
+
135
+ `andThen` works with any schedule, including nested compositions:
136
+
137
+ ```typescript
138
+ // union → andThen
139
+ const strategy1 = Schedule.union(
140
+ Schedule.recurs(2),
141
+ Schedule.recurs(3)
142
+ ).pipe(Schedule.andThen(Schedule.recurs(1)));
143
+
144
+ // andThen → andThen (triple chain)
145
+ const strategy2 = Schedule.recurs(1)
146
+ .pipe(Schedule.andThen(Schedule.recurs(1)))
147
+ .pipe(Schedule.andThen(Schedule.recurs(1)));
148
+ ```
149
+
150
+ ### `union` - Parallel with Shorter Delay
151
+
152
+ Run two schedules in parallel, taking the shorter delay at each step. Continues while either schedule continues.
153
+
154
+ ```typescript
155
+ import { Schedule, Duration } from '@jagreehal/workflow';
156
+
157
+ // Use whichever delay is shorter
158
+ const aggressive = Schedule.union(
159
+ Schedule.exponential(Duration.millis(100)), // Fast start
160
+ Schedule.spaced(Duration.seconds(1)) // Steady fallback
161
+ );
162
+
163
+ const runner = Schedule.run(aggressive);
164
+ runner.next(undefined); // { delay: 100ms, output: [0, 0] }
165
+ runner.next(undefined); // { delay: 200ms, output: [1, 1] }
166
+ runner.next(undefined); // { delay: 400ms, output: [2, 2] }
167
+ runner.next(undefined); // { delay: 800ms, output: [3, 3] }
168
+ runner.next(undefined); // { delay: 1000ms, output: [4, 4] } // spaced wins
169
+ ```
170
+
171
+ **Use Case: Adaptive Backoff**
172
+
173
+ ```typescript
174
+ // Start aggressive, fall back to steady polling
175
+ const adaptiveRetry = Schedule.union(
176
+ Schedule.exponential(Duration.millis(50)).pipe(Schedule.upTo(3)), // Fast initial
177
+ Schedule.spaced(Duration.seconds(5)) // Steady fallback
178
+ );
179
+ ```
180
+
181
+ ### `intersect` - Parallel with Longer Delay
182
+
183
+ Run two schedules in parallel, taking the longer delay at each step. Stops when either schedule stops.
184
+
185
+ ```typescript
186
+ import { Schedule, Duration } from '@jagreehal/workflow';
187
+
188
+ // Use whichever delay is longer (more conservative)
189
+ const conservative = Schedule.intersect(
190
+ Schedule.exponential(Duration.millis(100)),
191
+ Schedule.spaced(Duration.seconds(1))
192
+ );
193
+
194
+ const runner = Schedule.run(conservative);
195
+ runner.next(undefined); // { delay: 1000ms, output: [0, 0] } // spaced wins
196
+ runner.next(undefined); // { delay: 1000ms, output: [1, 1] }
197
+ ```
198
+
199
+ **Use Case: Rate Limiting**
200
+
201
+ ```typescript
202
+ // Never faster than 1 request per second, but back off on failures
203
+ const rateLimited = Schedule.intersect(
204
+ Schedule.spaced(Duration.seconds(1)), // Minimum 1s between requests
205
+ Schedule.exponential(Duration.millis(100)) // Back off on repeated failures
206
+ );
207
+ ```
208
+
209
+ ### Nested Composition
210
+
211
+ All combinators compose freely:
212
+
213
+ ```typescript
214
+ // union(andThen(...), andThen(...))
215
+ const complex = Schedule.union(
216
+ Schedule.recurs(1).pipe(Schedule.andThen(Schedule.recurs(1))),
217
+ Schedule.recurs(2).pipe(Schedule.andThen(Schedule.recurs(1)))
218
+ );
219
+
220
+ // intersect(andThen(...), recurs)
221
+ const nested = Schedule.intersect(
222
+ Schedule.recurs(2).pipe(Schedule.andThen(Schedule.recurs(2))),
223
+ Schedule.recurs(5)
224
+ );
225
+
226
+ // andThen(union(...), union(...))
227
+ const chained = Schedule.union(Schedule.recurs(1), Schedule.recurs(1))
228
+ .pipe(Schedule.andThen(Schedule.union(Schedule.recurs(1), Schedule.recurs(1))));
229
+ ```
230
+
231
+ ## Running Schedules
232
+
233
+ ### `Schedule.run` - Step-by-Step Iterator
234
+
235
+ ```typescript
236
+ const schedule = Schedule.exponential(Duration.millis(100))
237
+ .pipe(Schedule.upTo(3));
238
+
239
+ const runner = Schedule.run(schedule);
240
+
241
+ const step1 = runner.next(undefined);
242
+ // { done: false, value: { delay: Duration(100ms), output: 0 } }
243
+
244
+ const step2 = runner.next(undefined);
245
+ // { done: false, value: { delay: Duration(200ms), output: 1 } }
246
+
247
+ const step3 = runner.next(undefined);
248
+ // { done: false, value: { delay: Duration(400ms), output: 2 } }
249
+
250
+ const step4 = runner.next(undefined);
251
+ // { done: true }
252
+ ```
253
+
254
+ ### `Schedule.delays` - Get All Delays (Testing)
255
+
256
+ ```typescript
257
+ const schedule = Schedule.fibonacci(Duration.millis(100))
258
+ .pipe(Schedule.upTo(5));
259
+
260
+ const delays = Schedule.delays(schedule);
261
+ // [100ms, 100ms, 200ms, 300ms, 500ms]
262
+ ```
263
+
264
+ ## Transformations
265
+
266
+ ### `map` - Transform Output
267
+
268
+ ```typescript
269
+ const withAttemptNumber = Schedule.recurs(3)
270
+ .pipe(Schedule.map((n) => ({ attempt: n + 1 })));
271
+
272
+ const runner = Schedule.run(withAttemptNumber);
273
+ runner.next(undefined); // { output: { attempt: 1 } }
274
+ runner.next(undefined); // { output: { attempt: 2 } }
275
+ ```
276
+
277
+ ### `tap` - Side Effects
278
+
279
+ ```typescript
280
+ const withLogging = Schedule.exponential(Duration.millis(100))
281
+ .pipe(Schedule.tap((iteration) => console.log(`Attempt ${iteration + 1}`)));
282
+ ```
283
+
284
+ ### `modifyDelay` - Transform Delays
285
+
286
+ ```typescript
287
+ const doubled = Schedule.spaced(Duration.seconds(1))
288
+ .pipe(Schedule.modifyDelay((d) => Duration.multiply(d, 2)));
289
+ ```
290
+
291
+ ## Real-World Scenarios
292
+
293
+ ### Database Connection Pool: Health Check and Reconnect
294
+
295
+ Your app loses database connectivity occasionally. You want aggressive reconnection attempts initially, then back off to avoid hammering a potentially overloaded server.
296
+
297
+ ```typescript
298
+ import { Schedule, Duration } from '@jagreehal/workflow';
299
+
300
+ // Quick retries for blips, then slower checks to let the DB recover
301
+ const dbReconnect = Schedule.exponential(Duration.millis(100))
302
+ .pipe(Schedule.upTo(5)) // 5 quick attempts: 100, 200, 400, 800, 1600ms
303
+ .pipe(Schedule.andThen(
304
+ Schedule.spaced(Duration.seconds(10)) // Then check every 10s
305
+ .pipe(Schedule.upTo(30)) // Give up after 5 minutes
306
+ ));
307
+ ```
308
+
309
+ Why this works: Most connection drops are brief (network hiccup, connection pool exhaustion). Fast retries catch these. If the problem persists, slower retries prevent your app from contributing to the overload.
310
+
311
+ ### Email Service: Handle Transient Failures
312
+
313
+ Your email provider has rate limits and occasional 429s. You need to respect limits while still retrying transient failures.
314
+
315
+ ```typescript
316
+ import { Schedule, Duration } from '@jagreehal/workflow';
317
+
318
+ // Never faster than rate limit, but back off on repeated failures
319
+ const emailRetry = Schedule.intersect(
320
+ Schedule.spaced(Duration.millis(200)), // Rate limit: 5 emails/second
321
+ Schedule.exponential(Duration.millis(100)) // Back off: 100, 200, 400...
322
+ .pipe(Schedule.maxDelay(Duration.seconds(60))) // Cap at 1 minute
323
+ ).pipe(Schedule.upTo(10)); // Max 10 attempts
324
+ ```
325
+
326
+ Why `intersect`: Takes the *longer* delay, so you never exceed rate limits even when backing off rapidly. The exponential only kicks in once it exceeds the rate limit baseline.
327
+
328
+ ### Webhook Delivery: Escalating Retry with Deadline
329
+
330
+ You're sending webhooks to customer endpoints. Some endpoints are slow or unreliable. You want quick retries for transient issues, slower retries for persistent issues, but a hard deadline.
331
+
332
+ ```typescript
333
+ import { Schedule, Duration } from '@jagreehal/workflow';
334
+
335
+ // Three phases: immediate, patient, very patient
336
+ const webhookDelivery = Schedule.exponential(Duration.seconds(1))
337
+ .pipe(Schedule.upTo(3)) // Phase 1: 1s, 2s, 4s
338
+ .pipe(Schedule.andThen(
339
+ Schedule.spaced(Duration.minutes(5))
340
+ .pipe(Schedule.upTo(6)) // Phase 2: every 5min for 30min
341
+ ))
342
+ .pipe(Schedule.andThen(
343
+ Schedule.spaced(Duration.hours(1))
344
+ .pipe(Schedule.upTo(24)) // Phase 3: hourly for 24h
345
+ ));
346
+ ```
347
+
348
+ Why three phases: Fast retries catch brief outages. Medium retries handle maintenance windows. Slow retries give endpoints 24 hours to come back online before you mark the delivery as failed.
349
+
350
+ ### Background Job Processing: Throttled with Backpressure
351
+
352
+ You're processing jobs from a queue. You want steady throughput, but if jobs start failing, slow down to avoid overwhelming downstream services.
353
+
354
+ ```typescript
355
+ import { Schedule, Duration } from '@jagreehal/workflow';
356
+
357
+ // Steady pace normally, but back off when things go wrong
358
+ const jobProcessing = Schedule.union(
359
+ Schedule.spaced(Duration.millis(100)), // Normal: 10 jobs/second
360
+ Schedule.exponential(Duration.millis(50)) // Failure: back off quickly
361
+ .pipe(Schedule.maxDelay(Duration.seconds(5))) // But never more than 5s
362
+ ).pipe(Schedule.jittered(0.1)); // Add jitter to prevent thundering herd
363
+ ```
364
+
365
+ Why `union`: Takes the *shorter* delay. When healthy, processes at steady 100ms. When failing, exponential grows but union caps effective delay at 100ms until failures compound enough to exceed that baseline.
366
+
367
+ ## Real-World Patterns
368
+
369
+ ### Checkout Retries You Can Explain to a PM
370
+
371
+ Your payment provider flakes 1–2% of the time. You want a few fast retries to catch brief blips, then slow down so you don't hammer the gateway.
372
+
373
+ ```typescript
374
+ import { Schedule, Duration } from '@jagreehal/workflow';
375
+
376
+ // Fast retries for transient blips, then slower follow-ups
377
+ const paymentRetry = Schedule.exponential(Duration.millis(200))
378
+ .pipe(Schedule.upTo(4)) // 4 quick attempts
379
+ .pipe(Schedule.andThen(
380
+ Schedule.spaced(Duration.seconds(30)) // Then check every 30s
381
+ .pipe(Schedule.upTo(6)) // Give up after 3 minutes
382
+ ));
383
+ ```
384
+
385
+ ### Shipment Tracking: "Check Every 5 Minutes, Stop After a Day"
386
+
387
+ Your operations team wants predictable polling that doesn't run forever.
388
+
389
+ ```typescript
390
+ import { Schedule, Duration } from '@jagreehal/workflow';
391
+
392
+ const trackShipment = Schedule.spaced(Duration.minutes(5))
393
+ .pipe(Schedule.upToElapsed(Duration.hours(24)));
394
+ ```
395
+
396
+ ### Public API Safety: Respect Rate Limits While Backing Off
397
+
398
+ If you get throttled, back off, but never exceed the platform's baseline rate limit.
399
+
400
+ ```typescript
401
+ import { Schedule, Duration } from '@jagreehal/workflow';
402
+
403
+ const safeApiCalls = Schedule.intersect(
404
+ Schedule.spaced(Duration.seconds(1)), // Never faster than 1 req/s
405
+ Schedule.exponential(Duration.millis(200)) // Back off on failures
406
+ .pipe(Schedule.maxDelay(Duration.seconds(30)))
407
+ );
408
+ ```
409
+
410
+ ### Exponential Backoff with Jitter and Cap
411
+
412
+ ```typescript
413
+ const httpRetry = Schedule.exponential(Duration.millis(100))
414
+ .pipe(Schedule.jittered(0.2)) // Add randomness
415
+ .pipe(Schedule.maxDelay(Duration.seconds(30))) // Cap at 30s
416
+ .pipe(Schedule.upTo(5)); // Max 5 attempts
417
+ ```
418
+
419
+ ### Two-Phase Retry Strategy
420
+
421
+ ```typescript
422
+ // Aggressive retries first, then patient polling
423
+ const twoPhase = Schedule.exponential(Duration.millis(50))
424
+ .pipe(Schedule.upTo(3))
425
+ .pipe(Schedule.andThen(
426
+ Schedule.spaced(Duration.minutes(1)).pipe(Schedule.upTo(10))
427
+ ));
428
+ ```
429
+
430
+ ### Adaptive Rate Limiting
431
+
432
+ ```typescript
433
+ // Take the more conservative delay between rate limit and backoff
434
+ const adaptive = Schedule.intersect(
435
+ Schedule.spaced(Duration.millis(100)), // Rate limit: 10 req/s
436
+ Schedule.exponential(Duration.millis(50)) // Back off on failures
437
+ ).pipe(Schedule.upTo(10));
438
+ ```
439
+
440
+ ### Aggressive Retry with Fallback
441
+
442
+ ```typescript
443
+ // Take the shorter delay between exponential and fixed ceiling
444
+ const aggressive = Schedule.union(
445
+ Schedule.exponential(Duration.millis(100)),
446
+ Schedule.spaced(Duration.seconds(5)) // Never wait more than 5s
447
+ ).pipe(Schedule.upTo(10));
448
+ ```
449
+
450
+ ## Summary
451
+
452
+ | Function | Purpose |
453
+ | -------- | ------- |
454
+ | `Schedule.forever()` | Repeat indefinitely |
455
+ | `Schedule.recurs(n)` | Repeat exactly n times |
456
+ | `Schedule.spaced(d)` | Fixed delay interval |
457
+ | `Schedule.exponential(d)` | Exponential backoff |
458
+ | `Schedule.andThen(s)` | Sequential: first → second |
459
+ | `Schedule.union(a, b)` | Parallel: shorter delay, either continues |
460
+ | `Schedule.intersect(a, b)` | Parallel: longer delay, both must continue |
461
+ | `Schedule.upTo(n)` | Limit iterations |
462
+ | `Schedule.maxDelay(d)` | Cap maximum delay |
463
+ | `Schedule.jittered(f)` | Add random variation |
464
+ | `Schedule.run(s)` | Create iterator |
465
+ | `Schedule.delays(s)` | Get all delays (testing) |
466
+
467
+ **The key insight:** Build complex retry strategies by composing simple primitives. Schedules are reusable and safe to run multiple times.