@evolu/common 8.0.0-next.4 → 8.0.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 (237) hide show
  1. package/dist/src/Array.d.ts +279 -173
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +105 -101
  4. package/dist/src/Assert.d.ts +44 -44
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +44 -32
  7. package/dist/src/BigInt.d.ts +6 -3
  8. package/dist/src/BigInt.d.ts.map +1 -1
  9. package/dist/src/BigInt.js +5 -2
  10. package/dist/src/Brand.d.ts +27 -40
  11. package/dist/src/Brand.d.ts.map +1 -1
  12. package/dist/src/Buffer.d.ts +27 -11
  13. package/dist/src/Buffer.d.ts.map +1 -1
  14. package/dist/src/Cache.d.ts +9 -4
  15. package/dist/src/Cache.d.ts.map +1 -1
  16. package/dist/src/Cache.js +8 -3
  17. package/dist/src/Callbacks.d.ts +30 -19
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Console.d.ts +145 -66
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +91 -43
  22. package/dist/src/Crypto.d.ts +85 -42
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +44 -20
  25. package/dist/src/Eq.d.ts +87 -21
  26. package/dist/src/Eq.d.ts.map +1 -1
  27. package/dist/src/Eq.js +67 -19
  28. package/dist/src/Error.d.ts +5 -3
  29. package/dist/src/Error.d.ts.map +1 -1
  30. package/dist/src/Error.js +16 -0
  31. package/dist/src/Function.d.ts +119 -58
  32. package/dist/src/Function.d.ts.map +1 -1
  33. package/dist/src/Function.js +86 -43
  34. package/dist/src/Http.d.ts +394 -0
  35. package/dist/src/Http.d.ts.map +1 -0
  36. package/dist/src/Http.js +102 -0
  37. package/dist/src/Identicon.d.ts +16 -7
  38. package/dist/src/Identicon.d.ts.map +1 -1
  39. package/dist/src/Identicon.js +14 -5
  40. package/dist/src/LeakDetector.d.ts +88 -0
  41. package/dist/src/LeakDetector.d.ts.map +1 -0
  42. package/dist/src/LeakDetector.js +86 -0
  43. package/dist/src/LockManager.d.ts +18 -9
  44. package/dist/src/LockManager.d.ts.map +1 -1
  45. package/dist/src/LockManager.js +7 -8
  46. package/dist/src/Lookup.d.ts +43 -13
  47. package/dist/src/Lookup.d.ts.map +1 -1
  48. package/dist/src/Lookup.js +28 -8
  49. package/dist/src/Microtask.d.ts +1 -1
  50. package/dist/src/Microtask.d.ts.map +1 -1
  51. package/dist/src/Microtask.js +3 -7
  52. package/dist/src/Number.d.ts +49 -13
  53. package/dist/src/Number.d.ts.map +1 -1
  54. package/dist/src/Number.js +34 -5
  55. package/dist/src/Object.d.ts +160 -40
  56. package/dist/src/Object.d.ts.map +1 -1
  57. package/dist/src/Object.js +92 -46
  58. package/dist/src/Option.d.ts +10 -7
  59. package/dist/src/Option.d.ts.map +1 -1
  60. package/dist/src/Option.js +9 -6
  61. package/dist/src/Order.d.ts +21 -21
  62. package/dist/src/Order.d.ts.map +1 -1
  63. package/dist/src/Order.js +18 -21
  64. package/dist/src/Platform.d.ts +31 -0
  65. package/dist/src/Platform.d.ts.map +1 -1
  66. package/dist/src/Platform.js +98 -0
  67. package/dist/src/Random.d.ts +32 -17
  68. package/dist/src/Random.d.ts.map +1 -1
  69. package/dist/src/Random.js +12 -8
  70. package/dist/src/Redacted.d.ts +43 -29
  71. package/dist/src/Redacted.d.ts.map +1 -1
  72. package/dist/src/Redacted.js +14 -3
  73. package/dist/src/Ref.d.ts +11 -3
  74. package/dist/src/Ref.d.ts.map +1 -1
  75. package/dist/src/Ref.js +2 -2
  76. package/dist/src/RefCount.d.ts +2 -6
  77. package/dist/src/RefCount.d.ts.map +1 -1
  78. package/dist/src/RefCount.js +33 -90
  79. package/dist/src/Relation.d.ts +70 -5
  80. package/dist/src/Relation.d.ts.map +1 -1
  81. package/dist/src/Relation.js +77 -2
  82. package/dist/src/Resource.d.ts +590 -155
  83. package/dist/src/Resource.d.ts.map +1 -1
  84. package/dist/src/Resource.js +592 -343
  85. package/dist/src/Result.d.ts +838 -191
  86. package/dist/src/Result.d.ts.map +1 -1
  87. package/dist/src/Result.js +163 -102
  88. package/dist/src/Schedule.d.ts +684 -231
  89. package/dist/src/Schedule.d.ts.map +1 -1
  90. package/dist/src/Schedule.js +766 -295
  91. package/dist/src/Set.d.ts +89 -40
  92. package/dist/src/Set.d.ts.map +1 -1
  93. package/dist/src/Set.js +50 -29
  94. package/dist/src/Sqlite.d.ts +29 -72
  95. package/dist/src/Sqlite.d.ts.map +1 -1
  96. package/dist/src/Sqlite.js +41 -46
  97. package/dist/src/StackTrace.d.ts +40 -0
  98. package/dist/src/StackTrace.d.ts.map +1 -0
  99. package/dist/src/StackTrace.js +140 -0
  100. package/dist/src/Store.d.ts +5 -4
  101. package/dist/src/Store.d.ts.map +1 -1
  102. package/dist/src/Store.js +5 -4
  103. package/dist/src/String.d.ts +7 -1
  104. package/dist/src/String.d.ts.map +1 -1
  105. package/dist/src/String.js +20 -7
  106. package/dist/src/Task.d.ts +3572 -1770
  107. package/dist/src/Task.d.ts.map +1 -1
  108. package/dist/src/Task.js +1879 -1274
  109. package/dist/src/Test.d.ts +17 -125
  110. package/dist/src/Test.d.ts.map +1 -1
  111. package/dist/src/Test.js +17 -68
  112. package/dist/src/Time.d.ts +108 -49
  113. package/dist/src/Time.d.ts.map +1 -1
  114. package/dist/src/Time.js +172 -73
  115. package/dist/src/Type.d.ts +3542 -1926
  116. package/dist/src/Type.d.ts.map +1 -1
  117. package/dist/src/Type.js +3070 -1784
  118. package/dist/src/Types.d.ts +200 -108
  119. package/dist/src/Types.d.ts.map +1 -1
  120. package/dist/src/Types.js +39 -8
  121. package/dist/src/WebSocket.d.ts +48 -23
  122. package/dist/src/WebSocket.d.ts.map +1 -1
  123. package/dist/src/WebSocket.js +15 -6
  124. package/dist/src/Worker.d.ts +94 -36
  125. package/dist/src/Worker.d.ts.map +1 -1
  126. package/dist/src/Worker.js +58 -24
  127. package/dist/src/index.d.ts +55 -52
  128. package/dist/src/index.d.ts.map +1 -1
  129. package/dist/src/index.js +3 -1
  130. package/dist/src/intl/cs.d.ts +94 -0
  131. package/dist/src/intl/cs.d.ts.map +1 -0
  132. package/dist/src/intl/cs.js +220 -0
  133. package/dist/src/intl/index.d.ts +7 -0
  134. package/dist/src/intl/index.d.ts.map +1 -0
  135. package/dist/src/intl/index.js +6 -0
  136. package/dist/src/local-first/Db.d.ts +12 -8
  137. package/dist/src/local-first/Db.d.ts.map +1 -1
  138. package/dist/src/local-first/Db.js +109 -132
  139. package/dist/src/local-first/Error.d.ts +4 -4
  140. package/dist/src/local-first/Evolu.d.ts +338 -92
  141. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  142. package/dist/src/local-first/Evolu.js +47 -55
  143. package/dist/src/local-first/LocalAuth.d.ts +4 -4
  144. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.d.ts +61 -24
  146. package/dist/src/local-first/Owner.d.ts.map +1 -1
  147. package/dist/src/local-first/Owner.js +45 -8
  148. package/dist/src/local-first/Protocol.d.ts +44 -31
  149. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  150. package/dist/src/local-first/Protocol.js +36 -13
  151. package/dist/src/local-first/Query.d.ts +147 -62
  152. package/dist/src/local-first/Query.d.ts.map +1 -1
  153. package/dist/src/local-first/Query.js +112 -58
  154. package/dist/src/local-first/Relay.d.ts +44 -14
  155. package/dist/src/local-first/Relay.d.ts.map +1 -1
  156. package/dist/src/local-first/Schema.d.ts +92 -41
  157. package/dist/src/local-first/Schema.d.ts.map +1 -1
  158. package/dist/src/local-first/Schema.js +25 -8
  159. package/dist/src/local-first/Shared.d.ts +23 -21
  160. package/dist/src/local-first/Shared.d.ts.map +1 -1
  161. package/dist/src/local-first/Shared.js +100 -57
  162. package/dist/src/local-first/Storage.d.ts +53 -32
  163. package/dist/src/local-first/Storage.d.ts.map +1 -1
  164. package/dist/src/local-first/Storage.js +194 -234
  165. package/dist/src/local-first/Timestamp.d.ts +21 -21
  166. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  167. package/dist/src/local-first/Timestamp.js +2 -2
  168. package/dist/src/local-first/index.d.ts +11 -11
  169. package/package.json +14 -13
  170. package/src/Array.ts +306 -205
  171. package/src/Assert.ts +54 -69
  172. package/src/BigInt.ts +6 -3
  173. package/src/Brand.ts +27 -40
  174. package/src/Buffer.ts +28 -12
  175. package/src/Cache.ts +9 -4
  176. package/src/Callbacks.ts +30 -19
  177. package/src/Console.ts +156 -84
  178. package/src/Crypto.ts +83 -41
  179. package/src/Eq.ts +95 -22
  180. package/src/Error.ts +8 -4
  181. package/src/Function.ts +167 -59
  182. package/src/Http.ts +549 -0
  183. package/src/Identicon.ts +16 -7
  184. package/src/LeakDetector.ts +182 -0
  185. package/src/LockManager.ts +43 -39
  186. package/src/Lookup.ts +62 -28
  187. package/src/Microtask.ts +19 -23
  188. package/src/Number.ts +64 -12
  189. package/src/Object.ts +173 -49
  190. package/src/Option.ts +10 -7
  191. package/src/Order.ts +23 -21
  192. package/src/Platform.ts +136 -0
  193. package/src/Polyfills.ts +6 -12
  194. package/src/Random.ts +32 -19
  195. package/src/Redacted.ts +44 -30
  196. package/src/Ref.ts +14 -6
  197. package/src/RefCount.ts +38 -80
  198. package/src/Relation.ts +202 -10
  199. package/src/Resource.ts +1199 -591
  200. package/src/Result.ts +930 -271
  201. package/src/Schedule.ts +859 -343
  202. package/src/Set.ts +89 -40
  203. package/src/Sqlite.ts +144 -138
  204. package/src/StackTrace.ts +230 -0
  205. package/src/Store.ts +8 -7
  206. package/src/String.ts +22 -5
  207. package/src/Task.ts +5101 -2832
  208. package/src/Test.ts +20 -168
  209. package/src/Time.ts +293 -90
  210. package/src/Type.ts +10034 -4117
  211. package/src/Types.ts +227 -110
  212. package/src/WebSocket.ts +64 -36
  213. package/src/Worker.ts +180 -72
  214. package/src/index.ts +55 -52
  215. package/src/intl/cs.ts +388 -0
  216. package/src/intl/index.ts +7 -0
  217. package/src/local-first/Db.ts +152 -179
  218. package/src/local-first/Error.ts +4 -4
  219. package/src/local-first/Evolu.ts +495 -250
  220. package/src/local-first/LocalAuth.ts +6 -12
  221. package/src/local-first/Owner.ts +58 -21
  222. package/src/local-first/Protocol.ts +49 -31
  223. package/src/local-first/Query.ts +146 -61
  224. package/src/local-first/Relay.ts +51 -21
  225. package/src/local-first/Schema.ts +109 -51
  226. package/src/local-first/Shared.ts +266 -226
  227. package/src/local-first/Storage.ts +266 -264
  228. package/src/local-first/Timestamp.ts +25 -18
  229. package/src/local-first/index.ts +11 -11
  230. package/dist/src/Skiplist.d.ts +0 -28
  231. package/dist/src/Skiplist.d.ts.map +0 -1
  232. package/dist/src/Skiplist.js +0 -64
  233. package/dist/src/Tracer.d.ts +0 -48
  234. package/dist/src/Tracer.d.ts.map +0 -1
  235. package/dist/src/Tracer.js +0 -6
  236. package/src/Skiplist.ts +0 -108
  237. package/src/Tracer.ts +0 -54
