@jagreehal/workflow 1.12.0 → 1.14.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 (49) 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/dist/visualize.cjs +73 -67
  27. package/dist/visualize.cjs.map +1 -1
  28. package/dist/visualize.d.cts +13 -1
  29. package/dist/visualize.d.ts +13 -1
  30. package/dist/visualize.js +73 -67
  31. package/dist/visualize.js.map +1 -1
  32. package/docs/api.md +30 -0
  33. package/docs/coming-from-neverthrow.md +103 -10
  34. package/docs/effect-features-to-port.md +210 -0
  35. package/docs/match-examples.test.ts +558 -0
  36. package/docs/match.md +417 -0
  37. package/docs/pino-logging-example.md +293 -0
  38. package/docs/policies-examples.test.ts +750 -0
  39. package/docs/policies.md +508 -0
  40. package/docs/resource-management-examples.test.ts +729 -0
  41. package/docs/resource-management.md +509 -0
  42. package/docs/schedule-examples.test.ts +736 -0
  43. package/docs/schedule.md +467 -0
  44. package/docs/tagged-error-examples.test.ts +494 -0
  45. package/docs/tagged-error.md +730 -0
  46. package/docs/visualization-examples.test.ts +663 -0
  47. package/docs/visualization.md +430 -0
  48. package/docs/visualize-examples.md +1 -1
  49. package/package.json +22 -3
