@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.
- package/README.md +1197 -20
- package/dist/duration.cjs +2 -0
- package/dist/duration.cjs.map +1 -0
- package/dist/duration.d.cts +246 -0
- package/dist/duration.d.ts +246 -0
- package/dist/duration.js +2 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/match.cjs +2 -0
- package/dist/match.cjs.map +1 -0
- package/dist/match.d.cts +216 -0
- package/dist/match.d.ts +216 -0
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -0
- package/dist/schedule.cjs +2 -0
- package/dist/schedule.cjs.map +1 -0
- package/dist/schedule.d.cts +387 -0
- package/dist/schedule.d.ts +387 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/dist/visualize.cjs +73 -67
- package/dist/visualize.cjs.map +1 -1
- package/dist/visualize.d.cts +13 -1
- package/dist/visualize.d.ts +13 -1
- package/dist/visualize.js +73 -67
- package/dist/visualize.js.map +1 -1
- package/docs/api.md +30 -0
- package/docs/coming-from-neverthrow.md +103 -10
- package/docs/effect-features-to-port.md +210 -0
- package/docs/match-examples.test.ts +558 -0
- package/docs/match.md +417 -0
- package/docs/pino-logging-example.md +293 -0
- package/docs/policies-examples.test.ts +750 -0
- package/docs/policies.md +508 -0
- package/docs/resource-management-examples.test.ts +729 -0
- package/docs/resource-management.md +509 -0
- package/docs/schedule-examples.test.ts +736 -0
- package/docs/schedule.md +467 -0
- package/docs/tagged-error-examples.test.ts +494 -0
- package/docs/tagged-error.md +730 -0
- package/docs/visualization-examples.test.ts +663 -0
- package/docs/visualization.md +430 -0
- package/docs/visualize-examples.md +1 -1
- 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
|
+
});
|