@@ -3,42 +3,25 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- import { assertType } from "./Assert.js";
7
- import { fibonacciAt, FibonacciIndex, increment } from "./Number.js";
6
+ import { fibonacciAt, FibonacciIndex, increment, max, min, percentageToRatio, } from "./Number.js";
8
7
  import { done, err, ok } from "./Result.js";
9
- import { durationToMillis, Millis, minMillis, saturateMillis, } from "./Time.js";
10
- import { NonNegativeFiniteNumber, NonNegativeInt, onePositiveInt, PositiveInt, } from "./Type.js";
11
- /**
12
- * Creates an internal per-step metrics tracker.
13
- *
14
- * Each call updates internal state and returns computed metrics.
15
- */
16
- const createScheduleStepMetrics = (deps) => {
17
- let attempt = onePositiveInt;
18
- let start = null;
19
- let previous = null;
20
- return () => {
21
- const now = deps.time.now();
22
- const currentAttempt = attempt;
23
- attempt = PositiveInt.orThrow(increment(attempt));
24
- start ??= now;
25
- const elapsed = saturateMillis(now - start);
26
- const elapsedSincePrevious = previous === null ? minMillis : saturateMillis(now - previous);
27
- previous = now;
28
- return { attempt: currentAttempt, elapsed, elapsedSincePrevious };
29
- };
30
- };
8
+ import { durationToMillis, Millis, minMillis, PositiveMillis, saturateMillis, } from "./Time.js";
9
+ import { assertType, NonNaNNumber, NonNegativeFiniteNumber, PositiveInt, } from "./Type.js";
31
10
  /**
32
11
  * A schedule that never stops and has no delay.
33
12
  *
34
13
  * Outputs the attempt count (0, 1, 2, ...). Useful as a base for composition or
35
14
  * for immediate retry without backoff.
36
15
  *
37
- * ### Example
16
+ * ### Recurring immediately
38
17
  *
39
18
  * ```ts
40
- * // Retry immediately, up to 5 times
19
+ * import { forever, take, testCreateDeps } from "@evolu/common";
20
+ *
21
+ * // Retry immediately, up to 5 times.
41
22
  * const immediate = take(5)(forever);
23
+ * const step = immediate(testCreateDeps());
24
+ * expectOk(step(undefined), [0, 0]);
42
25
  * ```
43
26
  *
44
27
  * @group Constructors
@@ -50,13 +33,18 @@ export const forever = () => {
50
33
  /**
51
34
  * A schedule that runs exactly once with no delay.
52
35
  *
53
- * Convenience for `take(1)(forever)`. Useful for one-shot operations.
36
+ * Equivalent to `take(1)(forever)`. Useful for one-shot operations.
54
37
  *
55
- * ### Example
38
+ * ### Scheduling one recurrence
56
39
  *
57
40
  * ```ts
58
- * // Execute once, no retry
41
+ * import { done, once, testCreateDeps } from "@evolu/common";
42
+ *
43
+ * // Produce one scheduled recurrence, then stop.
59
44
  * const oneShot = once;
45
+ * const step = oneShot(testCreateDeps());
46
+ * expectOk(step(undefined), [0, 0]);
47
+ * expectErr(step(undefined), done());
60
48
  * ```
61
49
  *
62
50
  * @group Constructors
@@ -76,11 +64,21 @@ export const once = () => {
76
64
  * Convenience for `take(n)(forever)`. Outputs the current repetition count (0,
77
65
  * 1, 2, ..., n-1).
78
66
  *
79
- * ### Example
67
+ * `n` uses {@link Int0To100OrNonNegativeInt}: pass `0` to `100` as a literal, or
68
+ * a validated {@link NonNegativeInt} for larger or dynamic values.
69
+ *
70
+ * ### Limiting recurrence count
80
71
  *
81
72
  * ```ts
82
- * // Retry up to 3 times (4 total attempts including initial)
73
+ * import { done, recurs, testCreateDeps } from "@evolu/common";
74
+ *
75
+ * // Retry up to 3 times (4 total attempts including the initial operation).
83
76
  * const retry = recurs(3);
77
+ * const step = retry(testCreateDeps());
78
+ * expectOk(step(undefined), [0, 0]);
79
+ * step(undefined);
80
+ * step(undefined);
81
+ * expectErr(step(undefined), done());
84
82
  * ```
85
83
  *
86
84
  * @group Constructors
@@ -92,17 +90,20 @@ export const recurs = (n) => take(n)(forever);
92
90
  * Always waits the same duration after each execution completes. Never stops —
93
91
  * combine with {@link take} or {@link maxElapsed} to limit.
94
92
  *
95
- * ### Example
93
+ * ### Constant spacing
96
94
  *
97
95
  * ```ts
98
- * // 1s, 1s, 1s, ... (polling)
99
- * const poll = spaced("1s");
96
+ * import { spaced, take, testCreateDeps } from "@evolu/common";
100
97
  *
101
- * // Retry 3 times with 500ms between each
98
+ * // Poll every second, retry three times, or run a long-lived heartbeat.
99
+ * const poll = spaced("1s");
102
100
  * const retry = take(3)(spaced("500ms"));
103
- *
104
- * // Heartbeat schedule
105
101
  * const heartbeat = spaced("30s");
102
+ * const deps = testCreateDeps();
103
+ *
104
+ * expectOk(poll(deps)(undefined), [1000, 1000]);
105
+ * expectOk(retry(deps)(undefined), [500, 500]);
106
+ * expectOk(heartbeat(deps)(undefined), [30000, 30000]);
106
107
  * ```
107
108
  *
108
109
  * @group Constructors
@@ -114,57 +115,71 @@ export const spaced = (duration) => () => {
114
115
  /**
115
116
  * Exponential backoff schedule.
116
117
  *
117
- * Computes delay as `base * factor^(attempt - 1)`:
118
+ * Computes delay as `base * factor^(step - 1)`:
118
119
  *
119
- * - Attempt 1: `base`
120
- * - Attempt 2: `base * factor`
121
- * - Attempt 3: `base * factor²`
120
+ * - Step 1: `base`
121
+ * - Step 2: `base * factor`
122
+ * - Step 3: `base * factor²`
122
123
  * - ...
123
124
  *
124
125
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
125
126
  *
126
- * ### Example
127
+ * ### Exponential growth factors
127
128
  *
128
129
  * ```ts
129
- * // 100ms, 200ms, 400ms, 800ms, ...
130
- * const exp = exponential("100ms");
130
+ * import { exponential, testCreateDeps } from "@evolu/common";
131
131
  *
132
- * // 100ms, 150ms, 225ms, 338ms, ... (gentler growth)
132
+ * // Standard doubling and gentler 1.5× growth.
133
+ * const standard = exponential("100ms");
133
134
  * const gentle = exponential("100ms", 1.5);
135
+ * const standardStep = standard(testCreateDeps());
136
+ * const gentleStep = gentle(testCreateDeps());
137
+ * expectOk(standardStep(undefined), [100, 100]);
138
+ * expectOk(standardStep(undefined), [200, 200]);
139
+ * expectOk(gentleStep(undefined), [100, 100]);
140
+ * expectOk(gentleStep(undefined), [150, 150]);
134
141
  * ```
135
142
  *
136
143
  * @group Constructors
137
144
  */
138
145
  export const exponential = (base, factor = 2) => {
139
- assertType(NonNegativeFiniteNumber, factor, "Expected factor to be a non-negative finite number.");
146
+ assertType(NonNegativeFiniteNumber, factor);
140
147
  return () => {
141
148
  const baseMs = durationToMillis(base);
142
149
  let attempt = 0;
143
150
  return () => {
144
151
  attempt++;
145
- const rawDelay = baseMs * Math.pow(factor, attempt - 1);
146
- const delay = saturateMillis(rawDelay);
152
+ const rawDelay = baseMs === 0 ? minMillis : baseMs * Math.pow(factor, attempt - 1);
153
+ const delay = saturateComputedMillis(rawDelay);
147
154
  return ok([delay, delay]);
148
155
  };
149
156
  };
150
157
  };