@@ -0,0 +1,736 @@
1
+ /**
2
+ * Test file to verify all code examples in schedule.md actually work
3
+ * This file should compile and run without errors
4
+ */
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import { Schedule, delays } from "../src/schedule";
8
+ import { Duration } from "../src/duration";
9
+
10
+ // ============================================================================
11
+ // Base Schedules
12
+ // ============================================================================
13
+
14
+ describe("Schedule", () => {
15
+ describe("base schedules", () => {
16
+ it("forever repeats indefinitely", () => {
17
+ const schedule = Schedule.forever();
18
+ const d = delays(schedule, 5);
19
+ expect(d.length).toBe(5);
20
+ d.forEach((delay) => expect(Duration.toMillis(delay)).toBe(0));
21
+ });
22
+
23
+ it("recurs repeats exactly n times", () => {
24
+ const schedule = Schedule.recurs(5);
25
+ const d = delays(schedule, 10);
26
+ expect(d.length).toBe(5);
27
+ });
28
+
29
+ it("once repeats exactly once", () => {
30
+ const schedule = Schedule.once();
31
+ const d = delays(schedule, 10);
32
+ expect(d.length).toBe(1);
33
+ });
34
+
35
+ it("stop never runs", () => {
36
+ const schedule = Schedule.stop();
37
+ const d = delays(schedule, 10);
38
+ expect(d.length).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("delay-based schedules", () => {
43
+ it("spaced provides fixed interval", () => {
44
+ const schedule = Schedule.spaced(Duration.seconds(1));
45
+ const d = delays(schedule, 3);
46
+ d.forEach((delay) => expect(Duration.toMillis(delay)).toBe(1000));
47
+ });
48
+
49
+ it("exponential doubles each time", () => {
50
+ const schedule = Schedule.exponential(Duration.millis(100));
51
+ const d = delays(schedule, 4);
52
+ expect(Duration.toMillis(d[0])).toBe(100);
53
+ expect(Duration.toMillis(d[1])).toBe(200);
54
+ expect(Duration.toMillis(d[2])).toBe(400);
55
+ expect(Duration.toMillis(d[3])).toBe(800);
56
+ });
57
+
58
+ it("exponential with custom factor", () => {
59
+ const schedule = Schedule.exponential(Duration.millis(100), 3);
60
+ const d = delays(schedule, 3);
61
+ expect(Duration.toMillis(d[0])).toBe(100);
62
+ expect(Duration.toMillis(d[1])).toBe(300);
63
+ expect(Duration.toMillis(d[2])).toBe(900);
64
+ });
65
+
66
+ it("linear increases by base each time", () => {
67
+ const schedule = Schedule.linear(Duration.millis(100));
68
+ const d = delays(schedule, 4);
69
+ expect(Duration.toMillis(d[0])).toBe(100);
70
+ expect(Duration.toMillis(d[1])).toBe(200);
71
+ expect(Duration.toMillis(d[2])).toBe(300);
72
+ expect(Duration.toMillis(d[3])).toBe(400);
73
+ });
74
+
75
+ it("fibonacci follows fibonacci sequence", () => {
76
+ const schedule = Schedule.fibonacci(Duration.millis(100));
77
+ const d = delays(schedule, 5);
78
+ expect(Duration.toMillis(d[0])).toBe(100);
79
+ expect(Duration.toMillis(d[1])).toBe(100);
80
+ expect(Duration.toMillis(d[2])).toBe(200);
81
+ expect(Duration.toMillis(d[3])).toBe(300);
82
+ expect(Duration.toMillis(d[4])).toBe(500);
83
+ });
84
+ });
85
+
86
+ // ============================================================================
87
+ // Combinators
88
+ // ============================================================================
89
+
90
+ describe("limit combinators", () => {
91
+ it("upTo limits iterations", () => {
92
+ const schedule = Schedule.exponential(Duration.millis(100)).pipe(
93
+ Schedule.upTo(5)
94
+ );
95
+ const d = delays(schedule, 10);
96
+ expect(d.length).toBe(5);
97
+ });
98
+
99
+ it("maxDelay caps delays", () => {
100
+ const schedule = Schedule.exponential(Duration.millis(100))
101
+ .pipe(Schedule.maxDelay(Duration.millis(500)))
102
+ .pipe(Schedule.upTo(10));
103
+
104
+ const d = delays(schedule);
105
+ d.forEach((delay) => {
106
+ expect(Duration.toMillis(delay)).toBeLessThanOrEqual(500);
107
+ });
108
+ });
109
+
110
+ it("minDelay sets floor", () => {
111
+ const schedule = Schedule.spaced(Duration.millis(10))
112
+ .pipe(Schedule.minDelay(Duration.millis(50)))
113
+ .pipe(Schedule.upTo(3));
114
+
115
+ const d = delays(schedule);
116
+ d.forEach((delay) => {
117
+ expect(Duration.toMillis(delay)).toBeGreaterThanOrEqual(50);
118
+ });
119
+ });
120
+ });
121
+
122
+ describe("jitter", () => {
123
+ it("jittered adds randomness", () => {
124
+ const schedule = Schedule.spaced(Duration.millis(100))
125
+ .pipe(Schedule.jittered(0.2))
126
+ .pipe(Schedule.upTo(10));
127
+
128
+ const d = delays(schedule);
129
+ // At least some delays should differ from 100ms
130
+ const allSame = d.every((delay) => Duration.toMillis(delay) === 100);
131
+ expect(allSame).toBe(false);
132
+
133
+ // All should be within ±20% of 100ms
134
+ d.forEach((delay) => {
135
+ const ms = Duration.toMillis(delay);
136
+ expect(ms).toBeGreaterThanOrEqual(80);
137
+ expect(ms).toBeLessThanOrEqual(120);
138
+ });
139
+ });
140
+ });
141
+
142
+ // ============================================================================
143
+ // Composition Combinators (andThen, union, intersect)
144
+ // ============================================================================
145
+
146
+ describe("andThen - sequential composition", () => {
147
+ it("chains two schedules sequentially", () => {
148
+ // 5 fast retries, then switch to slow polling
149
+ const schedule = Schedule.spaced(Duration.millis(100))
150
+ .pipe(Schedule.upTo(3))
151
+ .pipe(Schedule.andThen(Schedule.spaced(Duration.millis(1000))));
152
+
153
+ const d = delays(schedule, 6);
154
+
155
+ // First 3 should be 100ms
156
+ expect(Duration.toMillis(d[0])).toBe(100);
157
+ expect(Duration.toMillis(d[1])).toBe(100);
158
+ expect(Duration.toMillis(d[2])).toBe(100);
159
+
160
+ // Next ones should be 1000ms
161
+ expect(Duration.toMillis(d[3])).toBe(1000);
162
+ expect(Duration.toMillis(d[4])).toBe(1000);
163
+ expect(Duration.toMillis(d[5])).toBe(1000);
164
+ });
165
+
166
+ it("is reusable across multiple runs", () => {
167
+ const schedule = Schedule.recurs(2).pipe(
168
+ Schedule.andThen(Schedule.recurs(2))
169
+ );
170
+
171
+ // First run
172
+ const firstRun = delays(schedule, 10);
173
+ expect(firstRun.length).toBe(4);
174
+
175
+ // Second run on SAME instance - should produce identical results
176
+ const secondRun = delays(schedule, 10);
177
+ expect(secondRun.length).toBe(4);
178
+ });
179
+
180
+ it("composes with nested schedules (union → andThen)", () => {
181
+ const schedule = Schedule.union(
182
+ Schedule.recurs(2),
183
+ Schedule.recurs(2)
184
+ ).pipe(Schedule.andThen(Schedule.recurs(1)));
185
+
186
+ const d = delays(schedule, 10);
187
+ expect(d.length).toBe(3); // 2 from union + 1 from andThen
188
+ });
189
+
190
+ it("supports triple chaining (andThen → andThen)", () => {
191
+ const schedule = Schedule.recurs(1)
192
+ .pipe(Schedule.andThen(Schedule.recurs(1)))
193
+ .pipe(Schedule.andThen(Schedule.recurs(1)));
194
+
195
+ const d = delays(schedule, 10);
196
+ expect(d.length).toBe(3);
197
+
198
+ // Verify reusability
199
+ const secondRun = delays(schedule, 10);
200
+ expect(secondRun.length).toBe(3);
201
+ });
202
+ });
203
+
204
+ describe("union - parallel with shorter delay", () => {
205
+ it("takes the shorter delay at each step", () => {
206
+ const schedule = Schedule.union(
207
+ Schedule.exponential(Duration.millis(100)),
208
+ Schedule.spaced(Duration.millis(500))
209
+ );
210
+
211
+ const d = delays(schedule, 4);
212
+
213
+ // Exponential: 100, 200, 400, 800
214
+ // Spaced: 500, 500, 500, 500
215
+ // Union (min): 100, 200, 400, 500
216
+ expect(Duration.toMillis(d[0])).toBe(100);
217
+ expect(Duration.toMillis(d[1])).toBe(200);
218
+ expect(Duration.toMillis(d[2])).toBe(400);
219
+ expect(Duration.toMillis(d[3])).toBe(500); // spaced wins
220
+ });
221
+
222
+ it("continues while either schedule continues", () => {
223
+ const schedule = Schedule.union(
224
+ Schedule.recurs(2), // stops after 2
225
+ Schedule.recurs(3) // stops after 3
226
+ );
227
+
228
+ const d = delays(schedule, 10);
229
+ expect(d.length).toBe(3); // max(2, 3) = 3
230
+ });
231
+
232
+ it("is reusable across multiple runs", () => {
233
+ const schedule = Schedule.union(Schedule.recurs(2), Schedule.recurs(3));
234
+
235
+ const firstRun = delays(schedule, 10);
236
+ expect(firstRun.length).toBe(3);
237
+
238
+ const secondRun = delays(schedule, 10);
239
+ expect(secondRun.length).toBe(3);
240
+ });
241
+
242
+ it("composes with andThen children", () => {
243
+ const schedule = Schedule.union(
244
+ Schedule.recurs(1).pipe(Schedule.andThen(Schedule.recurs(1))), // 2 total
245
+ Schedule.recurs(2).pipe(Schedule.andThen(Schedule.recurs(1))) // 3 total
246
+ );
247
+
248
+ const d = delays(schedule, 10);
249
+ expect(d.length).toBe(3); // max(2, 3) = 3
250
+
251
+ // Verify reusability
252
+ const secondRun = delays(schedule, 10);
253
+ expect(secondRun.length).toBe(3);
254
+ });
255
+ });
256
+
257
+ describe("intersect - parallel with longer delay", () => {
258
+ it("takes the longer delay at each step", () => {
259
+ const schedule = Schedule.intersect(
260
+ Schedule.exponential(Duration.millis(100)),
261
+ Schedule.spaced(Duration.millis(500))
262
+ ).pipe(Schedule.upTo(4));
263
+
264
+ const d = delays(schedule, 10);
265
+
266
+ // Exponential: 100, 200, 400, 800
267
+ // Spaced: 500, 500, 500, 500
268
+ // Intersect (max): 500, 500, 500, 800
269
+ expect(Duration.toMillis(d[0])).toBe(500);
270
+ expect(Duration.toMillis(d[1])).toBe(500);
271
+ expect(Duration.toMillis(d[2])).toBe(500);
272
+ expect(Duration.toMillis(d[3])).toBe(800); // exponential wins
273
+ });
274
+
275
+ it("stops when either schedule stops", () => {
276
+ const schedule = Schedule.intersect(
277
+ Schedule.recurs(2), // stops after 2
278
+ Schedule.recurs(3) // stops after 3
279
+ );
280
+
281
+ const d = delays(schedule, 10);
282
+ expect(d.length).toBe(2); // min(2, 3) = 2
283
+ });
284
+
285
+ it("is reusable across multiple runs", () => {
286
+ const schedule = Schedule.intersect(Schedule.recurs(2), Schedule.recurs(3));
287
+
288
+ const firstRun = delays(schedule, 10);
289
+ expect(firstRun.length).toBe(2);
290
+
291
+ const secondRun = delays(schedule, 10);
292
+ expect(secondRun.length).toBe(2);
293
+ });
294
+
295
+ it("composes with andThen children", () => {
296
+ const schedule = Schedule.intersect(
297
+ Schedule.recurs(1).pipe(Schedule.andThen(Schedule.recurs(1))), // 2 total
298
+ Schedule.recurs(2).pipe(Schedule.andThen(Schedule.recurs(1))) // 3 total
299
+ );
300
+
301
+ const d = delays(schedule, 10);
302
+ expect(d.length).toBe(2); // min(2, 3) = 2
303
+
304
+ // Verify reusability
305
+ const secondRun = delays(schedule, 10);
306
+ expect(secondRun.length).toBe(2);
307
+ });
308
+ });
309
+
310
+ describe("deeply nested composition", () => {
311
+ it("intersect → andThen", () => {
312
+ const schedule = Schedule.intersect(
313
+ Schedule.recurs(2),
314
+ Schedule.recurs(3)
315
+ ).pipe(Schedule.andThen(Schedule.recurs(2)));
316
+
317
+ const d = delays(schedule, 10);
318
+ expect(d.length).toBe(4); // min(2, 3) + 2 = 4
319
+
320
+ const secondRun = delays(schedule, 10);
321
+ expect(secondRun.length).toBe(4);
322
+ });
323
+
324
+ it("union(intersect(...), andThen(...))", () => {
325
+ const schedule = Schedule.union(
326
+ Schedule.intersect(
327
+ Schedule.recurs(1).pipe(Schedule.andThen(Schedule.recurs(1))),
328
+ Schedule.recurs(3)
329
+ ),
330
+ Schedule.recurs(1).pipe(Schedule.andThen(Schedule.recurs(1)))
331
+ );
332
+
333
+ const d = delays(schedule, 10);
334
+ const secondRun = delays(schedule, 10);
335
+
336
+ // Should be reusable
337
+ expect(d.length).toBe(secondRun.length);
338
+ expect(d.length).toBeGreaterThan(0);
339
+ });
340
+
341
+ it("andThen(union(...), union(...))", () => {
342
+ const schedule = Schedule.union(Schedule.recurs(1), Schedule.recurs(1))
343
+ .pipe(
344
+ Schedule.andThen(
345
+ Schedule.union(Schedule.recurs(1), Schedule.recurs(1))
346
+ )
347
+ );
348
+
349
+ const d = delays(schedule, 10);
350
+ expect(d.length).toBe(2); // 1 from first union + 1 from second union
351
+
352
+ const secondRun = delays(schedule, 10);
353
+ expect(secondRun.length).toBe(2);
354
+ });
355
+ });
356
+
357
+ // ============================================================================
358
+ // Running Schedules
359
+ // ============================================================================
360
+
361
+ describe("Schedule.run", () => {
362
+ it("provides step-by-step iteration", () => {
363
+ const schedule = Schedule.recurs<undefined>(3);
364
+
365
+ const runner = Schedule.run(schedule);
366
+
367
+ const step1 = runner.next(undefined);
368
+ expect(step1.done).toBe(false);
369
+ if (!step1.done) {
370
+ expect(step1.value.output).toBe(0);
371
+ }
372
+
373
+ const step2 = runner.next(undefined);
374
+ expect(step2.done).toBe(false);
375
+ if (!step2.done) {
376
+ expect(step2.value.output).toBe(1);
377
+ }
378
+
379
+ const step3 = runner.next(undefined);
380
+ expect(step3.done).toBe(false);
381
+ if (!step3.done) {
382
+ expect(step3.value.output).toBe(2);
383
+ }
384
+
385
+ const step4 = runner.next(undefined);
386
+ expect(step4.done).toBe(true);
387
+ });
388
+ });
389
+
390
+ describe("Schedule.delays", () => {
391
+ it("returns all delays for testing", () => {
392
+ const schedule = Schedule.fibonacci(Duration.millis(100)).pipe(
393
+ Schedule.upTo(5)
394
+ );
395
+
396
+ const d = Schedule.delays(schedule);
397
+
398
+ expect(d.length).toBe(5);
399
+ expect(Duration.toMillis(d[0])).toBe(100);
400
+ expect(Duration.toMillis(d[1])).toBe(100);
401
+ expect(Duration.toMillis(d[2])).toBe(200);
402
+ expect(Duration.toMillis(d[3])).toBe(300);
403
+ expect(Duration.toMillis(d[4])).toBe(500);
404
+ });
405
+ });
406
+
407
+ // ============================================================================
408
+ // Real-world scenarios
409
+ // ============================================================================
410
+
411
+ describe("real-world scenarios", () => {
412
+ it("payment retry: fast attempts then slower follow-ups", () => {
413
+ const paymentRetry = Schedule.exponential(Duration.millis(200))
414
+ .pipe(Schedule.upTo(4))
415
+ .pipe(
416
+ Schedule.andThen(
417
+ Schedule.spaced(Duration.seconds(30)).pipe(Schedule.upTo(6))
418
+ )
419
+ );
420
+
421
+ const d = delays(paymentRetry, 6);
422
+
423
+ expect(Duration.toMillis(d[0])).toBe(200);
424
+ expect(Duration.toMillis(d[1])).toBe(400);
425
+ expect(Duration.toMillis(d[2])).toBe(800);
426
+ expect(Duration.toMillis(d[3])).toBe(1600);
427
+ expect(Duration.toMillis(d[4])).toBe(30000);
428
+ expect(Duration.toMillis(d[5])).toBe(30000);
429
+ });
430
+
431
+ it("shipment tracking: poll every 5 minutes", () => {
432
+ const trackShipment = Schedule.spaced(Duration.minutes(5))
433
+ .pipe(Schedule.upToElapsed(Duration.hours(24)));
434
+
435
+ const d = delays(trackShipment, 3);
436
+ d.forEach((delay) => expect(Duration.toMillis(delay)).toBe(5 * 60 * 1000));
437
+ });
438
+
439
+ it("safe API calls: rate limit plus backoff", () => {
440
+ const safeApiCalls = Schedule.intersect(
441
+ Schedule.spaced(Duration.seconds(1)),
442
+ Schedule.exponential(Duration.millis(200)).pipe(
443
+ Schedule.maxDelay(Duration.seconds(30))
444
+ )
445
+ );
446
+
447
+ const d = delays(safeApiCalls, 5);
448
+
449
+ expect(Duration.toMillis(d[0])).toBe(1000); // max(1000, 200)
450
+ expect(Duration.toMillis(d[1])).toBe(1000); // max(1000, 400)
451
+ expect(Duration.toMillis(d[2])).toBe(1000); // max(1000, 800)
452
+ expect(Duration.toMillis(d[3])).toBe(1600); // max(1000, 1600)
453
+ expect(Duration.toMillis(d[4])).toBe(3200); // max(1000, 3200)
454
+ });
455
+ });
456
+
457
+ // ============================================================================
458
+ // Transformations
459
+ // ============================================================================
460
+
461
+ describe("transformations", () => {
462
+ it("map transforms output", () => {
463
+ const schedule = Schedule.recurs<undefined>(3).pipe(
464
+ Schedule.map((n) => ({ attempt: n + 1 }))
465
+ );
466
+
467
+ const runner = Schedule.run(schedule);
468
+ const step1 = runner.next(undefined);
469
+ const step2 = runner.next(undefined);
470
+
471
+ expect(step1.done).toBe(false);
472
+ if (!step1.done) {
473
+ expect(step1.value.output).toEqual({ attempt: 1 });
474
+ }
475
+
476
+ expect(step2.done).toBe(false);
477
+ if (!step2.done) {
478
+ expect(step2.value.output).toEqual({ attempt: 2 });
479
+ }
480
+ });
481
+
482
+ it("tap performs side effects", () => {
483
+ const logged: number[] = [];
484
+ const schedule = Schedule.recurs<undefined>(3).pipe(
485
+ Schedule.tap((n) => logged.push(n))
486
+ );
487
+
488
+ delays(schedule);
489
+
490
+ expect(logged).toEqual([0, 1, 2]);
491
+ });
492
+ });
493
+
494
+ // ============================================================================
495
+ // Real-World Scenarios (business-focused)
496
+ // ============================================================================
497
+
498
+ describe("real-world scenarios (business-focused)", () => {
499
+ describe("database connection pool", () => {
500
+ it("uses aggressive retries then slower checks", () => {
501
+ const dbReconnect = Schedule.exponential(Duration.millis(100))
502
+ .pipe(Schedule.upTo(5))
503
+ .pipe(
504
+ Schedule.andThen(
505
+ Schedule.spaced(Duration.seconds(10)).pipe(Schedule.upTo(3))
506
+ )
507
+ );
508
+
509
+ const d = delays(dbReconnect, 10);
510
+
511
+ // First 5 are exponential: 100, 200, 400, 800, 1600
512
+ expect(Duration.toMillis(d[0])).toBe(100);
513
+ expect(Duration.toMillis(d[1])).toBe(200);
514
+ expect(Duration.toMillis(d[2])).toBe(400);
515
+ expect(Duration.toMillis(d[3])).toBe(800);
516
+ expect(Duration.toMillis(d[4])).toBe(1600);
517
+
518
+ // Then spaced at 10s
519
+ expect(Duration.toMillis(d[5])).toBe(10000);
520
+ expect(Duration.toMillis(d[6])).toBe(10000);
521
+ expect(Duration.toMillis(d[7])).toBe(10000);
522
+
523
+ // Total: 5 + 3 = 8
524
+ expect(d.length).toBe(8);
525
+ });
526
+
527
+ it("is reusable for multiple connection attempts", () => {
528
+ const dbReconnect = Schedule.exponential(Duration.millis(100))
529
+ .pipe(Schedule.upTo(3))
530
+ .pipe(Schedule.andThen(Schedule.spaced(Duration.seconds(1)).pipe(Schedule.upTo(2))));
531
+
532
+ const firstAttempt = delays(dbReconnect);
533
+ const secondAttempt = delays(dbReconnect);
534
+
535
+ expect(firstAttempt.length).toBe(5);
536
+ expect(secondAttempt.length).toBe(5);
537
+ });
538
+ });
539
+
540
+ describe("email service", () => {
541
+ it("respects rate limits while backing off", () => {
542
+ const emailRetry = Schedule.intersect(
543
+ Schedule.spaced(Duration.millis(200)), // Rate limit: 5/second
544
+ Schedule.exponential(Duration.millis(100))
545
+ .pipe(Schedule.maxDelay(Duration.seconds(60)))
546
+ ).pipe(Schedule.upTo(5));
547
+
548
+ const d = delays(emailRetry);
549
+
550
+ // intersect takes max(200, exponential)
551
+ // exponential: 100, 200, 400, 800, 1600
552
+ // spaced: 200, 200, 200, 200, 200
553
+ // max: 200, 200, 400, 800, 1600
554
+ expect(Duration.toMillis(d[0])).toBe(200);
555
+ expect(Duration.toMillis(d[1])).toBe(200);
556
+ expect(Duration.toMillis(d[2])).toBe(400);
557
+ expect(Duration.toMillis(d[3])).toBe(800);
558
+ expect(Duration.toMillis(d[4])).toBe(1600);
559
+ });
560
+
561
+ it("caps delay at maxDelay", () => {
562
+ const emailRetry = Schedule.intersect(
563
+ Schedule.spaced(Duration.millis(200)),
564
+ Schedule.exponential(Duration.millis(1000))
565
+ .pipe(Schedule.maxDelay(Duration.millis(5000)))
566
+ ).pipe(Schedule.upTo(10));
567
+
568
+ const d = delays(emailRetry);
569
+
570
+ // All delays should be capped at 5000ms
571
+ d.forEach((delay) => {
572
+ expect(Duration.toMillis(delay)).toBeLessThanOrEqual(5000);
573
+ });
574
+ });
575
+ });
576
+
577
+ describe("webhook delivery", () => {
578
+ it("uses three-phase escalating retry", () => {
579
+ const webhookDelivery = Schedule.exponential(Duration.millis(100))
580
+ .pipe(Schedule.upTo(3)) // Phase 1
581
+ .pipe(
582
+ Schedule.andThen(
583
+ Schedule.spaced(Duration.millis(500)).pipe(Schedule.upTo(2)) // Phase 2
584
+ )
585
+ )
586
+ .pipe(
587
+ Schedule.andThen(
588
+ Schedule.spaced(Duration.millis(1000)).pipe(Schedule.upTo(2)) // Phase 3
589
+ )
590
+ );
591
+
592
+ const d = delays(webhookDelivery);
593
+
594
+ // Phase 1: 100, 200, 400
595
+ expect(Duration.toMillis(d[0])).toBe(100);
596
+ expect(Duration.toMillis(d[1])).toBe(200);
597
+ expect(Duration.toMillis(d[2])).toBe(400);
598
+
599
+ // Phase 2: 500, 500
600
+ expect(Duration.toMillis(d[3])).toBe(500);
601
+ expect(Duration.toMillis(d[4])).toBe(500);
602
+
603
+ // Phase 3: 1000, 1000
604
+ expect(Duration.toMillis(d[5])).toBe(1000);
605
+ expect(Duration.toMillis(d[6])).toBe(1000);
606
+
607
+ expect(d.length).toBe(7);
608
+ });
609
+
610
+ it("is reusable across multiple webhook deliveries", () => {
611
+ const webhookDelivery = Schedule.exponential(Duration.millis(100))
612
+ .pipe(Schedule.upTo(2))
613
+ .pipe(Schedule.andThen(Schedule.spaced(Duration.millis(500)).pipe(Schedule.upTo(2))));
614
+
615
+ const first = delays(webhookDelivery);
616
+ const second = delays(webhookDelivery);
617
+
618
+ expect(first.length).toBe(4);
619
+ expect(second.length).toBe(4);
620
+ expect(Duration.toMillis(first[0])).toBe(Duration.toMillis(second[0]));
621
+ });
622
+ });
623
+
624
+ describe("background job processing", () => {
625
+ it("uses union for steady pace with backoff ceiling", () => {
626
+ const jobProcessing = Schedule.union(
627
+ Schedule.spaced(Duration.millis(100)), // Normal: 10 jobs/second
628
+ Schedule.exponential(Duration.millis(50))
629
+ .pipe(Schedule.maxDelay(Duration.millis(500)))
630
+ ).pipe(Schedule.upTo(6));
631
+
632
+ const d = delays(jobProcessing);
633
+
634
+ // union takes min(100, exponential)
635
+ // exponential: 50, 100, 200, 400, 500 (capped), 500
636
+ // spaced: 100, 100, 100, 100, 100, 100
637
+ // min: 50, 100, 100, 100, 100, 100
638
+ expect(Duration.toMillis(d[0])).toBe(50);
639
+ expect(Duration.toMillis(d[1])).toBe(100);
640
+ expect(Duration.toMillis(d[2])).toBe(100);
641
+ expect(Duration.toMillis(d[3])).toBe(100);
642
+ });
643
+
644
+ it("with jitter adds variation", () => {
645
+ const jobProcessing = Schedule.union(
646
+ Schedule.spaced(Duration.millis(100)),
647
+ Schedule.exponential(Duration.millis(50))
648
+ )
649
+ .pipe(Schedule.jittered(0.1))
650
+ .pipe(Schedule.upTo(10));
651
+
652
+ const d = delays(jobProcessing);
653
+
654
+ // With jitter, not all delays should be exactly the same
655
+ const allSame = d.every(
656
+ (delay) => Duration.toMillis(delay) === Duration.toMillis(d[0])
657
+ );
658
+ expect(allSame).toBe(false);
659
+ });
660
+ });
661
+ });
662
+
663
+ // ============================================================================
664
+ // Real-World Patterns
665
+ // ============================================================================
666
+
667
+ describe("real-world patterns", () => {
668
+ it("exponential backoff with jitter and cap", () => {
669
+ const httpRetry = Schedule.exponential(Duration.millis(100))
670
+ .pipe(Schedule.jittered(0.2))
671
+ .pipe(Schedule.maxDelay(Duration.seconds(30)))
672
+ .pipe(Schedule.upTo(5));
673
+
674
+ const d = delays(httpRetry);
675
+
676
+ expect(d.length).toBe(5);
677
+ // All delays should be capped at 30s
678
+ d.forEach((delay) => {
679
+ expect(Duration.toMillis(delay)).toBeLessThanOrEqual(30000);
680
+ });
681
+ });
682
+
683
+ it("two-phase retry strategy", () => {
684
+ const twoPhase = Schedule.exponential(Duration.millis(50))
685
+ .pipe(Schedule.upTo(3))
686
+ .pipe(
687
+ Schedule.andThen(
688
+ Schedule.spaced(Duration.millis(1000)).pipe(Schedule.upTo(2))
689
+ )
690
+ );
691
+
692
+ const d = delays(twoPhase);
693
+
694
+ // 3 exponential + 2 spaced = 5
695
+ expect(d.length).toBe(5);
696
+
697
+ // First 3 are exponential
698
+ expect(Duration.toMillis(d[0])).toBe(50);
699
+ expect(Duration.toMillis(d[1])).toBe(100);
700
+ expect(Duration.toMillis(d[2])).toBe(200);
701
+
702
+ // Last 2 are spaced
703
+ expect(Duration.toMillis(d[3])).toBe(1000);
704
+ expect(Duration.toMillis(d[4])).toBe(1000);
705
+ });
706
+
707
+ it("adaptive rate limiting with intersect", () => {
708
+ const adaptive = Schedule.intersect(
709
+ Schedule.spaced(Duration.millis(100)), // Rate limit: 10 req/s
710
+ Schedule.exponential(Duration.millis(50)) // Back off on failures
711
+ ).pipe(Schedule.upTo(5));
712
+
713
+ const d = delays(adaptive);
714
+
715
+ expect(d.length).toBe(5);
716
+ // First delays should be 100ms (spaced wins over small exponential)
717
+ expect(Duration.toMillis(d[0])).toBe(100);
718
+ expect(Duration.toMillis(d[1])).toBe(100);
719
+ });
720
+
721
+ it("aggressive retry with union fallback", () => {
722
+ const aggressive = Schedule.union(
723
+ Schedule.exponential(Duration.millis(100)),
724
+ Schedule.spaced(Duration.millis(500)) // Never wait more than 500ms
725
+ ).pipe(Schedule.upTo(5));
726
+
727
+ const d = delays(aggressive);
728
+
729
+ expect(d.length).toBe(5);
730
+ // All delays should be at most 500ms (union takes min)
731
+ d.forEach((delay) => {
732
+ expect(Duration.toMillis(delay)).toBeLessThanOrEqual(500);
733
+ });
734
+ });
735
+ });
736
+ });