158
+ const saturateComputedMillis = (value) => {
159
+ assertType(NonNaNNumber, value);
160
+ return saturateMillis(value);
161
+ };
151
162
  /**
152
163
  * Linear backoff schedule.
153
164
  *
154
- * Delay increases linearly: `base * attempt`:
165
+ * Delay increases linearly: `base * step`:
155
166
  *
156
- * - Attempt 1: `base`
157
- * - Attempt 2: `base * 2`
158
- * - Attempt 3: `base * 3`
167
+ * - Step 1: `base`
168
+ * - Step 2: `base * 2`
169
+ * - Step 3: `base * 3`
159
170
  * - ...
160
171
  *
161
172
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
162
173
  *
163
- * ### Example
174
+ * ### Linear growth
164
175
  *
165
176
  * ```ts
177
+ * import { linear, testCreateDeps } from "@evolu/common";
178
+ *
166
179
  * // 100ms, 200ms, 300ms, 400ms, ...
167
- * const lin = linear("100ms");
180
+ * const step = linear("100ms")(testCreateDeps());
181
+ * expectOk(step(undefined), [100, 100]);
182
+ * expectOk(step(undefined), [200, 200]);
168
183
  * ```
169
184
  *
170
185
  * @group Constructors
@@ -174,7 +189,7 @@ export const linear = (base) => () => {
174
189
  let attempt = 0;
175
190
  return () => {
176
191
  attempt++;
177
- const delay = saturateMillis(ms * attempt);
192
+ const delay = saturateComputedMillis(ms * attempt);
178
193
  return ok([delay, delay]);
179
194
  };
180
195
  };
@@ -183,20 +198,25 @@ export const linear = (base) => () => {
183
198
  *
184
199
  * Delays follow the Fibonacci sequence, growing more slowly than exponential:
185
200
  *
186
- * - Attempt 1: `initial`
187
- * - Attempt 2: `initial`
188
- * - Attempt 3: `initial * 2`
189
- * - Attempt 4: `initial * 3`
190
- * - Attempt 5: `initial * 5`
201
+ * - Step 1: `initial`
202
+ * - Step 2: `initial`
203
+ * - Step 3: `initial * 2`
204
+ * - Step 4: `initial * 3`
205
+ * - Step 5: `initial * 5`
191
206
  * - ...
192
207
  *
193
208
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
194
209
  *
195
- * ### Example
210
+ * ### Fibonacci growth
196
211
  *
197
212
  * ```ts
198
- * // 100ms, 100ms, 200ms, 300ms, 500ms, 800ms, ...
199
- * const fib = fibonacci("100ms");
213
+ * import { fibonacci, testCreateDeps } from "@evolu/common";
214
+ *
215
+ * // 100ms, 100ms, 200ms, 300ms, 500ms, ...
216
+ * const step = fibonacci("100ms")(testCreateDeps());
217
+ * expectOk(step(undefined), [100, 100]);
218
+ * expectOk(step(undefined), [100, 100]);
219
+ * expectOk(step(undefined), [200, 200]);
200
220
  * ```
201
221
  *
202
222
  * @group Constructors
@@ -205,7 +225,7 @@ export const fibonacci = (initial) => () => {
205
225
  const ms = durationToMillis(initial);
206
226
  let index = FibonacciIndex.orThrow(1);
207
227
  return () => {
208
- const delay = saturateMillis(ms * fibonacciAt(index));
228
+ const delay = saturateComputedMillis(ms * fibonacciAt(index));
209
229
  index = FibonacciIndex.orNull(increment(index)) ?? index;
210
230
  return ok([delay, delay]);
211
231
  };
@@ -215,20 +235,27 @@ export const fibonacci = (initial) => () => {
215
235
  *
216
236
  * Recurs on a fixed interval, outputting the repetition count (0, 1, 2, ...).
217
237
  * Unlike {@link spaced}, which waits a duration _after_ each execution, `fixed`
218
- * maintains a consistent cadence from when the schedule started.
238
+ * maintains a consistent cadence from the first schedule step.
219
239
  *
220
- * If execution takes longer than the interval, the next execution happens
221
- * immediately but subsequent runs still align to the original window
222
- * boundaries. This prevents "pile-up" while maintaining predictable timing.
240
+ * If execution falls behind by one or more intervals, missed recurrences happen
241
+ * immediately until the schedule catches up to the original cadence. Use
242
+ * {@link windowed} to skip missed recurrences instead.
223
243
  *
224
- * ### Example
244
+ * ### Maintaining a fixed cadence
225
245
  *
226
246
  * ```ts
227
- * // Health check every 5 seconds, aligned to windows
228
- * const healthCheck = take(10)(fixed("5s"));
247
+ * import { fixed, take, testCreateDeps } from "@evolu/common";
229
248
  *
230
- * // Cron-like behavior: run at consistent intervals
249
+ * // A bounded health check and an unbounded cron-like cadence.
250
+ * const healthCheck = take(10)(fixed("5s"));
231
251
  * const cronLike = fixed("1m");
252
+ *
253
+ * const healthDeps = testCreateDeps();
254
+ * const healthStep = healthCheck(healthDeps);
255
+ * expectOk(healthStep(undefined), [0, 5000]);
256
+ * healthDeps.time.advance("3s");
257
+ * expectOk(healthStep(undefined), [1, 2000]);
258
+ * expectOk(cronLike(testCreateDeps())(undefined), [0, 60000]);
232
259
  * ```
233
260
  *
234
261
  * @group Constructors
@@ -246,23 +273,50 @@ export const fixed = (interval) => (deps) => {
246
273
  const remainder = intervalMs === 0 ? 0 : elapsed % intervalMs;
247
274
  const boundary = intervalMs - remainder;
248
275
  const delay = runningBehind ? 0 : boundary;
249
- return ok([count++, saturateMillis(delay)]);
276
+ return ok([count++, saturateComputedMillis(delay)]);
277
+ };
278
+ };
279
+ /**
280
+ * Creates an internal per-step metrics tracker.
281
+ *
282
+ * Each call updates internal state and returns computed metrics.
283
+ */
284
+ const createScheduleStepMetrics = (deps) => {
285
+ let start = null;
286
+ let previous = null;
287
+ return () => {
288
+ const now = deps.time.now();
289
+ start ??= now;
290
+ const elapsed = saturateComputedMillis(now - start);
291
+ const elapsedSincePrevious = previous === null ? minMillis : saturateComputedMillis(now - previous);
292
+ previous = now;
293
+ return { elapsed, elapsedSincePrevious };
250
294
  };
251
295
  };
252
296
  /**
253
297
  * Divides the timeline into fixed windows and sleeps until the next boundary.
254
298
  *
255
- * Similar to {@link fixed}, but always sleeps until the next window boundary
256
- * regardless of when the last execution started. Outputs the repetition count.
299
+ * Similar to {@link fixed}, but skips missed recurrences and always sleeps until
300
+ * the next window boundary. Outputs the repetition count.
257
301
  *
258
- * Useful for aligning executions to regular intervals from the start time.
302
+ * Useful for aligning executions to regular intervals from the first step.
259
303
  *
260
- * ### Example
304
+ * ### Aligning to time windows
261
305
  *
262
306
  * ```ts
263
- * // Execute at regular 5-second boundaries from start
264
- * const aligned = windowed("5s");
265
- * // If elapsed is 3s, waits 2s. If elapsed is 7s, waits 3s.
307
+ * import { testCreateDeps, windowed } from "@evolu/common";
308
+ *
309
+ * const stepAfter = (elapsed: "3s" | "7s") => {
310
+ * const deps = testCreateDeps();
311
+ * const step = windowed("5s")(deps);
312
+ * step(undefined);
313
+ * deps.time.advance(elapsed);
314
+ * return step(undefined);
315
+ * };
316
+ *
317
+ * // At 3s the next boundary is 2s away; at 7s it is 3s away.
318
+ * expectOk(stepAfter("3s"), [1, 2000]);
319
+ * expectOk(stepAfter("7s"), [1, 3000]);
266
320
  * ```
267
321
  *
268
322
  * @group Constructors
@@ -275,7 +329,7 @@ export const windowed = (interval) => (deps) => {
275
329
  const { elapsed } = metrics();
276
330
  const remainder = intervalMs === 0 ? 0 : elapsed % intervalMs;
277
331
  const delay = intervalMs === 0 ? 0 : intervalMs - remainder;
278
- return ok([count++, saturateMillis(delay)]);
332
+ return ok([count++, saturateComputedMillis(delay)]);
279
333
  };
280
334
  };
281
335
  /**
@@ -283,11 +337,15 @@ export const windowed = (interval) => (deps) => {
283
337
  *
284
338
  * Convenience for `take(1)(spaced(delay))`. Useful for simple one-shot delays.
285
339
  *
286
- * ### Example
340
+ * ### Scheduling one delayed recurrence
287
341
  *
288
342
  * ```ts
289
- * // Wait 1 second then stop
290
- * const oneShot = fromDelay("1s");
343
+ * import { done, fromDelay, testCreateDeps } from "@evolu/common";
344
+ *
345
+ * // Wait one second, then stop.
346
+ * const step = fromDelay("1s")(testCreateDeps());
347
+ * expectOk(step(undefined), [1000, 1000]);
348
+ * expectErr(step(undefined), done());
291
349
  * ```
292
350
  *
293
351
  * @group Constructors
@@ -297,35 +355,57 @@ export const fromDelay = (delay) => take(1)(spaced(delay));
297
355
  * A schedule that runs through a sequence of delays.
298
356
  *
299
357
  * Convenience for sequencing single-delay schedules. Useful for predefined
300
- * retry patterns.
358
+ * retry patterns. With no delays, returns a schedule that stops immediately.
301
359
  *
302
- * ### Example
360
+ * ### Sequencing custom delays
303
361
  *
304
362
  * ```ts
305
- * // Custom retry sequence: 100ms, 500ms, 2s
363
+ * import { done, fromDelays, testCreateDeps } from "@evolu/common";
364
+ *
365
+ * // A custom retry sequence: 100ms, 500ms, then 2s.
306
366
  * const custom = fromDelays("100ms", "500ms", "2s");
367
+ * const step = custom(testCreateDeps());
368
+ * expectOk(step(undefined), [100, 100]);
369
+ * expectOk(step(undefined), [500, 500]);
370
+ * expectOk(step(undefined), [2000, 2000]);
371
+ * expectErr(step(undefined), done());
307
372
  * ```
308
373
  *
309
374
  * @group Constructors
310
375
  */
311
376
  export const fromDelays = (...delays) => sequenceSchedules(...delays.map((d) => fromDelay(d)));
312
377
  /**
313
- * A schedule that outputs the total elapsed time since the schedule started.
378
+ * A schedule that outputs the total elapsed time since its first step.
314
379
  *
315
380
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit. Useful
316
381
  * for tracking how long a retry sequence has been running.
317
382
  *
318
- * ### Example
383
+ * ### Tracking elapsed time
319
384
  *
320
385
  * ```ts
321
- * // Track elapsed time alongside retries
386
+ * import {
387
+ * done,
388
+ * elapsed,
389
+ * exponential,
390
+ * intersectSchedules,
391
+ * testCreateDeps,
392
+ * whileScheduleOutput,
393
+ * type Millis,
394
+ * } from "@evolu/common";
395
+ *
396
+ * // Track elapsed time alongside each backoff step.
322
397
  * const withTiming = intersectSchedules(exponential("100ms"), elapsed);
323
- * // Outputs: [[100, 0], [200, ~100], [400, ~300], ...]
398
+ * expectOk(withTiming(testCreateDeps())(undefined), [[100, 0], 100]);
324
399
  *
325
- * // Stop after 30 seconds of elapsed time
400
+ * // Or stop a schedule after 30 seconds of elapsed time.
326
401
  * const timeLimited = whileScheduleOutput((ms: Millis) => ms < 30000)(
327
402
  * elapsed,
328
403
  * );
404
+ * const deps = testCreateDeps();
405
+ * const step = timeLimited(deps);
406
+ * step(undefined);
407
+ * deps.time.advance("30s");
408
+ * expectErr(step(undefined), done());
329
409
  * ```
330
410
  *
331
411
  * @group Constructors
@@ -340,17 +420,31 @@ export const elapsed = (deps) => {
340
420
  * Outputs the elapsed time. Useful for time-boxed operations or combining with
341
421
  * other schedules to create time-limited variants.
342
422
  *
343
- * ### Example
423
+ * ### Time-boxing a schedule
344
424
  *
345
425
  * ```ts
346
- * // Run for at most 30 seconds
426
+ * import {
427
+ * done,
428
+ * during,
429
+ * exponential,
430
+ * intersectSchedules,
431
+ * testCreateDeps,
432
+ * } from "@evolu/common";
433
+ *
434
+ * // Run for at most 30 seconds.
347
435
  * const timeLimited = during("30s");
436
+ * const deps = testCreateDeps();
437
+ * const step = timeLimited(deps);
438
+ * expectOk(step(undefined), [0, 0]);
439
+ * deps.time.advance("30.1s");
440
+ * expectErr(step(undefined), done());
348
441
  *
349
- * // Combine with exponential for time-boxed retry
442
+ * // Combine elapsed time with backoff for a time-boxed retry.
350
443
  * const timedRetry = intersectSchedules(
351
444
  * exponential("100ms"),
352
445
  * during("10s"),
353
446
  * );
447
+ * expectOk(timedRetry(testCreateDeps())(undefined), [[100, 0], 100]);
354
448
  * ```
355
449
  *
356
450
  * @group Constructors
@@ -361,17 +455,29 @@ export const during = (duration) => whileScheduleOutput((ms) => ms <= durationTo
361
455
  *
362
456
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
363
457
  *
364
- * ### Example
458
+ * ### Adding constant output
365
459
  *
366
460
  * ```ts
367
- * // Always output "retry"
461
+ * import {
462
+ * always,
463
+ * exponential,
464
+ * intersectSchedules,
465
+ * testCreateDeps,
466
+ * } from "@evolu/common";
467
+ *
468
+ * // Always emit the same label.
368
469
  * const labeled = always("retry");
470
+ * expectOk(labeled(testCreateDeps())(undefined), ["retry", 0]);
369
471
  *
370
- * // Combine with timing
472
+ * // Add a label while preserving exponential timing.
371
473
  * const withLabel = intersectSchedules(
372
474
  * exponential("100ms"),
373
475
  * always("backoff"),
374
476
  * );
477
+ * expectOk(withLabel(testCreateDeps())(undefined), [
478
+ * [100, "backoff"],
479
+ * 100,
480
+ * ]);
375
481
  * ```
376
482
  *
377
483
  * @group Constructors
@@ -384,18 +490,17 @@ export const always = (value) => mapSchedule(() => value)(forever);
384
490
  * provided function. Never stops — combine with {@link take} or
385
491
  * {@link maxElapsed} to limit.
386
492
  *
387
- * ### Example
493
+ * ### Unfolding state
388
494
  *
389
495
  * ```ts
390
- * // Counter: 0, 1, 2, 3, ...
391
- * const counter = unfoldSchedule(0, (n) => n + 1);
496
+ * import { testCreateDeps, unfoldSchedule } from "@evolu/common";
392
497
  *
393
- * // Custom backoff: 100, 150, 225, 338, ... (×1.5 each time)
498
+ * // Unfold counters, custom backoff values, or state machines.
499
+ * const counter = unfoldSchedule(0, (n) => n + 1);
394
500
  * const customBackoff = unfoldSchedule(100, (delay) =>
395
501
  * Math.round(delay * 1.5),
396
502
  * );
397
503
  *
398
- * // State machine
399
504
  * type Phase = "init" | "warmup" | "active";
400
505
  * const phases = unfoldSchedule<Phase>("init", (phase) => {
401
506
  * switch (phase) {
@@ -407,6 +512,16 @@ export const always = (value) => mapSchedule(() => value)(forever);
407
512
  * return "active";
408
513
  * }
409
514
  * });
515
+ *
516
+ * const counterStep = counter(testCreateDeps());
517
+ * const backoffStep = customBackoff(testCreateDeps());
518
+ * const phaseStep = phases(testCreateDeps());
519
+ * expectOk(counterStep(undefined), [0, 0]);
520
+ * expectOk(counterStep(undefined), [1, 0]);
521
+ * backoffStep(undefined);
522
+ * phaseStep(undefined);
523
+ * expectOk(backoffStep(undefined), [150, 0]);
524
+ * expectOk(phaseStep(undefined), ["warmup", 0]);
410
525
  * ```
411
526
  *
412
527
  * @group Constructors
@@ -420,44 +535,59 @@ export const unfoldSchedule = (initial, next) => () => {
420
535
  };
421
536
  };
422
537
  /**
423
- * Limits a schedule to a maximum number of attempts.
538
+ * Limits a schedule to a maximum number of steps.
424
539
  *
425
- * After `n` attempts, returns `Err(Done<void>)` (stop).
540
+ * After `n` steps, returns `Err(Done<void>)` (stop).
426
541
  *
427
- * ### Example
542
+ * `n` uses {@link Int0To100OrNonNegativeInt}: pass `0` to `100` as a literal, or
543
+ * a validated {@link NonNegativeInt} for larger or dynamic values.
544
+ *
545
+ * ### Limiting a schedule
428
546
  *
429
547
  * ```ts
430
- * // Exponential backoff, max 3 retries
431
- * const limited = take(3)(exponential("100ms"));
432
- * // Attempt 1: 100ms, Attempt 2: 200ms, Attempt 3: 400ms, Attempt 4: Err(Done<void>)
548
+ * import { done, exponential, take, testCreateDeps } from "@evolu/common";
549
+ *
550
+ * // Three exponential retries, then Done.
551
+ * const step = take(3)(exponential("100ms"))(testCreateDeps());
552
+ * step(undefined);
553
+ * step(undefined);
554
+ * step(undefined);
555
+ * expectErr(step(undefined), done());
433
556
  * ```
434
557
  *
435
558
  * @group Limiting
436
559
  */
437
- export const take = (n) => {
438
- assertType(NonNegativeInt, n, "Expected n to be a non-negative integer.");
439
- return (schedule) => (deps) => {
440
- const step = schedule(deps);
441
- let attempt = 0;
442
- return (input) => {
443
- attempt++;
444
- if (attempt > n)
445
- return err(done());
446
- return step(input);
447
- };
560
+ export const take = (n) => (schedule) => (deps) => {
561
+ const step = schedule(deps);
562
+ let attempt = 0;
563
+ return (input) => {
564
+ attempt++;
565
+ if (attempt > n)
566
+ return err(done());
567
+ return step(input);
448
568
  };
449
569
  };
450
570
  /**
451
- * Limits schedule execution to a maximum elapsed time.
571
+ * Limits schedule execution to a maximum elapsed time since its first step.
452
572
  *
453
- * After `duration` has elapsed since the schedule started, returns
454
- * `Err(Done<void>)`.
573
+ * After `duration` has elapsed since the first step, returns `Err(Done<void>)`.
455
574
  *
456
- * ### Example
575
+ * ### Limiting elapsed time
457
576
  *
458
577
  * ```ts
459
- * // Retry for at most 30 seconds
460
- * const timeLimited = maxElapsed("30s")(exponential("1s"));
578
+ * import {
579
+ * done,
580
+ * exponential,
581
+ * maxElapsed,
582
+ * testCreateDeps,
583
+ * } from "@evolu/common";
584
+ *
585
+ * // Retry for at most 30 seconds.
586
+ * const deps = testCreateDeps();
587
+ * const step = maxElapsed("30s")(exponential("1s"))(deps);
588
+ * expectOk(step(undefined), [1000, 1000]);
589
+ * deps.time.advance("30s");
590
+ * expectErr(step(undefined), done());
461
591
  * ```
462
592
  *
463
593
  * @group Limiting
@@ -467,9 +597,16 @@ export const maxElapsed = (duration) => {
467
597
  return (schedule) => (deps) => {
468
598
  const step = schedule(deps);
469
599
  const metrics = createScheduleStepMetrics(deps);
600
+ let stopped = false;
470
601
  return (input) => {
602
+ if (stopped)
603
+ return err(done());
471
604
  const { elapsed } = metrics();
472
- return elapsed >= maxMs ? err(done()) : step(input);
605
+ if (elapsed >= maxMs) {
606
+ stopped = true;
607
+ return err(done());
608
+ }
609
+ return step(input);
473
610
  };
474
611
  };
475
612
  };
@@ -478,12 +615,18 @@ export const maxElapsed = (duration) => {
478
615
  *
479
616
  * If the schedule returns a delay greater than `max`, returns `max` instead.
480
617
  *
481
- * ### Example
618
+ * ### Capping delays
482
619
  *
483
620
  * ```ts
484
- * // Exponential capped at 10 seconds
485
- * const capped = maxDelay("10s")(exponential("1s"));
486
- * // 1s, 2s, 4s, 8s, 10s, 10s, 10s, ...
621
+ * import { exponential, maxDelay, testCreateDeps } from "@evolu/common";
622
+ *
623
+ * // Exponential delays grow 1s, 2s, 4s, 8s, then stay capped at 10s.
624
+ * const step = maxDelay("10s")(exponential("1s"))(testCreateDeps());
625
+ * step(undefined);
626
+ * step(undefined);
627
+ * step(undefined);
628
+ * step(undefined);
629
+ * expectOk(step(undefined), [16000, 10000]);
487
630
  * ```
488
631
  *
489
632
  * @group Limiting
@@ -497,56 +640,85 @@ export const maxDelay = (max) => {
497
640
  if (!result.ok)
498
641
  return result;
499
642
  const [output, delay] = result.value;
500
- return ok([output, Millis.orThrow(Math.min(delay, maxMs))]);
643
+ return ok([output, min(delay, maxMs)]);
501
644
  };
502
645
  };
503
646
  };
504
647
  /**
505
- * Adds randomized jitter to delays.
648
+ * Randomizes delays by up to a percentage.
506
649
  *
507
650
  * Jitter helps prevent "thundering herd" when many clients retry simultaneously
508
- * after a service recovers. The delay is randomized within a range:
651
+ * after a service recovers. By default, the original delay is the upper bound:
509
652
  *
510
- * - `factor = 0` — no jitter (original delay)
511
- * - `factor = 0.5` — delay varies ±50% (e.g., 1s becomes 500ms-1500ms)
512
- * - `factor = 1` — full jitter, delay varies 0-200% (e.g., 1s becomes 0-2s)
653
+ * - `"0%"` — no jitter (original delay)
654
+ * - `"50%"` — equal jitter, shortens the delay by up to 50%
655
+ * - `"100%"` — full jitter, shortens the delay by up to 100%
513
656
  *
514
- * ### Example
657
+ * Pass `"around"` to preserve the average delay for periodic work:
515
658
  *
516
- * ```ts
517
- * // AWS-style full jitter
518
- * const awsStyle = jitter(1)(exponential("1s"));
659
+ * - `"0%"` — no jitter (original delay)
660
+ * - `"50%"` — varies by up to 50% below or above the original delay
661
+ * - `"100%"` varies by up to 100% below or above the original delay
662
+ *
663
+ * ### Jittering below or around a delay
519
664
  *
520
- * // Conservative jitter (±25%)
521
- * const conservative = jitter(0.25)(exponential("1s"));
665
+ * ```ts
666
+ * import {
667
+ * exponential,
668
+ * jitter,
669
+ * spaced,
670
+ * testCreateDeps,
671
+ * type RandomNumber,
672
+ * } from "@evolu/common";
673
+ *
674
+ * const deps = {
675
+ * ...testCreateDeps(),
676
+ * random: { next: () => 0.5 as RandomNumber },
677
+ * };
678
+ *
679
+ * // Shorten retry delays by at most 25%.
680
+ * const conservative = jitter("25%")(exponential("1s"));
681
+ * // Poll around a 30s target cadence, from 27s to 33s.
682
+ * const polling = jitter("10%", "around")(spaced("30s"));
683
+ *
684
+ * expectOk(conservative(deps)(undefined), [1000, 875]);
685
+ * expectOk(polling(deps)(undefined), [30000, 30000]);
522
686
  * ```
523
687
  *
524
688
  * @group Delay
525
689
  */
526
- export const jitter = (factor = 0.5) => {
527
- assertType(NonNegativeFiniteNumber, factor, "Expected factor to be a non-negative finite number.");
528
- return (schedule) => (deps) => {
529
- const step = schedule(deps);
530
- return (input) => {
531
- const result = step(input);
532
- if (!result.ok)
533
- return result;
534
- const [output, delay] = result.value;
535
- const jittered = delay * (1 - factor + deps.random.next() * 2 * factor);
536
- return ok([output, saturateMillis(jittered)]);
537
- };
690
+ export const jitter = (percentage = "50%", mode = "below") => {
691
+ const ratio = percentageToRatio(percentage);
692
+ return createJitter(ratio, mode === "around" ? 1 + ratio : 1);
693
+ };
694
+ const createJitter = (factor, maxMultiplier) => (schedule) => (deps) => {
695
+ const step = schedule(deps);
696
+ return (input) => {
697
+ const result = step(input);
698
+ if (!result.ok)
699
+ return result;
700
+ const [output, delay] = result.value;
701
+ const minMultiplier = 1 - factor;
702
+ const jittered = delay *
703
+ (minMultiplier + deps.random.next() * (maxMultiplier - minMultiplier));
704
+ return ok([output, saturateComputedMillis(jittered)]);
538
705
  };
539
706
  };
540
707
  /**
541
- * Adds an initial delay before the first attempt.
708
+ * Replaces the schedule's first delay.
542
709
  *
543
- * Subsequent attempts use the schedule's normal delays.
710
+ * The first successful step uses `initialDelay` instead of the delay produced
711
+ * by the schedule. Subsequent steps use the schedule's delays unchanged.
544
712
  *
545
- * ### Example
713
+ * ### Replacing the first delay
546
714
  *
547
715
  * ```ts
548
- * // Wait 1s before first attempt, then exponential backoff
549
- * const withWarmup = delayed("1s")(exponential("100ms"));
716
+ * import { delayed, exponential, testCreateDeps } from "@evolu/common";
717
+ *
718
+ * const step = delayed("1s")(exponential("100ms"))(testCreateDeps());
719
+ * // Only the first delay is replaced; later exponential delays are unchanged.
720
+ * expectOk(step(undefined), [100, 1000]);
721
+ * expectOk(step(undefined), [200, 200]);
550
722
  * ```
551
723
  *
552
724
  * @group Delay
@@ -571,12 +743,14 @@ export const delayed = (initialDelay) => {
571
743
  /**
572
744
  * Adds a fixed delay to the schedule's existing delay.
573
745
  *
574
- * ### Example
746
+ * ### Adding to every delay
575
747
  *
576
748
  * ```ts
577
- * // Add 500ms to each delay
578
- * const slower = addDelay("500ms")(exponential("100ms"));
579
- * // Delays: 600ms, 700ms, 900ms, 1300ms, ...
749
+ * import { addDelay, exponential, testCreateDeps } from "@evolu/common";
750
+ *
751
+ * // Add 500ms to every exponential delay.
752
+ * const step = addDelay("500ms")(exponential("100ms"))(testCreateDeps());
753
+ * expectOk(step(undefined), [100, 600]);
580
754
  * ```
581
755
  *
582
756
  * @group Delay
@@ -590,14 +764,21 @@ export const addDelay = (extra) => {
590
764
  *
591
765
  * More flexible than {@link maxDelay} — can implement any delay transformation.
592
766
  *
593
- * ### Example
767
+ * ### Transforming delays
594
768
  *
595
769
  * ```ts
596
- * // Double all delays
597
- * const slower = modifyDelay((d) => d * 2)(exponential("100ms"));
770
+ * import { exponential, modifyDelay, testCreateDeps } from "@evolu/common";
771
+ *
772
+ * // Arbitrary transformations can double or cap delays.
773
+ * const slower = modifyDelay((delay) => delay * 2)(exponential("100ms"));
774
+ * // Equivalent to maxDelay("10s") for this schedule.
775
+ * const capped = modifyDelay((delay) => Math.min(delay, 10000))(
776
+ * exponential("20s"),
777
+ * );
778
+ * const deps = testCreateDeps();
598
779
  *
599
- * // Cap at 10s (equivalent to maxDelay)
600
- * const capped = modifyDelay((d) => Math.min(d, 10000))(exponential("1s"));
780
+ * expectOk(slower(deps)(undefined), [100, 200]);
781
+ * expectOk(capped(deps)(undefined), [20000, 10000]);
601
782
  * ```
602
783
  *
603
784
  * @group Delay
@@ -609,23 +790,41 @@ export const modifyDelay = (f) => (schedule) => (deps) => {
609
790
  if (!result.ok)
610
791
  return result;
611
792
  const [output, delay] = result.value;
612
- return ok([output, saturateMillis(f(delay))]);
793
+ return ok([output, saturateComputedMillis(f(delay))]);
613
794
  };
614
795
  };
615
796
  /**
616
- * Adjusts delay by subtracting execution time.
797
+ * Adjusts delay by subtracting time elapsed beyond the previously returned
798
+ * delay.
617
799
  *
618
- * A simple combinator that subtracts the previous execution time from the
619
- * schedule's delay. If execution took longer than the delay, returns 0.
800
+ * In a normal executor loop, this corresponds to the previous execution time.
801
+ * If the runtime wakes later than requested, the extra lag is also compensated.
802
+ * If execution and lag took longer than the delay, returns 0.
803
+ *
804
+ * When composing with delay-shaping combinators such as {@link maxDelay}, put
805
+ * `compensate` near the outside of the stack so it observes the final returned
806
+ * delay.
620
807
  *
621
808
  * For window-aligned scheduling, use {@link fixed} instead.
622
809
  *
623
- * ### Example
810
+ * ### Compensating for execution time
624
811
  *
625
812
  * ```ts
626
- * // Poll every 5s, accounting for execution time
627
- * const polling = compensate(spaced("5s"));
628
- * // If poll takes 1s → wait 4s. If poll takes 6s → wait 0s.
813
+ * import { compensate, spaced, testCreateDeps } from "@evolu/common";
814
+ *
815
+ * const fastDeps = testCreateDeps();
816
+ * const fastStep = compensate(spaced("5s"))(fastDeps);
817
+ * expectOk(fastStep(undefined), [5000, 5000]);
818
+ * // Five seconds waiting plus one second working leaves four seconds.
819
+ * fastDeps.time.advance("6s");
820
+ * expectOk(fastStep(undefined), [5000, 4000]);
821
+ *
822
+ * const slowDeps = testCreateDeps();
823
+ * const slowStep = compensate(spaced("5s"))(slowDeps);
824
+ * expectOk(slowStep(undefined), [5000, 5000]);
825
+ * // Five seconds waiting plus six seconds working leaves no delay.
826
+ * slowDeps.time.advance("11s");
827
+ * expectOk(slowStep(undefined), [5000, 0]);
629
828
  * ```
630
829
  *
631
830
  * @group Delay
@@ -633,13 +832,17 @@ export const modifyDelay = (f) => (schedule) => (deps) => {
633
832
  export const compensate = (schedule) => (deps) => {
634
833
  const step = schedule(deps);
635
834
  const metrics = createScheduleStepMetrics(deps);
835
+ let previousReturnedDelay = minMillis;
636
836
  return (input) => {
637
837
  const { elapsedSincePrevious } = metrics();
638
838
  const result = step(input);
639
839
  if (!result.ok)
640
840
  return result;
641
841
  const [output, delay] = result.value;
642
- return ok([output, saturateMillis(delay - elapsedSincePrevious)]);
842
+ const executionTime = saturateComputedMillis(elapsedSincePrevious - previousReturnedDelay);
843
+ const compensatedDelay = saturateComputedMillis(delay - executionTime);
844
+ previousReturnedDelay = compensatedDelay;
845
+ return ok([output, compensatedDelay]);
643
846
  };
644
847
  };
645
848
  /**
@@ -649,24 +852,40 @@ export const compensate = (schedule) => (deps) => {
649
852
  * Useful for input-aware retry strategies, e.g., only retry certain error
650
853
  * types.
651
854
  *
652
- * ### Example
855
+ * ### Continuing by input
653
856
  *
654
857
  * ```ts
858
+ * import {
859
+ * done,
860
+ * exponential,
861
+ * testCreateDeps,
862
+ * whileScheduleInput,
863
+ * type Typed,
864
+ * } from "@evolu/common";
865
+ *
655
866
  * interface MyError extends Typed<"Transient" | "Fatal"> {}
656
867
  *
657
- * // Only retry transient errors
868
+ * // Retry only transient errors.
658
869
  * const retryTransient = whileScheduleInput(
659
870
  * (error: MyError) => error.type === "Transient",
660
871
  * )(exponential("100ms"));
872
+ * const step = retryTransient(testCreateDeps());
873
+ * expectOk(step({ type: "Transient" }), [100, 100]);
874
+ * expectErr(step({ type: "Fatal" }), done());
661
875
  * ```
662
876
  *
663
877
  * @group Filtering
664
878
  */
665
879
  export const whileScheduleInput = (predicate) => (schedule) => (deps) => {
666
880
  const step = schedule(deps);
881
+ let stopped = false;
667
882
  return (input) => {
668
- if (!predicate(input))
883
+ if (stopped)
669
884
  return err(done());
885
+ if (!predicate(input)) {
886
+ stopped = true;
887
+ return err(done());
888
+ }
670
889
  return step(input);
671
890
  };
672
891
  };
@@ -676,24 +895,40 @@ export const whileScheduleInput = (predicate) => (schedule) => (deps) => {
676
895
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `true`.
677
896
  * Useful for stopping retry on specific error conditions.
678
897
  *
679
- * ### Example
898
+ * ### Stopping by input
680
899
  *
681
900
  * ```ts
901
+ * import {
902
+ * done,
903
+ * exponential,
904
+ * testCreateDeps,
905
+ * untilScheduleInput,
906
+ * type Typed,
907
+ * } from "@evolu/common";
908
+ *
682
909
  * interface MyError extends Typed<"Transient" | "Fatal"> {}
683
910
  *
684
- * // Stop retrying on fatal errors
911
+ * // Stop retrying when an error is fatal.
685
912
  * const stopOnFatal = untilScheduleInput(
686
913
  * (error: MyError) => error.type === "Fatal",
687
914
  * )(exponential("100ms"));
915
+ * const step = stopOnFatal(testCreateDeps());
916
+ * expectOk(step({ type: "Transient" }), [100, 100]);
917
+ * expectErr(step({ type: "Fatal" }), done());
688
918
  * ```
689
919
  *
690
920
  * @group Filtering
691
921
  */
692
922
  export const untilScheduleInput = (predicate) => (schedule) => (deps) => {
693
923
  const step = schedule(deps);
924
+ let stopped = false;
694
925
  return (input) => {
695
- if (predicate(input))
926
+ if (stopped)
927
+ return err(done());
928
+ if (predicate(input)) {
929
+ stopped = true;
696
930
  return err(done());
931
+ }
697
932
  return step(input);
698
933
  };
699
934
  };
@@ -702,25 +937,43 @@ export const untilScheduleInput = (predicate) => (schedule) => (deps) => {
702
937
  *
703
938
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `false`.
704
939
  *
705
- * ### Example
940
+ * ### Continuing by output
706
941
  *
707
942
  * ```ts
708
- * // Continue while delay is under 5 seconds
943
+ * import {
944
+ * done,
945
+ * exponential,
946
+ * testCreateDeps,
947
+ * whileScheduleOutput,
948
+ * type Millis,
949
+ * } from "@evolu/common";
950
+ *
951
+ * // Continue while the exponential delay is below five seconds.
709
952
  * const capped = whileScheduleOutput((delay: Millis) => delay < 5000)(
710
953
  * exponential("1s"),
711
954
  * );
955
+ * const step = capped(testCreateDeps());
956
+ * step(undefined);
957
+ * step(undefined);
958
+ * step(undefined);
959
+ * expectErr(step(undefined), done());
712
960
  * ```
713
961
  *
714
962
  * @group Filtering
715
963
  */
716
964
  export const whileScheduleOutput = (predicate) => (schedule) => (deps) => {
717
965
  const step = schedule(deps);
966
+ let stopped = false;
718
967
  return (input) => {
968
+ if (stopped)
969
+ return err(done());
719
970
  const result = step(input);
720
971
  if (!result.ok)
721
972
  return result;
722
- if (!predicate(result.value[0]))
973
+ if (!predicate(result.value[0])) {
974
+ stopped = true;
723
975
  return err(done());
976
+ }
724
977
  return result;
725
978
  };
726
979
  };
@@ -729,42 +982,69 @@ export const whileScheduleOutput = (predicate) => (schedule) => (deps) => {
729
982
  *
730
983
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `true`.
731
984
  *
732
- * ### Example
985
+ * ### Stopping by output
733
986
  *
734
987
  * ```ts
735
- * // Stop when delay reaches 1 second
988
+ * import {
989
+ * done,
990
+ * exponential,
991
+ * testCreateDeps,
992
+ * untilScheduleOutput,
993
+ * type Millis,
994
+ * } from "@evolu/common";
995
+ *
996
+ * // Stop once the exponential delay reaches at least one second.
736
997
  * const limited = untilScheduleOutput((delay: Millis) => delay >= 1000)(
737
998
  * exponential("100ms"),
738
999
  * );
1000
+ * const step = limited(testCreateDeps());
1001
+ * step(undefined);
1002
+ * step(undefined);
1003
+ * step(undefined);
1004
+ * step(undefined);
1005
+ * expectErr(step(undefined), done());
739
1006
  * ```
740
1007
  *
741
1008
  * @group Filtering
742
1009
  */
743
1010
  export const untilScheduleOutput = (predicate) => (schedule) => (deps) => {
744
1011
  const step = schedule(deps);
1012
+ let stopped = false;
745
1013
  return (input) => {
1014
+ if (stopped)
1015
+ return err(done());
746
1016
  const result = step(input);
747
1017
  if (!result.ok)
748
1018
  return result;
749
- if (predicate(result.value[0]))
1019
+ if (predicate(result.value[0])) {
1020
+ stopped = true;
750
1021
  return err(done());
1022
+ }
751
1023
  return result;
752
1024
  };
753
1025
  };
754
1026
  /**
755
- * Resets the schedule after a period of inactivity.
1027
+ * Resets a running schedule after a period of inactivity.
756
1028
  *
757
- * If `elapsedSincePrevious` is greater than or equal to `duration`, creates a
758
- * fresh state. Useful for circuit breakers that should "forget" failures after
759
- * idle time.
1029
+ * Before each step, if the time since the previous step is at least `duration`,
1030
+ * replaces the wrapped schedule with fresh state. Once the wrapped schedule
1031
+ * returns `Done`, termination is final.
760
1032
  *
761
- * ### Example
1033
+ * ### Resetting after inactivity
762
1034
  *
763
1035
  * ```ts
764
- * // Reset retry count after 1 minute of success
765
- * const circuitBreaker = resetScheduleAfter("1m")(
766
- * take(5)(exponential("1s")),
767
- * );
1036
+ * import {
1037
+ * exponential,
1038
+ * resetScheduleAfter,
1039
+ * testCreateDeps,
1040
+ * } from "@evolu/common";
1041
+ *
1042
+ * const backoff = resetScheduleAfter("1m")(exponential("1s"));
1043
+ * const deps = testCreateDeps();
1044
+ * const step = backoff(deps);
1045
+ * expectOk(step(undefined), [1000, 1000]);
1046
+ * deps.time.advance("1m");
1047
+ * expectOk(step(undefined), [1000, 1000]);
768
1048
  * ```
769
1049
  *
770
1050
  * @group State
@@ -774,12 +1054,18 @@ export const resetScheduleAfter = (duration) => {
774
1054
  return (schedule) => (deps) => {
775
1055
  let step = schedule(deps);
776
1056
  const metrics = createScheduleStepMetrics(deps);
1057
+ let stopped = false;
777
1058
  return (input) => {
1059
+ if (stopped)
1060
+ return err(done());
778
1061
  const { elapsedSincePrevious } = metrics();
779
1062
  if (elapsedSincePrevious >= resetMs) {
780
1063
  step = schedule(deps);
781
1064
  }
782
- return step(input);
1065
+ const result = step(input);
1066
+ if (!result.ok)
1067
+ stopped = true;
1068
+ return result;
783
1069
  };
784
1070
  };
785
1071
  };
@@ -788,15 +1074,22 @@ export const resetScheduleAfter = (duration) => {
788
1074
  *
789
1075
  * The delay (second tuple element) remains unchanged.
790
1076
  *
791
- * ### Example
1077
+ * ### Mapping schedule output
792
1078
  *
793
1079
  * ```ts
794
- * import { exponential, mapSchedule } from "@evolu/common";
795
- *
796
- * const schedule = mapSchedule((delay) => ({
1080
+ * import {
1081
+ * exponential,
1082
+ * mapSchedule,
1083
+ * testCreateDeps,
1084
+ * type Millis,
1085
+ * } from "@evolu/common";
1086
+ *
1087
+ * const schedule = mapSchedule((delay: Millis) => ({
797
1088
  * delay,
798
1089
  * doubled: delay * 2,
799
1090
  * }))(exponential("100ms"));
1091
+ * const step = schedule(testCreateDeps());
1092
+ * expectOk(step(undefined), [{ delay: 100, doubled: 200 }, 100]);
800
1093
  * ```
801
1094
  *
802
1095
  * @group Transform
@@ -831,31 +1124,45 @@ export function passthrough(schedule) {
831
1124
  * Each step outputs the accumulated value. Useful for tracking totals,
832
1125
  * collecting outputs, or building up metadata across attempts.
833
1126
  *
834
- * ### Example
1127
+ * ### Folding schedule output
835
1128
  *
836
1129
  * ```ts
837
- * // Track total delay spent
1130
+ * import {
1131
+ * exponential,
1132
+ * foldSchedule,
1133
+ * minMillis,
1134
+ * spaced,
1135
+ * take,
1136
+ * testCreateDeps,
1137
+ * type Millis,
1138
+ * } from "@evolu/common";
1139
+ *
1140
+ * // Accumulate totals, complete output histories, or structured metadata.
838
1141
  * const withTotal = foldSchedule(
839
1142
  * 0,
840
1143
  * (total: number, delay: Millis) => total + delay,
841
1144
  * )(exponential("100ms"));
842
- * // Outputs: 100, 300, 700, 1500, ... (cumulative)
843
- *
844
- * // Collect all outputs
845
1145
  * const collected = foldSchedule<ReadonlyArray<Millis>, Millis>(
846
1146
  * [],
847
- * (acc, delay) => [...acc, delay],
1147
+ * (outputs, delay) => [...outputs, delay],
848
1148
  * )(take(3)(spaced("1s")));
849
- * // Outputs: [1000], [1000, 1000], [1000, 1000, 1000]
850
- *
851
- * // Count attempts with custom output
852
1149
  * const counted = foldSchedule(
853
1150
  * { attempts: 0, lastDelay: minMillis },
854
- * (acc, delay: Millis) => ({
855
- * attempts: acc.attempts + 1,
1151
+ * (state, delay: Millis) => ({
1152
+ * attempts: state.attempts + 1,
856
1153
  * lastDelay: delay,
857
1154
  * }),
858
1155
  * )(exponential("100ms"));
1156
+ *
1157
+ * const deps = testCreateDeps();
1158
+ * const totalStep = withTotal(deps);
1159
+ * totalStep(undefined);
1160
+ * expectOk(totalStep(undefined), [300, 200]);
1161
+ * expectOk(collected(deps)(undefined), [[1000], 1000]);
1162
+ * expectOk(counted(deps)(undefined), [
1163
+ * { attempts: 1, lastDelay: 100 },
1164
+ * 100,
1165
+ * ]);
859
1166
  * ```
860
1167
  *
861
1168
  * @group Transform
@@ -879,12 +1186,15 @@ export const foldSchedule = (initial, f) => (schedule) => (deps) => {
879
1186
  * Outputs 0, 1, 2, ... while preserving the underlying schedule's timing and
880
1187
  * termination behavior.
881
1188
  *
882
- * ### Example
1189
+ * ### Counting repetitions
883
1190
  *
884
1191
  * ```ts
885
- * // Track how many retries occurred
886
- * const counted = repetitions(exponential("100ms"));
887
- * // Outputs: 0, 1, 2, ... with exponential delays
1192
+ * import { exponential, repetitions, testCreateDeps } from "@evolu/common";
1193
+ *
1194
+ * // Count retries while preserving exponential timing.
1195
+ * const step = repetitions(exponential("100ms"))(testCreateDeps());
1196
+ * expectOk(step(undefined), [0, 100]);
1197
+ * expectOk(step(undefined), [1, 200]);
888
1198
  * ```
889
1199
  *
890
1200
  * @group Transform
@@ -896,17 +1206,28 @@ export const repetitions = (schedule) => foldSchedule(-1, (n) => n + 1)(schedule
896
1206
  * Wraps a schedule to output its delay (in milliseconds) instead of the
897
1207
  * original output. Useful for monitoring or logging delay patterns.
898
1208
  *
899
- * ### Example
1209
+ * ### Exposing and observing delays
900
1210
  *
901
1211
  * ```ts
902
- * // Monitor exponential delays
1212
+ * import {
1213
+ * delays,
1214
+ * exponential,
1215
+ * tapScheduleOutput,
1216
+ * testCreateDeps,
1217
+ * type Millis,
1218
+ * } from "@evolu/common";
1219
+ *
1220
+ * // Expose delays for monitoring, or observe them without changing output.
903
1221
  * const monitorDelays = delays(exponential("100ms"));
904
- * // Outputs: 100, 200, 400, 800, ... (the delays themselves)
1222
+ * const observed: Array<Millis> = [];
1223
+ * const logged = tapScheduleOutput((delay: Millis) => {
1224
+ * observed.push(delay);
1225
+ * })(delays(exponential("100ms")));
1226
+ * const deps = testCreateDeps();
905
1227
  *
906
- * // Log delays for debugging
907
- * const logged = tapScheduleOutput(console.log)(
908
- * delays(exponential("100ms")),
909
- * );
1228
+ * expectOk(monitorDelays(deps)(undefined), [100, 100]);
1229
+ * expectOk(logged(deps)(undefined), [100, 100]);
1230
+ * expect(observed).toEqual([100]);
910
1231
  * ```
911
1232
  *
912
1233
  * @group Transform
@@ -924,14 +1245,25 @@ export const delays = (schedule) => (deps) => {
924
1245
  /**
925
1246
  * Collects all outputs into an array.
926
1247
  *
927
- * Each step outputs an array containing all outputs so far.
1248
+ * Each step outputs a new snapshot containing all outputs so far. Because all
1249
+ * outputs are retained and copied on each step, use this combinator with finite
1250
+ * schedules.
928
1251
  *
929
- * ### Example
1252
+ * ### Collecting outputs
930
1253
  *
931
1254
  * ```ts
932
- * // Collect all delays
1255
+ * import {
1256
+ * collectAllScheduleOutputs,
1257
+ * spaced,
1258
+ * take,
1259
+ * testCreateDeps,
1260
+ * } from "@evolu/common";
1261
+ *
1262
+ * // Retain every delay produced by the finite schedule.
933
1263
  * const collected = collectAllScheduleOutputs(take(3)(spaced("100ms")));
934
- * // Outputs: [100], [100, 100], [100, 100, 100]
1264
+ * const step = collected(testCreateDeps());
1265
+ * step(undefined);
1266
+ * expectOk(step(undefined), [[100, 100], 100]);
935
1267
  * ```
936
1268
  *
937
1269
  * @group Collection
@@ -943,14 +1275,24 @@ export const collectAllScheduleOutputs = (schedule) => foldSchedule([], (acc, ou
943
1275
  * Each step outputs an array containing all inputs received so far. Mirror of
944
1276
  * {@link collectAllScheduleOutputs} but for inputs.
945
1277
  *
946
- * ### Example
1278
+ * ### Collecting inputs
947
1279
  *
948
1280
  * ```ts
949
- * // Collect all errors during retry
950
- * const errorHistory = collectScheduleInputs(
951
- * take(3)(exponential("100ms")),
952
- * );
953
- * // After 3 retries, outputs array of all error inputs
1281
+ * import {
1282
+ * collectScheduleInputs,
1283
+ * exponential,
1284
+ * take,
1285
+ * testCreateDeps,
1286
+ * type Millis,
1287
+ * type Schedule,
1288
+ * } from "@evolu/common";
1289
+ *
1290
+ * const retries: Schedule<Millis, string> = take(3)(exponential("100ms"));
1291
+ * // Keep every error received during retry.
1292
+ * const errorHistory = collectScheduleInputs(retries);
1293
+ * const step = errorHistory(testCreateDeps());
1294
+ * step("network");
1295
+ * expectOk(step("timeout"), [["network", "timeout"], 200]);
954
1296
  * ```
955
1297
  *
956
1298
  * @group Collection
@@ -962,14 +1304,27 @@ export const collectScheduleInputs = (schedule) => collectAllScheduleOutputs(pas
962
1304
  * More flexible than {@link collectAllScheduleOutputs} — stops collecting when
963
1305
  * the predicate returns false.
964
1306
  *
965
- * ### Example
1307
+ * ### Collecting while output matches
966
1308
  *
967
1309
  * ```ts
968
- * // Collect delays while under 1 second
1310
+ * import {
1311
+ * collectWhileScheduleOutput,
1312
+ * done,
1313
+ * exponential,
1314
+ * testCreateDeps,
1315
+ * type Millis,
1316
+ * } from "@evolu/common";
1317
+ *
1318
+ * // Collect delays only while they remain below one second.
969
1319
  * const smallDelays = collectWhileScheduleOutput(
970
1320
  * (delay: Millis) => delay < 1000,
971
1321
  * )(exponential("100ms"));
972
- * // Outputs: [100], [100, 200], [100, 200, 400], [100, 200, 400, 800], stops
1322
+ * const step = smallDelays(testCreateDeps());
1323
+ * step(undefined);
1324
+ * step(undefined);
1325
+ * step(undefined);
1326
+ * expectOk(step(undefined), [[100, 200, 400, 800], 800]);
1327
+ * expectErr(step(undefined), done());
973
1328
  * ```
974
1329
  *
975
1330
  * @group Collection
@@ -979,16 +1334,29 @@ export const collectWhileScheduleOutput = (predicate) => (schedule) => collectAl
979
1334
  * Collects outputs until a predicate becomes true.
980
1335
  *
981
1336
  * Mirror of {@link collectWhileScheduleOutput} — stops collecting when the
982
- * predicate returns true (inclusive of the matching output).
1337
+ * predicate returns true.
983
1338
  *
984
- * ### Example
1339
+ * ### Collecting until output matches
985
1340
  *
986
1341
  * ```ts
987
- * // Collect delays until reaching 1 second
1342
+ * import {
1343
+ * collectUntilScheduleOutput,
1344
+ * done,
1345
+ * exponential,
1346
+ * testCreateDeps,
1347
+ * type Millis,
1348
+ * } from "@evolu/common";
1349
+ *
1350
+ * // Collect delays until the next delay reaches at least one second.
988
1351
  * const untilLarge = collectUntilScheduleOutput(
989
1352
  * (delay: Millis) => delay >= 1000,
990
1353
  * )(exponential("100ms"));
991
- * // Outputs: [100], [100, 200], [100, 200, 400], [100, 200, 400, 800], stops
1354
+ * const step = untilLarge(testCreateDeps());
1355
+ * step(undefined);
1356
+ * step(undefined);
1357
+ * step(undefined);
1358
+ * expectOk(step(undefined), [[100, 200, 400, 800], 800]);
1359
+ * expectErr(step(undefined), done());
992
1360
  * ```
993
1361
  *
994
1362
  * @group Collection
@@ -1000,16 +1368,27 @@ export const collectUntilScheduleOutput = (predicate) => (schedule) => collectAl
1000
1368
  * Useful for adaptive strategies that start aggressive and become more
1001
1369
  * conservative over time.
1002
1370
  *
1003
- * ### Example
1371
+ * ### Sequencing strategies
1004
1372
  *
1005
1373
  * ```ts
1006
- * // Fast retries first, then slower, then final fallback
1007
- * const adaptive = sequenceSchedules(
1374
+ * import {
1375
+ * exponential,
1376
+ * fixed,
1377
+ * sequenceSchedules,
1378
+ * take,
1379
+ * testCreateDeps,
1380
+ * } from "@evolu/common";
1381
+ *
1382
+ * // Fast retries first, then slower retries, then a steady fallback.
1383
+ * const step = sequenceSchedules(
1008
1384
  * take(3)(exponential("100ms")),
1009
1385
  * take(5)(fixed("500ms")),
1010
1386
  * fixed("1s"),
1011
- * );
1012
- * // Runs: 100ms, 200ms, 400ms, then 500ms×5, then 1s forever
1387
+ * )(testCreateDeps());
1388
+ * step(undefined);
1389
+ * step(undefined);
1390
+ * step(undefined);
1391
+ * expectOk(step(undefined), [0, 500]);
1013
1392
  * ```
1014
1393
  *
1015
1394
  * @group Composition
@@ -1034,14 +1413,28 @@ export const sequenceSchedules = (...schedules) => (deps) => {
1034
1413
  *
1035
1414
  * Continues only while both schedules want to continue. Uses the maximum delay.
1036
1415
  *
1037
- * ### Example
1416
+ * ### Combining constraints with AND
1038
1417
  *
1039
1418
  * ```ts
1040
- * // Retry up to 5 times AND within 30 seconds (both conditions must be met)
1041
- * const both = intersectSchedules(
1419
+ * import {
1420
+ * done,
1421
+ * exponential,
1422
+ * forever,
1423
+ * intersectSchedules,
1424
+ * maxElapsed,
1425
+ * take,
1426
+ * testCreateDeps,
1427
+ * } from "@evolu/common";
1428
+ *
1429
+ * // Retry at most 5 times and only within 30 seconds.
1430
+ * const deps = testCreateDeps();
1431
+ * const step = intersectSchedules(
1042
1432
  * take(5)(exponential("1s")),
1043
1433
  * maxElapsed("30s")(forever),
1044
- * );
1434
+ * )(deps);
1435
+ * expectOk(step(undefined), [[1000, 0], 1000]);
1436
+ * deps.time.advance("30s");
1437
+ * expectErr(step(undefined), done());
1045
1438
  * ```
1046
1439
  *
1047
1440
  * @group Composition
@@ -1049,14 +1442,19 @@ export const sequenceSchedules = (...schedules) => (deps) => {
1049
1442
  export const intersectSchedules = (a, b) => (deps) => {
1050
1443
  const stepA = a(deps);
1051
1444
  const stepB = b(deps);
1445
+ let stopped = false;
1052
1446
  return (input) => {
1447
+ if (stopped)
1448
+ return err(done());
1053
1449
  const resultA = stepA(input);
1054
1450
  const resultB = stepB(input);
1055
- if (!resultA.ok || !resultB.ok)
1451
+ if (!resultA.ok || !resultB.ok) {
1452
+ stopped = true;
1056
1453
  return err(done());
1454
+ }
1057
1455
  const [outputA, delayA] = resultA.value;
1058
1456
  const [outputB, delayB] = resultB.value;
1059
- return ok([[outputA, outputB], Millis.orThrow(Math.max(delayA, delayB))]);
1457
+ return ok([[outputA, outputB], max(delayA, delayB)]);
1060
1458
  };
1061
1459
  };
1062
1460
  /**
@@ -1064,34 +1462,50 @@ export const intersectSchedules = (a, b) => (deps) => {
1064
1462
  *
1065
1463
  * Continues while either schedule wants to continue. Uses the minimum delay.
1066
1464
  *
1067
- * ### Example
1465
+ * ### Combining constraints with OR
1068
1466
  *
1069
1467
  * ```ts
1070
- * // Retry up to 5 times OR up to 30 seconds, whichever is longer
1468
+ * import {
1469
+ * done,
1470
+ * spaced,
1471
+ * take,
1472
+ * testCreateDeps,
1473
+ * unionSchedules,
1474
+ * } from "@evolu/common";
1475
+ *
1476
+ * // The second policy keeps the union alive after the first one stops.
1071
1477
  * const either = unionSchedules(
1072
- * take(5)(exponential("1s")),
1073
- * maxElapsed("30s")(forever),
1478
+ * take(1)(spaced("100ms")),
1479
+ * take(2)(spaced("200ms")),
1074
1480
  * );
1481
+ * const step = either(testCreateDeps());
1482
+ * expectOk(step(undefined), [100, 100]);
1483
+ * expectOk(step(undefined), [200, 200]);
1484
+ * expectErr(step(undefined), done());
1075
1485
  * ```
1076
1486
  *
1077
1487
  * @group Composition
1078
1488
  */
1079
1489
  export const unionSchedules = (a, b) => (deps) => {
1080
- const stepA = a(deps);
1081
- const stepB = b(deps);
1490
+ let stepA = a(deps);
1491
+ let stepB = b(deps);
1082
1492
  return (input) => {
1083
- const resultA = stepA(input);
1084
- const resultB = stepB(input);
1085
- if (!resultA.ok && !resultB.ok)
1493
+ if (stepA === null && stepB === null)
1086
1494
  return err(done());
1087
- if (!resultA.ok)
1088
- return resultB;
1089
- if (!resultB.ok)
1495
+ const resultA = stepA?.(input) ?? null;
1496
+ const resultB = stepB?.(input) ?? null;
1497
+ if (resultA !== null && !resultA.ok)
1498
+ stepA = null;
1499
+ if (resultB !== null && !resultB.ok)
1500
+ stepB = null;
1501
+ if (!resultA?.ok)
1502
+ return resultB?.ok ? resultB : err(done());
1503
+ if (!resultB?.ok)
1090
1504
  return resultA;
1091
1505
  const [outputA, delayA] = resultA.value;
1092
1506
  const [outputB, delayB] = resultB.value;
1093
1507
  // Use minimum delay, output from the one with smaller delay
1094
- const minDelay = Millis.orThrow(Math.min(delayA, delayB));
1508
+ const minDelay = min(delayA, delayB);
1095
1509
  return delayA <= delayB
1096
1510
  ? ok([outputA, minDelay])
1097
1511
  : ok([outputB, minDelay]);
@@ -1104,15 +1518,36 @@ export const unionSchedules = (a, b) => (deps) => {
1104
1518
  * base schedule. Useful for implementing error-aware backoff where certain
1105
1519
  * errors (e.g., throttling) use different delays.
1106
1520
  *
1107
- * ### Example
1521
+ * Each branch has independent state. Place combinators such as {@link take}
1522
+ * outside `whenInput` when their state must be shared across both branches.
1523
+ *
1524
+ * ### Selecting a schedule by input
1108
1525
  *
1109
1526
  * ```ts
1527
+ * import {
1528
+ * done,
1529
+ * exponential,
1530
+ * take,
1531
+ * testCreateDeps,
1532
+ * whenInput,
1533
+ * type Millis,
1534
+ * type Typed,
1535
+ * } from "@evolu/common";
1536
+ *
1110
1537
  * interface MyError extends Typed<"Throttled" | "NetworkError"> {}
1111
1538
  *
1112
- * const awsWithThrottling = whenInput<MyError, Millis>(
1113
- * (error) => error.type === "Throttled",
1114
- * exponential("1s"), // throttled: 1s base
1115
- * )(exponential("100ms")); // normal: 100ms base
1539
+ * // The outer take shares one retry limit across both error branches.
1540
+ * const awsWithThrottling = take(3)(
1541
+ * whenInput<MyError, Millis>(
1542
+ * (error) => error.type === "Throttled",
1543
+ * exponential("1s"),
1544
+ * )(exponential("100ms")),
1545
+ * );
1546
+ * const step = awsWithThrottling(testCreateDeps());
1547
+ * expectOk(step({ type: "Throttled" }), [1000, 1000]);
1548
+ * expectOk(step({ type: "NetworkError" }), [100, 100]);
1549
+ * expectOk(step({ type: "Throttled" }), [2000, 2000]);
1550
+ * expectErr(step({ type: "NetworkError" }), done());
1116
1551
  * ```
1117
1552
  *
1118
1553
  * @group Composition
@@ -1120,10 +1555,14 @@ export const unionSchedules = (a, b) => (deps) => {
1120
1555
  export const whenInput = (predicate, altSchedule) => (schedule) => (deps) => {
1121
1556
  const normalStep = schedule(deps);
1122
1557
  const altStep = altSchedule(deps);
1558
+ let stopped = false;
1123
1559
  return (input) => {
1124
- if (predicate(input))
1125
- return altStep(input);
1126
- return normalStep(input);
1560
+ if (stopped)
1561
+ return err(done());
1562
+ const result = predicate(input) ? altStep(input) : normalStep(input);
1563
+ if (!result.ok)
1564
+ stopped = true;
1565
+ return result;
1127
1566
  };
1128
1567
  };
1129
1568
  /**
@@ -1132,19 +1571,34 @@ export const whenInput = (predicate, altSchedule) => (schedule) => (deps) => {
1132
1571
  * Useful for logging, monitoring, or debugging without changing schedule
1133
1572
  * behavior.
1134
1573
  *
1135
- * ### Example
1574
+ * ### Observing schedule output
1136
1575
  *
1137
1576
  * ```ts
1138
- * // Log each delay for debugging
1577
+ * import {
1578
+ * exponential,
1579
+ * retryStrategyAws,
1580
+ * tapScheduleOutput,
1581
+ * testCreateDeps,
1582
+ * type Millis,
1583
+ * } from "@evolu/common";
1584
+ *
1585
+ * // Log each delay for debugging.
1586
+ * const messages: Array<string> = [];
1139
1587
  * const logged = tapScheduleOutput((delay: Millis) => {
1140
- * console.log(`Next delay: ${delay}ms`);
1588
+ * messages.push(`Next delay: ${delay}ms`);
1141
1589
  * })(exponential("100ms"));
1142
1590
  *
1143
- * // Track metrics
1144
- * const recorded: Array<Millis> = [];
1145
- * const tracked = tapScheduleOutput((delay: Millis) => {
1146
- * recorded.push(delay);
1591
+ * // Track the preset's pre-jitter delay candidates without changing it.
1592
+ * const recordedCandidates: Array<Millis> = [];
1593
+ * const tracked = tapScheduleOutput((candidate: Millis) => {
1594
+ * recordedCandidates.push(candidate);
1147
1595
  * })(retryStrategyAws);
1596
+ * const deps = testCreateDeps();
1597
+ * logged(deps)(undefined);
1598
+ * tracked(deps)(undefined);
1599
+ *
1600
+ * expect(messages).toEqual(["Next delay: 100ms"]);
1601
+ * expect(recordedCandidates).toEqual([50]);
1148
1602
  * ```
1149
1603
  *
1150
1604
  * @group Side effects
@@ -1165,23 +1619,38 @@ export const tapScheduleOutput = (f) => (schedule) => (deps) => {
1165
1619
  * Useful for logging errors during retry or monitoring what values are being
1166
1620
  * processed.
1167
1621
  *
1168
- * ### Example
1622
+ * ### Observing schedule input
1169
1623
  *
1170
1624
  * ```ts
1171
- * interface MyError extends Typed<string> {}
1172
- *
1625
+ * import {
1626
+ * exponential,
1627
+ * tapScheduleInput,
1628
+ * testCreateDeps,
1629
+ * type Millis,
1630
+ * type Schedule,
1631
+ * type Typed,
1632
+ * } from "@evolu/common";
1633
+ *
1634
+ * interface MyError extends Typed<"NetworkError" | "Timeout"> {}
1173
1635
  * const retrySchedule: Schedule<Millis, MyError> = exponential("100ms");
1174
- *
1175
- * // Log each error during retry
1636
+ * // Log errors for debugging.
1637
+ * const messages: Array<string> = [];
1176
1638
  * const logged = tapScheduleInput((error: MyError) => {
1177
- * console.log(`Retrying after error: ${error.type}`);
1639
+ * messages.push(`Retrying after error: ${error.type}`);
1178
1640
  * })(retrySchedule);
1179
1641
  *
1180
- * // Track retry reasons
1642
+ * // Or retain just the retry reasons for metrics.
1181
1643
  * const reasons: Array<string> = [];
1182
1644
  * const tracked = tapScheduleInput((error: MyError) => {
1183
1645
  * reasons.push(error.type);
1184
1646
  * })(retrySchedule);
1647
+ * const deps = testCreateDeps();
1648
+ * const error: MyError = { type: "NetworkError" };
1649
+ * logged(deps)(error);
1650
+ * tracked(deps)(error);
1651
+ *
1652
+ * expect(messages).toEqual(["Retrying after error: NetworkError"]);
1653
+ * expect(reasons).toEqual(["NetworkError"]);
1185
1654
  * ```
1186
1655
  *
1187
1656
  * @group Side effects
@@ -1194,15 +1663,17 @@ export const tapScheduleInput = (f) => (schedule) => (deps) => {
1194
1663
  };
1195
1664
  };
1196
1665
  /**
1197
- * AWS standard retry strategy.
1666
+ * AWS SDK for Java 2.1 ordinary-failure retry timing.
1198
1667
  *
1199
- * Exponential backoff (100ms base), max 2 retries (3 total attempts), 20s cap,
1668
+ * Exponential backoff (50ms base), max 2 retries (3 total attempts), 20s cap,
1200
1669
  * full jitter.
1201
1670
  *
1671
+ * This schedule does not model throttling-specific timing, token accounting, or
1672
+ * circuit breaking.
1673
+ *
1202
1674
  * @group Retry Strategies
1203
- * @see https://github.com/aws/aws-sdk-java-v2/blob/master/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java
1204
- * @see https://github.com/aws/aws-sdk-java-v2/blob/master/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
1675
+ * @see https://github.com/aws/aws-sdk-java-v2/blob/b69b75f07b6ebd93fd44b032d49b76a3b71fbb90/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
1205
1676
  */
1206
- export const retryStrategyAws = /*#__PURE__*/ jitter(1)(
1677
+ export const retryStrategyAws = /*#__PURE__*/ jitter("100%")(
1207
1678
  /*#__PURE__*/ maxDelay("20s")(
1208
- /*#__PURE__*/ take(2)(/*#__PURE__*/ exponential("100ms"))));
1679
+ /*#__PURE__*/ take(2)(/*#__PURE__*/ exponential("50ms"))));