@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
package/src/Schedule.ts CHANGED
@@ -4,26 +4,38 @@
4
4
  * @module
5
5
  */
6
6
 
7
- import { assertType } from "./Assert.js";
8
- import { fibonacciAt, FibonacciIndex, increment } from "./Number.js";
9
- import type { RandomDep } from "./Random.js";
10
- import { done, err, type NextResult, ok } from "./Result.js";
11
- import type { repeat, RepeatAttempt, retry, RetryAttempt } from "./Task.js";
7
+ import {
8
+ fibonacciAt,
9
+ FibonacciIndex,
10
+ increment,
11
+ max,
12
+ min,
13
+ type Percentage,
14
+ percentageToRatio,
15
+ } from "./Number.ts";
16
+ import type { RandomDep } from "./Random.ts";
17
+ import { done, err, type NextResult, ok } from "./Result.ts";
18
+ import type { repeat, RepeatAttempt, retry, RetryAttempt } from "./Task.ts";
12
19
  import {
13
20
  type Duration,
21
+ type DurationLiteral,
14
22
  durationToMillis,
15
23
  Millis,
16
24
  minMillis,
25
+ PositiveMillis,
17
26
  saturateMillis,
18
27
  type TimeDep,
19
- } from "./Time.js";
28
+ } from "./Time.ts";
20
29
  import {
30
+ assertType,
31
+ type Int0To100OrNonNegativeInt,
32
+ NonNaNNumber,
21
33
  NonNegativeFiniteNumber,
22
- NonNegativeInt,
23
- onePositiveInt,
34
+ type NonNegativeInt,
24
35
  PositiveInt,
25
- } from "./Type.js";
26
- import type { Predicate } from "./Types.js";
36
+ type Ratio,
37
+ } from "./Type.ts";
38
+ import type { Predicate } from "./Types.ts";
27
39
 
28
40
  /**
29
41
  * Composable scheduling strategies for {@link retry}, {@link repeat}, rate
@@ -35,30 +47,58 @@ import type { Predicate } from "./Types.js";
35
47
  * to stop. Multiple calls to `schedule(deps)` create independent state
36
48
  * instances.
37
49
  *
38
- * ### Example
50
+ * `Err(Done<void>)` is terminal. After a step returns it, every subsequent call
51
+ * to that step must also return `Err(Done<void>)`.
52
+ *
53
+ * With {@link retry} and {@link repeat}, the initial Task execution happens
54
+ * before the first schedule step. Schedule outputs therefore describe
55
+ * recurrences, not the initial execution. Time-based schedules establish their
56
+ * time origin on the first step call, not when `schedule(deps)` creates the
57
+ * step.
58
+ *
59
+ * ### Composing a retry policy
39
60
  *
40
61
  * ```ts
41
62
  * import {
63
+ * err,
42
64
  * exponential,
43
65
  * jitter,
44
66
  * maxDelay,
67
+ * ok,
45
68
  * retry,
46
69
  * take,
70
+ * testCreateRun,
71
+ * type RandomNumber,
72
+ * type Task,
47
73
  * } from "@evolu/common";
48
74
  *
75
+ * let attempts = 0;
76
+ * const fetchData: Task<string, { readonly type: "FetchError" }> = () => {
77
+ * attempts++;
78
+ * return attempts < 2 ? err({ type: "FetchError" }) : ok("data");
79
+ * };
80
+ *
49
81
  * const fetchWithRetry = retry(
50
82
  * fetchData,
51
83
  * // A jittered, capped, limited exponential backoff.
52
- * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
84
+ * jitter("100%")(maxDelay("20s")(take(2)(exponential("100ms")))),
53
85
  * );
86
+ *
87
+ * await using run = testCreateRun({
88
+ * random: { next: () => 0 as RandomNumber },
89
+ * });
90
+ * expectOk(await run(fetchWithRetry), "data");
54
91
  * ```
55
92
  *
56
93
  * Or use a preset:
57
94
  *
58
95
  * ```ts
59
- * import { retryStrategyAws, retry } from "@evolu/common";
96
+ * import { ok, retry, retryStrategyAws, type Task } from "@evolu/common";
60
97
  *
98
+ * const fetchData: Task<string> = () => ok("data");
61
99
  * const fetchWithRetry = retry(fetchData, retryStrategyAws);
100
+ *
101
+ * expect(fetchWithRetry).toBeTypeOf("function");
62
102
  * ```
63
103
  */
64
104
  export type Schedule<out Output, in Input = unknown> = (
@@ -87,60 +127,25 @@ export interface ScheduleStep<Output> {
87
127
  /** Output from the {@link Schedule} step. */
88
128
  readonly output: Output;
89
129
 
90
- /** Delay before this step executes. */
130
+ /** Delay before the scheduled recurrence executes. */
91
131
  readonly delay: Millis;
92
132
  }
93
133
 
94
- /**
95
- * Internal per-step metrics computed from timestamps.
96
- *
97
- * The schedule computes this internally from deps.time.now().
98
- */
99
- interface ScheduleStepMetrics {
100
- /** Current attempt number. */
101
- readonly attempt: PositiveInt;
102
- /** Milliseconds elapsed since the schedule started. */
103
- readonly elapsed: Millis;
104
- /** Milliseconds since the previous step. On first step, this is 0. */
105
- readonly elapsedSincePrevious: Millis;
106
- }
107
-
108
- /**
109
- * Creates an internal per-step metrics tracker.
110
- *
111
- * Each call updates internal state and returns computed metrics.
112
- */
113
- const createScheduleStepMetrics = (
114
- deps: TimeDep,
115
- ): (() => ScheduleStepMetrics) => {
116
- let attempt = onePositiveInt;
117
- let start: Millis | null = null;
118
- let previous: Millis | null = null;
119
-
120
- return () => {
121
- const now = deps.time.now();
122
- const currentAttempt = attempt;
123
- attempt = PositiveInt.orThrow(increment(attempt));
124
- start ??= now;
125
- const elapsed = saturateMillis(now - start);
126
- const elapsedSincePrevious =
127
- previous === null ? minMillis : saturateMillis(now - previous);
128
- previous = now;
129
- return { attempt: currentAttempt, elapsed, elapsedSincePrevious };
130
- };
131
- };
132
-
133
134
  /**
134
135
  * A schedule that never stops and has no delay.
135
136
  *
136
137
  * Outputs the attempt count (0, 1, 2, ...). Useful as a base for composition or
137
138
  * for immediate retry without backoff.
138
139
  *
139
- * ### Example
140
+ * ### Recurring immediately
140
141
  *
141
142
  * ```ts
142
- * // Retry immediately, up to 5 times
143
+ * import { forever, take, testCreateDeps } from "@evolu/common";
144
+ *
145
+ * // Retry immediately, up to 5 times.
143
146
  * const immediate = take(5)(forever);
147
+ * const step = immediate(testCreateDeps());
148
+ * expectOk(step(undefined), [0, 0]);
144
149
  * ```
145
150
  *
146
151
  * @group Constructors
@@ -153,13 +158,18 @@ export const forever: Schedule<number> = () => {
153
158
  /**
154
159
  * A schedule that runs exactly once with no delay.
155
160
  *
156
- * Convenience for `take(1)(forever)`. Useful for one-shot operations.
161
+ * Equivalent to `take(1)(forever)`. Useful for one-shot operations.
157
162
  *
158
- * ### Example
163
+ * ### Scheduling one recurrence
159
164
  *
160
165
  * ```ts
161
- * // Execute once, no retry
166
+ * import { done, once, testCreateDeps } from "@evolu/common";
167
+ *
168
+ * // Produce one scheduled recurrence, then stop.
162
169
  * const oneShot = once;
170
+ * const step = oneShot(testCreateDeps());
171
+ * expectOk(step(undefined), [0, 0]);
172
+ * expectErr(step(undefined), done());
163
173
  * ```
164
174
  *
165
175
  * @group Constructors
@@ -179,16 +189,27 @@ export const once: Schedule<number> = () => {
179
189
  * Convenience for `take(n)(forever)`. Outputs the current repetition count (0,
180
190
  * 1, 2, ..., n-1).
181
191
  *
182
- * ### Example
192
+ * `n` uses {@link Int0To100OrNonNegativeInt}: pass `0` to `100` as a literal, or
193
+ * a validated {@link NonNegativeInt} for larger or dynamic values.
194
+ *
195
+ * ### Limiting recurrence count
183
196
  *
184
197
  * ```ts
185
- * // Retry up to 3 times (4 total attempts including initial)
198
+ * import { done, recurs, testCreateDeps } from "@evolu/common";
199
+ *
200
+ * // Retry up to 3 times (4 total attempts including the initial operation).
186
201
  * const retry = recurs(3);
202
+ * const step = retry(testCreateDeps());
203
+ * expectOk(step(undefined), [0, 0]);
204
+ * step(undefined);
205
+ * step(undefined);
206
+ * expectErr(step(undefined), done());
187
207
  * ```
188
208
  *
189
209
  * @group Constructors
190
210
  */
191
- export const recurs = (n: number): Schedule<number> => take(n)(forever);
211
+ export const recurs = (n: Int0To100OrNonNegativeInt): Schedule<number> =>
212
+ take(n)(forever);
192
213
 
193
214
  /**
194
215
  * Constant delay schedule.
@@ -196,17 +217,20 @@ export const recurs = (n: number): Schedule<number> => take(n)(forever);
196
217
  * Always waits the same duration after each execution completes. Never stops —
197
218
  * combine with {@link take} or {@link maxElapsed} to limit.
198
219
  *
199
- * ### Example
220
+ * ### Constant spacing
200
221
  *
201
222
  * ```ts
202
- * // 1s, 1s, 1s, ... (polling)
203
- * const poll = spaced("1s");
223
+ * import { spaced, take, testCreateDeps } from "@evolu/common";
204
224
  *
205
- * // Retry 3 times with 500ms between each
225
+ * // Poll every second, retry three times, or run a long-lived heartbeat.
226
+ * const poll = spaced("1s");
206
227
  * const retry = take(3)(spaced("500ms"));
207
- *
208
- * // Heartbeat schedule
209
228
  * const heartbeat = spaced("30s");
229
+ * const deps = testCreateDeps();
230
+ *
231
+ * expectOk(poll(deps)(undefined), [1000, 1000]);
232
+ * expectOk(retry(deps)(undefined), [500, 500]);
233
+ * expectOk(heartbeat(deps)(undefined), [30000, 30000]);
210
234
  * ```
211
235
  *
212
236
  * @group Constructors
@@ -221,63 +245,75 @@ export const spaced =
221
245
  /**
222
246
  * Exponential backoff schedule.
223
247
  *
224
- * Computes delay as `base * factor^(attempt - 1)`:
248
+ * Computes delay as `base * factor^(step - 1)`:
225
249
  *
226
- * - Attempt 1: `base`
227
- * - Attempt 2: `base * factor`
228
- * - Attempt 3: `base * factor²`
250
+ * - Step 1: `base`
251
+ * - Step 2: `base * factor`
252
+ * - Step 3: `base * factor²`
229
253
  * - ...
230
254
  *
231
255
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
232
256
  *
233
- * ### Example
257
+ * ### Exponential growth factors
234
258
  *
235
259
  * ```ts
236
- * // 100ms, 200ms, 400ms, 800ms, ...
237
- * const exp = exponential("100ms");
260
+ * import { exponential, testCreateDeps } from "@evolu/common";
238
261
  *
239
- * // 100ms, 150ms, 225ms, 338ms, ... (gentler growth)
262
+ * // Standard doubling and gentler 1.5× growth.
263
+ * const standard = exponential("100ms");
240
264
  * const gentle = exponential("100ms", 1.5);
265
+ * const standardStep = standard(testCreateDeps());
266
+ * const gentleStep = gentle(testCreateDeps());
267
+ * expectOk(standardStep(undefined), [100, 100]);
268
+ * expectOk(standardStep(undefined), [200, 200]);
269
+ * expectOk(gentleStep(undefined), [100, 100]);
270
+ * expectOk(gentleStep(undefined), [150, 150]);
241
271
  * ```
242
272
  *
243
273
  * @group Constructors
244
274
  */
245
275
  export const exponential = (base: Duration, factor = 2): Schedule<Millis> => {
246
- assertType(
247
- NonNegativeFiniteNumber,
248
- factor,
249
- "Expected factor to be a non-negative finite number.",
250
- );
276
+ assertType(NonNegativeFiniteNumber, factor);
251
277
 
252
278
  return () => {
253
279
  const baseMs = durationToMillis(base);
254
280
  let attempt = 0;
255
281
  return () => {
256
282
  attempt++;
257
- const rawDelay = baseMs * Math.pow(factor, attempt - 1);
258
- const delay = saturateMillis(rawDelay);
283
+ const rawDelay =
284
+ baseMs === 0 ? minMillis : baseMs * Math.pow(factor, attempt - 1);
285
+ const delay = saturateComputedMillis(rawDelay);
259
286
  return ok([delay, delay]);
260
287
  };
261
288
  };
262
289
  };
263
290
 
291
+ const saturateComputedMillis = (value: number): Millis => {
292
+ assertType(NonNaNNumber, value);
293
+ return saturateMillis(value);
294
+ };
295
+
264
296
  /**
265
297
  * Linear backoff schedule.
266
298
  *
267
- * Delay increases linearly: `base * attempt`:
299
+ * Delay increases linearly: `base * step`:
268
300
  *
269
- * - Attempt 1: `base`
270
- * - Attempt 2: `base * 2`
271
- * - Attempt 3: `base * 3`
301
+ * - Step 1: `base`
302
+ * - Step 2: `base * 2`
303
+ * - Step 3: `base * 3`
272
304
  * - ...
273
305
  *
274
306
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
275
307
  *
276
- * ### Example
308
+ * ### Linear growth
277
309
  *
278
310
  * ```ts
311
+ * import { linear, testCreateDeps } from "@evolu/common";
312
+ *
279
313
  * // 100ms, 200ms, 300ms, 400ms, ...
280
- * const lin = linear("100ms");
314
+ * const step = linear("100ms")(testCreateDeps());
315
+ * expectOk(step(undefined), [100, 100]);
316
+ * expectOk(step(undefined), [200, 200]);
281
317
  * ```
282
318
  *
283
319
  * @group Constructors
@@ -289,7 +325,7 @@ export const linear =
289
325
  let attempt = 0;
290
326
  return () => {
291
327
  attempt++;
292
- const delay = saturateMillis(ms * attempt);
328
+ const delay = saturateComputedMillis(ms * attempt);
293
329
  return ok([delay, delay]);
294
330
  };
295
331
  };
@@ -299,20 +335,25 @@ export const linear =
299
335
  *
300
336
  * Delays follow the Fibonacci sequence, growing more slowly than exponential:
301
337
  *
302
- * - Attempt 1: `initial`
303
- * - Attempt 2: `initial`
304
- * - Attempt 3: `initial * 2`
305
- * - Attempt 4: `initial * 3`
306
- * - Attempt 5: `initial * 5`
338
+ * - Step 1: `initial`
339
+ * - Step 2: `initial`
340
+ * - Step 3: `initial * 2`
341
+ * - Step 4: `initial * 3`
342
+ * - Step 5: `initial * 5`
307
343
  * - ...
308
344
  *
309
345
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
310
346
  *
311
- * ### Example
347
+ * ### Fibonacci growth
312
348
  *
313
349
  * ```ts
314
- * // 100ms, 100ms, 200ms, 300ms, 500ms, 800ms, ...
315
- * const fib = fibonacci("100ms");
350
+ * import { fibonacci, testCreateDeps } from "@evolu/common";
351
+ *
352
+ * // 100ms, 100ms, 200ms, 300ms, 500ms, ...
353
+ * const step = fibonacci("100ms")(testCreateDeps());
354
+ * expectOk(step(undefined), [100, 100]);
355
+ * expectOk(step(undefined), [100, 100]);
356
+ * expectOk(step(undefined), [200, 200]);
316
357
  * ```
317
358
  *
318
359
  * @group Constructors
@@ -323,7 +364,7 @@ export const fibonacci =
323
364
  const ms = durationToMillis(initial);
324
365
  let index = FibonacciIndex.orThrow(1);
325
366
  return () => {
326
- const delay = saturateMillis(ms * fibonacciAt(index));
367
+ const delay = saturateComputedMillis(ms * fibonacciAt(index));
327
368
  index = FibonacciIndex.orNull(increment(index)) ?? index;
328
369
  return ok([delay, delay]);
329
370
  };
@@ -334,20 +375,27 @@ export const fibonacci =
334
375
  *
335
376
  * Recurs on a fixed interval, outputting the repetition count (0, 1, 2, ...).
336
377
  * Unlike {@link spaced}, which waits a duration _after_ each execution, `fixed`
337
- * maintains a consistent cadence from when the schedule started.
378
+ * maintains a consistent cadence from the first schedule step.
338
379
  *
339
- * If execution takes longer than the interval, the next execution happens
340
- * immediately but subsequent runs still align to the original window
341
- * boundaries. This prevents "pile-up" while maintaining predictable timing.
380
+ * If execution falls behind by one or more intervals, missed recurrences happen
381
+ * immediately until the schedule catches up to the original cadence. Use
382
+ * {@link windowed} to skip missed recurrences instead.
342
383
  *
343
- * ### Example
384
+ * ### Maintaining a fixed cadence
344
385
  *
345
386
  * ```ts
346
- * // Health check every 5 seconds, aligned to windows
347
- * const healthCheck = take(10)(fixed("5s"));
387
+ * import { fixed, take, testCreateDeps } from "@evolu/common";
348
388
  *
349
- * // Cron-like behavior: run at consistent intervals
389
+ * // A bounded health check and an unbounded cron-like cadence.
390
+ * const healthCheck = take(10)(fixed("5s"));
350
391
  * const cronLike = fixed("1m");
392
+ *
393
+ * const healthDeps = testCreateDeps();
394
+ * const healthStep = healthCheck(healthDeps);
395
+ * expectOk(healthStep(undefined), [0, 5000]);
396
+ * healthDeps.time.advance("3s");
397
+ * expectOk(healthStep(undefined), [1, 2000]);
398
+ * expectOk(cronLike(testCreateDeps())(undefined), [0, 60000]);
351
399
  * ```
352
400
  *
353
401
  * @group Constructors
@@ -367,24 +415,68 @@ export const fixed =
367
415
  const remainder = intervalMs === 0 ? 0 : elapsed % intervalMs;
368
416
  const boundary = intervalMs - remainder;
369
417
  const delay = runningBehind ? 0 : boundary;
370
- return ok([count++, saturateMillis(delay)]);
418
+ return ok([count++, saturateComputedMillis(delay)]);
371
419
  };
372
420
  };
373
421
 
422
+ /**
423
+ * Internal per-step metrics computed from timestamps.
424
+ *
425
+ * The schedule computes this internally from deps.time.now().
426
+ */
427
+ interface ScheduleStepMetrics {
428
+ /** Milliseconds elapsed since the first step. */
429
+ readonly elapsed: Millis;
430
+ /** Milliseconds since the previous step. On first step, this is 0. */
431
+ readonly elapsedSincePrevious: Millis;
432
+ }
433
+
434
+ /**
435
+ * Creates an internal per-step metrics tracker.
436
+ *
437
+ * Each call updates internal state and returns computed metrics.
438
+ */
439
+ const createScheduleStepMetrics = (
440
+ deps: TimeDep,
441
+ ): (() => ScheduleStepMetrics) => {
442
+ let start: Millis | null = null;
443
+ let previous: Millis | null = null;
444
+
445
+ return () => {
446
+ const now = deps.time.now();
447
+ start ??= now;
448
+ const elapsed = saturateComputedMillis(now - start);
449
+ const elapsedSincePrevious =
450
+ previous === null ? minMillis : saturateComputedMillis(now - previous);
451
+ previous = now;
452
+ return { elapsed, elapsedSincePrevious };
453
+ };
454
+ };
455
+
374
456
  /**
375
457
  * Divides the timeline into fixed windows and sleeps until the next boundary.
376
458
  *
377
- * Similar to {@link fixed}, but always sleeps until the next window boundary
378
- * regardless of when the last execution started. Outputs the repetition count.
459
+ * Similar to {@link fixed}, but skips missed recurrences and always sleeps until
460
+ * the next window boundary. Outputs the repetition count.
379
461
  *
380
- * Useful for aligning executions to regular intervals from the start time.
462
+ * Useful for aligning executions to regular intervals from the first step.
381
463
  *
382
- * ### Example
464
+ * ### Aligning to time windows
383
465
  *
384
466
  * ```ts
385
- * // Execute at regular 5-second boundaries from start
386
- * const aligned = windowed("5s");
387
- * // If elapsed is 3s, waits 2s. If elapsed is 7s, waits 3s.
467
+ * import { testCreateDeps, windowed } from "@evolu/common";
468
+ *
469
+ * const stepAfter = (elapsed: "3s" | "7s") => {
470
+ * const deps = testCreateDeps();
471
+ * const step = windowed("5s")(deps);
472
+ * step(undefined);
473
+ * deps.time.advance(elapsed);
474
+ * return step(undefined);
475
+ * };
476
+ *
477
+ * // At 3s the next boundary is 2s away; at 7s it is 3s away.
478
+ * expectOk(stepAfter("3s"), [1, 2000]);
479
+ * expectOk(stepAfter("7s"), [1, 3000]);
388
480
  * ```
389
481
  *
390
482
  * @group Constructors
@@ -399,7 +491,7 @@ export const windowed =
399
491
  const { elapsed } = metrics();
400
492
  const remainder = intervalMs === 0 ? 0 : elapsed % intervalMs;
401
493
  const delay = intervalMs === 0 ? 0 : intervalMs - remainder;
402
- return ok([count++, saturateMillis(delay)]);
494
+ return ok([count++, saturateComputedMillis(delay)]);
403
495
  };
404
496
  };
405
497
 
@@ -408,11 +500,15 @@ export const windowed =
408
500
  *
409
501
  * Convenience for `take(1)(spaced(delay))`. Useful for simple one-shot delays.
410
502
  *
411
- * ### Example
503
+ * ### Scheduling one delayed recurrence
412
504
  *
413
505
  * ```ts
414
- * // Wait 1 second then stop
415
- * const oneShot = fromDelay("1s");
506
+ * import { done, fromDelay, testCreateDeps } from "@evolu/common";
507
+ *
508
+ * // Wait one second, then stop.
509
+ * const step = fromDelay("1s")(testCreateDeps());
510
+ * expectOk(step(undefined), [1000, 1000]);
511
+ * expectErr(step(undefined), done());
416
512
  * ```
417
513
  *
418
514
  * @group Constructors
@@ -424,13 +520,20 @@ export const fromDelay = (delay: Duration): Schedule<Millis> =>
424
520
  * A schedule that runs through a sequence of delays.
425
521
  *
426
522
  * Convenience for sequencing single-delay schedules. Useful for predefined
427
- * retry patterns.
523
+ * retry patterns. With no delays, returns a schedule that stops immediately.
428
524
  *
429
- * ### Example
525
+ * ### Sequencing custom delays
430
526
  *
431
527
  * ```ts
432
- * // Custom retry sequence: 100ms, 500ms, 2s
528
+ * import { done, fromDelays, testCreateDeps } from "@evolu/common";
529
+ *
530
+ * // A custom retry sequence: 100ms, 500ms, then 2s.
433
531
  * const custom = fromDelays("100ms", "500ms", "2s");
532
+ * const step = custom(testCreateDeps());
533
+ * expectOk(step(undefined), [100, 100]);
534
+ * expectOk(step(undefined), [500, 500]);
535
+ * expectOk(step(undefined), [2000, 2000]);
536
+ * expectErr(step(undefined), done());
434
537
  * ```
435
538
  *
436
539
  * @group Constructors
@@ -440,22 +543,37 @@ export const fromDelays = (
440
543
  ): Schedule<Millis> => sequenceSchedules(...delays.map((d) => fromDelay(d)));
441
544
 
442
545
  /**
443
- * A schedule that outputs the total elapsed time since the schedule started.
546
+ * A schedule that outputs the total elapsed time since its first step.
444
547
  *
445
548
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit. Useful
446
549
  * for tracking how long a retry sequence has been running.
447
550
  *
448
- * ### Example
551
+ * ### Tracking elapsed time
449
552
  *
450
553
  * ```ts
451
- * // Track elapsed time alongside retries
554
+ * import {
555
+ * done,
556
+ * elapsed,
557
+ * exponential,
558
+ * intersectSchedules,
559
+ * testCreateDeps,
560
+ * whileScheduleOutput,
561
+ * type Millis,
562
+ * } from "@evolu/common";
563
+ *
564
+ * // Track elapsed time alongside each backoff step.
452
565
  * const withTiming = intersectSchedules(exponential("100ms"), elapsed);
453
- * // Outputs: [[100, 0], [200, ~100], [400, ~300], ...]
566
+ * expectOk(withTiming(testCreateDeps())(undefined), [[100, 0], 100]);
454
567
  *
455
- * // Stop after 30 seconds of elapsed time
568
+ * // Or stop a schedule after 30 seconds of elapsed time.
456
569
  * const timeLimited = whileScheduleOutput((ms: Millis) => ms < 30000)(
457
570
  * elapsed,
458
571
  * );
572
+ * const deps = testCreateDeps();
573
+ * const step = timeLimited(deps);
574
+ * step(undefined);
575
+ * deps.time.advance("30s");
576
+ * expectErr(step(undefined), done());
459
577
  * ```
460
578
  *
461
579
  * @group Constructors
@@ -471,17 +589,31 @@ export const elapsed: Schedule<Millis> = (deps) => {
471
589
  * Outputs the elapsed time. Useful for time-boxed operations or combining with
472
590
  * other schedules to create time-limited variants.
473
591
  *
474
- * ### Example
592
+ * ### Time-boxing a schedule
475
593
  *
476
594
  * ```ts
477
- * // Run for at most 30 seconds
595
+ * import {
596
+ * done,
597
+ * during,
598
+ * exponential,
599
+ * intersectSchedules,
600
+ * testCreateDeps,
601
+ * } from "@evolu/common";
602
+ *
603
+ * // Run for at most 30 seconds.
478
604
  * const timeLimited = during("30s");
605
+ * const deps = testCreateDeps();
606
+ * const step = timeLimited(deps);
607
+ * expectOk(step(undefined), [0, 0]);
608
+ * deps.time.advance("30.1s");
609
+ * expectErr(step(undefined), done());
479
610
  *
480
- * // Combine with exponential for time-boxed retry
611
+ * // Combine elapsed time with backoff for a time-boxed retry.
481
612
  * const timedRetry = intersectSchedules(
482
613
  * exponential("100ms"),
483
614
  * during("10s"),
484
615
  * );
616
+ * expectOk(timedRetry(testCreateDeps())(undefined), [[100, 0], 100]);
485
617
  * ```
486
618
  *
487
619
  * @group Constructors
@@ -496,17 +628,29 @@ export const during = (duration: Duration): Schedule<Millis> =>
496
628
  *
497
629
  * Never stops — combine with {@link take} or {@link maxElapsed} to limit.
498
630
  *
499
- * ### Example
631
+ * ### Adding constant output
500
632
  *
501
633
  * ```ts
502
- * // Always output "retry"
634
+ * import {
635
+ * always,
636
+ * exponential,
637
+ * intersectSchedules,
638
+ * testCreateDeps,
639
+ * } from "@evolu/common";
640
+ *
641
+ * // Always emit the same label.
503
642
  * const labeled = always("retry");
643
+ * expectOk(labeled(testCreateDeps())(undefined), ["retry", 0]);
504
644
  *
505
- * // Combine with timing
645
+ * // Add a label while preserving exponential timing.
506
646
  * const withLabel = intersectSchedules(
507
647
  * exponential("100ms"),
508
648
  * always("backoff"),
509
649
  * );
650
+ * expectOk(withLabel(testCreateDeps())(undefined), [
651
+ * [100, "backoff"],
652
+ * 100,
653
+ * ]);
510
654
  * ```
511
655
  *
512
656
  * @group Constructors
@@ -521,18 +665,17 @@ export const always = <A>(value: A): Schedule<A> =>
521
665
  * provided function. Never stops — combine with {@link take} or
522
666
  * {@link maxElapsed} to limit.
523
667
  *
524
- * ### Example
668
+ * ### Unfolding state
525
669
  *
526
670
  * ```ts
527
- * // Counter: 0, 1, 2, 3, ...
528
- * const counter = unfoldSchedule(0, (n) => n + 1);
671
+ * import { testCreateDeps, unfoldSchedule } from "@evolu/common";
529
672
  *
530
- * // Custom backoff: 100, 150, 225, 338, ... (×1.5 each time)
673
+ * // Unfold counters, custom backoff values, or state machines.
674
+ * const counter = unfoldSchedule(0, (n) => n + 1);
531
675
  * const customBackoff = unfoldSchedule(100, (delay) =>
532
676
  * Math.round(delay * 1.5),
533
677
  * );
534
678
  *
535
- * // State machine
536
679
  * type Phase = "init" | "warmup" | "active";
537
680
  * const phases = unfoldSchedule<Phase>("init", (phase) => {
538
681
  * switch (phase) {
@@ -544,6 +687,16 @@ export const always = <A>(value: A): Schedule<A> =>
544
687
  * return "active";
545
688
  * }
546
689
  * });
690
+ *
691
+ * const counterStep = counter(testCreateDeps());
692
+ * const backoffStep = customBackoff(testCreateDeps());
693
+ * const phaseStep = phases(testCreateDeps());
694
+ * expectOk(counterStep(undefined), [0, 0]);
695
+ * expectOk(counterStep(undefined), [1, 0]);
696
+ * backoffStep(undefined);
697
+ * phaseStep(undefined);
698
+ * expectOk(backoffStep(undefined), [150, 0]);
699
+ * expectOk(phaseStep(undefined), ["warmup", 0]);
547
700
  * ```
548
701
  *
549
702
  * @group Constructors
@@ -560,48 +713,62 @@ export const unfoldSchedule =
560
713
  };
561
714
 
562
715
  /**
563
- * Limits a schedule to a maximum number of attempts.
716
+ * Limits a schedule to a maximum number of steps.
564
717
  *
565
- * After `n` attempts, returns `Err(Done<void>)` (stop).
718
+ * After `n` steps, returns `Err(Done<void>)` (stop).
566
719
  *
567
- * ### Example
720
+ * `n` uses {@link Int0To100OrNonNegativeInt}: pass `0` to `100` as a literal, or
721
+ * a validated {@link NonNegativeInt} for larger or dynamic values.
722
+ *
723
+ * ### Limiting a schedule
568
724
  *
569
725
  * ```ts
570
- * // Exponential backoff, max 3 retries
571
- * const limited = take(3)(exponential("100ms"));
572
- * // Attempt 1: 100ms, Attempt 2: 200ms, Attempt 3: 400ms, Attempt 4: Err(Done<void>)
726
+ * import { done, exponential, take, testCreateDeps } from "@evolu/common";
727
+ *
728
+ * // Three exponential retries, then Done.
729
+ * const step = take(3)(exponential("100ms"))(testCreateDeps());
730
+ * step(undefined);
731
+ * step(undefined);
732
+ * step(undefined);
733
+ * expectErr(step(undefined), done());
573
734
  * ```
574
735
  *
575
736
  * @group Limiting
576
737
  */
577
- export const take = (n: number) => {
578
- assertType(NonNegativeInt, n, "Expected n to be a non-negative integer.");
579
-
580
- return <Output, Input>(
581
- schedule: Schedule<Output, Input>,
582
- ): Schedule<Output, Input> =>
583
- (deps) => {
584
- const step = schedule(deps);
585
- let attempt = 0;
586
- return (input) => {
587
- attempt++;
588
- if (attempt > n) return err(done());
589
- return step(input);
590
- };
738
+ export const take =
739
+ (n: Int0To100OrNonNegativeInt) =>
740
+ <Output, Input>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
741
+ (deps) => {
742
+ const step = schedule(deps);
743
+ let attempt = 0;
744
+ return (input) => {
745
+ attempt++;
746
+ if (attempt > n) return err(done());
747
+ return step(input);
591
748
  };
592
- };
749
+ };
593
750
 
594
751
  /**
595
- * Limits schedule execution to a maximum elapsed time.
752
+ * Limits schedule execution to a maximum elapsed time since its first step.
596
753
  *
597
- * After `duration` has elapsed since the schedule started, returns
598
- * `Err(Done<void>)`.
754
+ * After `duration` has elapsed since the first step, returns `Err(Done<void>)`.
599
755
  *
600
- * ### Example
756
+ * ### Limiting elapsed time
601
757
  *
602
758
  * ```ts
603
- * // Retry for at most 30 seconds
604
- * const timeLimited = maxElapsed("30s")(exponential("1s"));
759
+ * import {
760
+ * done,
761
+ * exponential,
762
+ * maxElapsed,
763
+ * testCreateDeps,
764
+ * } from "@evolu/common";
765
+ *
766
+ * // Retry for at most 30 seconds.
767
+ * const deps = testCreateDeps();
768
+ * const step = maxElapsed("30s")(exponential("1s"))(deps);
769
+ * expectOk(step(undefined), [1000, 1000]);
770
+ * deps.time.advance("30s");
771
+ * expectErr(step(undefined), done());
605
772
  * ```
606
773
  *
607
774
  * @group Limiting
@@ -614,9 +781,15 @@ export const maxElapsed = (duration: Duration) => {
614
781
  (deps) => {
615
782
  const step = schedule(deps);
616
783
  const metrics = createScheduleStepMetrics(deps);
784
+ let stopped = false;
617
785
  return (input) => {
786
+ if (stopped) return err(done());
618
787
  const { elapsed } = metrics();
619
- return elapsed >= maxMs ? err(done()) : step(input);
788
+ if (elapsed >= maxMs) {
789
+ stopped = true;
790
+ return err(done());
791
+ }
792
+ return step(input);
620
793
  };
621
794
  };
622
795
  };
@@ -626,12 +799,18 @@ export const maxElapsed = (duration: Duration) => {
626
799
  *
627
800
  * If the schedule returns a delay greater than `max`, returns `max` instead.
628
801
  *
629
- * ### Example
802
+ * ### Capping delays
630
803
  *
631
804
  * ```ts
632
- * // Exponential capped at 10 seconds
633
- * const capped = maxDelay("10s")(exponential("1s"));
634
- * // 1s, 2s, 4s, 8s, 10s, 10s, 10s, ...
805
+ * import { exponential, maxDelay, testCreateDeps } from "@evolu/common";
806
+ *
807
+ * // Exponential delays grow 1s, 2s, 4s, 8s, then stay capped at 10s.
808
+ * const step = maxDelay("10s")(exponential("1s"))(testCreateDeps());
809
+ * step(undefined);
810
+ * step(undefined);
811
+ * step(undefined);
812
+ * step(undefined);
813
+ * expectOk(step(undefined), [16000, 10000]);
635
814
  * ```
636
815
  *
637
816
  * @group Limiting
@@ -647,65 +826,96 @@ export const maxDelay = (max: Duration) => {
647
826
  const result = step(input);
648
827
  if (!result.ok) return result;
649
828
  const [output, delay] = result.value;
650
- return ok([output, Millis.orThrow(Math.min(delay, maxMs))]);
829
+ return ok([output, min(delay, maxMs)]);
651
830
  };
652
831
  };
653
832
  };
654
833
 
655
834
  /**
656
- * Adds randomized jitter to delays.
835
+ * Randomizes delays by up to a percentage.
657
836
  *
658
837
  * Jitter helps prevent "thundering herd" when many clients retry simultaneously
659
- * after a service recovers. The delay is randomized within a range:
838
+ * after a service recovers. By default, the original delay is the upper bound:
839
+ *
840
+ * - `"0%"` — no jitter (original delay)
841
+ * - `"50%"` — equal jitter, shortens the delay by up to 50%
842
+ * - `"100%"` — full jitter, shortens the delay by up to 100%
660
843
  *
661
- * - `factor = 0` no jitter (original delay)
662
- * - `factor = 0.5` — delay varies ±50% (e.g., 1s becomes 500ms-1500ms)
663
- * - `factor = 1` — full jitter, delay varies 0-200% (e.g., 1s becomes 0-2s)
844
+ * Pass `"around"` to preserve the average delay for periodic work:
664
845
  *
665
- * ### Example
846
+ * - `"0%"` — no jitter (original delay)
847
+ * - `"50%"` — varies by up to 50% below or above the original delay
848
+ * - `"100%"` — varies by up to 100% below or above the original delay
849
+ *
850
+ * ### Jittering below or around a delay
666
851
  *
667
852
  * ```ts
668
- * // AWS-style full jitter
669
- * const awsStyle = jitter(1)(exponential("1s"));
853
+ * import {
854
+ * exponential,
855
+ * jitter,
856
+ * spaced,
857
+ * testCreateDeps,
858
+ * type RandomNumber,
859
+ * } from "@evolu/common";
860
+ *
861
+ * const deps = {
862
+ * ...testCreateDeps(),
863
+ * random: { next: () => 0.5 as RandomNumber },
864
+ * };
670
865
  *
671
- * // Conservative jitter 25%)
672
- * const conservative = jitter(0.25)(exponential("1s"));
866
+ * // Shorten retry delays by at most 25%.
867
+ * const conservative = jitter("25%")(exponential("1s"));
868
+ * // Poll around a 30s target cadence, from 27s to 33s.
869
+ * const polling = jitter("10%", "around")(spaced("30s"));
870
+ *
871
+ * expectOk(conservative(deps)(undefined), [1000, 875]);
872
+ * expectOk(polling(deps)(undefined), [30000, 30000]);
673
873
  * ```
674
874
  *
675
875
  * @group Delay
676
876
  */
677
- export const jitter = (factor = 0.5) => {
678
- assertType(
679
- NonNegativeFiniteNumber,
680
- factor,
681
- "Expected factor to be a non-negative finite number.",
682
- );
877
+ export const jitter = (
878
+ percentage: Percentage = "50%",
879
+ mode: "below" | "around" = "below",
880
+ ): (<Output, Input>(
881
+ schedule: Schedule<Output, Input>,
882
+ ) => Schedule<Output, Input>) => {
883
+ const ratio = percentageToRatio(percentage);
884
+ return createJitter(ratio, mode === "around" ? 1 + ratio : 1);
885
+ };
683
886
 
684
- return <Output, Input>(
685
- schedule: Schedule<Output, Input>,
686
- ): Schedule<Output, Input> =>
687
- (deps) => {
688
- const step = schedule(deps);
689
- return (input) => {
690
- const result = step(input);
691
- if (!result.ok) return result;
692
- const [output, delay] = result.value;
693
- const jittered = delay * (1 - factor + deps.random.next() * 2 * factor);
694
- return ok([output, saturateMillis(jittered)]);
695
- };
887
+ const createJitter =
888
+ (factor: Ratio, maxMultiplier: number) =>
889
+ <Output, Input>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
890
+ (deps) => {
891
+ const step = schedule(deps);
892
+ return (input) => {
893
+ const result = step(input);
894
+ if (!result.ok) return result;
895
+ const [output, delay] = result.value;
896
+ const minMultiplier = 1 - factor;
897
+ const jittered =
898
+ delay *
899
+ (minMultiplier + deps.random.next() * (maxMultiplier - minMultiplier));
900
+ return ok([output, saturateComputedMillis(jittered)]);
696
901
  };
697
- };
902
+ };
698
903
 
699
904
  /**
700
- * Adds an initial delay before the first attempt.
905
+ * Replaces the schedule's first delay.
701
906
  *
702
- * Subsequent attempts use the schedule's normal delays.
907
+ * The first successful step uses `initialDelay` instead of the delay produced
908
+ * by the schedule. Subsequent steps use the schedule's delays unchanged.
703
909
  *
704
- * ### Example
910
+ * ### Replacing the first delay
705
911
  *
706
912
  * ```ts
707
- * // Wait 1s before first attempt, then exponential backoff
708
- * const withWarmup = delayed("1s")(exponential("100ms"));
913
+ * import { delayed, exponential, testCreateDeps } from "@evolu/common";
914
+ *
915
+ * const step = delayed("1s")(exponential("100ms"))(testCreateDeps());
916
+ * // Only the first delay is replaced; later exponential delays are unchanged.
917
+ * expectOk(step(undefined), [100, 1000]);
918
+ * expectOk(step(undefined), [200, 200]);
709
919
  * ```
710
920
  *
711
921
  * @group Delay
@@ -733,12 +943,14 @@ export const delayed = (initialDelay: Duration) => {
733
943
  /**
734
944
  * Adds a fixed delay to the schedule's existing delay.
735
945
  *
736
- * ### Example
946
+ * ### Adding to every delay
737
947
  *
738
948
  * ```ts
739
- * // Add 500ms to each delay
740
- * const slower = addDelay("500ms")(exponential("100ms"));
741
- * // Delays: 600ms, 700ms, 900ms, 1300ms, ...
949
+ * import { addDelay, exponential, testCreateDeps } from "@evolu/common";
950
+ *
951
+ * // Add 500ms to every exponential delay.
952
+ * const step = addDelay("500ms")(exponential("100ms"))(testCreateDeps());
953
+ * expectOk(step(undefined), [100, 600]);
742
954
  * ```
743
955
  *
744
956
  * @group Delay
@@ -757,14 +969,21 @@ export const addDelay = (
757
969
  *
758
970
  * More flexible than {@link maxDelay} — can implement any delay transformation.
759
971
  *
760
- * ### Example
972
+ * ### Transforming delays
761
973
  *
762
974
  * ```ts
763
- * // Double all delays
764
- * const slower = modifyDelay((d) => d * 2)(exponential("100ms"));
975
+ * import { exponential, modifyDelay, testCreateDeps } from "@evolu/common";
765
976
  *
766
- * // Cap at 10s (equivalent to maxDelay)
767
- * const capped = modifyDelay((d) => Math.min(d, 10000))(exponential("1s"));
977
+ * // Arbitrary transformations can double or cap delays.
978
+ * const slower = modifyDelay((delay) => delay * 2)(exponential("100ms"));
979
+ * // Equivalent to maxDelay("10s") for this schedule.
980
+ * const capped = modifyDelay((delay) => Math.min(delay, 10000))(
981
+ * exponential("20s"),
982
+ * );
983
+ * const deps = testCreateDeps();
984
+ *
985
+ * expectOk(slower(deps)(undefined), [100, 200]);
986
+ * expectOk(capped(deps)(undefined), [20000, 10000]);
768
987
  * ```
769
988
  *
770
989
  * @group Delay
@@ -778,24 +997,42 @@ export const modifyDelay =
778
997
  const result = step(input);
779
998
  if (!result.ok) return result;
780
999
  const [output, delay] = result.value;
781
- return ok([output, saturateMillis(f(delay))]);
1000
+ return ok([output, saturateComputedMillis(f(delay))]);
782
1001
  };
783
1002
  };
784
1003
 
785
1004
  /**
786
- * Adjusts delay by subtracting execution time.
1005
+ * Adjusts delay by subtracting time elapsed beyond the previously returned
1006
+ * delay.
787
1007
  *
788
- * A simple combinator that subtracts the previous execution time from the
789
- * schedule's delay. If execution took longer than the delay, returns 0.
1008
+ * In a normal executor loop, this corresponds to the previous execution time.
1009
+ * If the runtime wakes later than requested, the extra lag is also compensated.
1010
+ * If execution and lag took longer than the delay, returns 0.
1011
+ *
1012
+ * When composing with delay-shaping combinators such as {@link maxDelay}, put
1013
+ * `compensate` near the outside of the stack so it observes the final returned
1014
+ * delay.
790
1015
  *
791
1016
  * For window-aligned scheduling, use {@link fixed} instead.
792
1017
  *
793
- * ### Example
1018
+ * ### Compensating for execution time
794
1019
  *
795
1020
  * ```ts
796
- * // Poll every 5s, accounting for execution time
797
- * const polling = compensate(spaced("5s"));
798
- * // If poll takes 1s → wait 4s. If poll takes 6s → wait 0s.
1021
+ * import { compensate, spaced, testCreateDeps } from "@evolu/common";
1022
+ *
1023
+ * const fastDeps = testCreateDeps();
1024
+ * const fastStep = compensate(spaced("5s"))(fastDeps);
1025
+ * expectOk(fastStep(undefined), [5000, 5000]);
1026
+ * // Five seconds waiting plus one second working leaves four seconds.
1027
+ * fastDeps.time.advance("6s");
1028
+ * expectOk(fastStep(undefined), [5000, 4000]);
1029
+ *
1030
+ * const slowDeps = testCreateDeps();
1031
+ * const slowStep = compensate(spaced("5s"))(slowDeps);
1032
+ * expectOk(slowStep(undefined), [5000, 5000]);
1033
+ * // Five seconds waiting plus six seconds working leaves no delay.
1034
+ * slowDeps.time.advance("11s");
1035
+ * expectOk(slowStep(undefined), [5000, 0]);
799
1036
  * ```
800
1037
  *
801
1038
  * @group Delay
@@ -805,12 +1042,18 @@ export const compensate =
805
1042
  (deps) => {
806
1043
  const step = schedule(deps);
807
1044
  const metrics = createScheduleStepMetrics(deps);
1045
+ let previousReturnedDelay = minMillis;
808
1046
  return (input) => {
809
1047
  const { elapsedSincePrevious } = metrics();
810
1048
  const result = step(input);
811
1049
  if (!result.ok) return result;
812
1050
  const [output, delay] = result.value;
813
- return ok([output, saturateMillis(delay - elapsedSincePrevious)]);
1051
+ const executionTime = saturateComputedMillis(
1052
+ elapsedSincePrevious - previousReturnedDelay,
1053
+ );
1054
+ const compensatedDelay = saturateComputedMillis(delay - executionTime);
1055
+ previousReturnedDelay = compensatedDelay;
1056
+ return ok([output, compensatedDelay]);
814
1057
  };
815
1058
  };
816
1059
 
@@ -821,15 +1064,26 @@ export const compensate =
821
1064
  * Useful for input-aware retry strategies, e.g., only retry certain error
822
1065
  * types.
823
1066
  *
824
- * ### Example
1067
+ * ### Continuing by input
825
1068
  *
826
1069
  * ```ts
1070
+ * import {
1071
+ * done,
1072
+ * exponential,
1073
+ * testCreateDeps,
1074
+ * whileScheduleInput,
1075
+ * type Typed,
1076
+ * } from "@evolu/common";
1077
+ *
827
1078
  * interface MyError extends Typed<"Transient" | "Fatal"> {}
828
1079
  *
829
- * // Only retry transient errors
1080
+ * // Retry only transient errors.
830
1081
  * const retryTransient = whileScheduleInput(
831
1082
  * (error: MyError) => error.type === "Transient",
832
1083
  * )(exponential("100ms"));
1084
+ * const step = retryTransient(testCreateDeps());
1085
+ * expectOk(step({ type: "Transient" }), [100, 100]);
1086
+ * expectErr(step({ type: "Fatal" }), done());
833
1087
  * ```
834
1088
  *
835
1089
  * @group Filtering
@@ -839,8 +1093,13 @@ export const whileScheduleInput =
839
1093
  <Output>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
840
1094
  (deps) => {
841
1095
  const step = schedule(deps);
1096
+ let stopped = false;
842
1097
  return (input) => {
843
- if (!predicate(input)) return err(done());
1098
+ if (stopped) return err(done());
1099
+ if (!predicate(input)) {
1100
+ stopped = true;
1101
+ return err(done());
1102
+ }
844
1103
  return step(input);
845
1104
  };
846
1105
  };
@@ -851,15 +1110,26 @@ export const whileScheduleInput =
851
1110
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `true`.
852
1111
  * Useful for stopping retry on specific error conditions.
853
1112
  *
854
- * ### Example
1113
+ * ### Stopping by input
855
1114
  *
856
1115
  * ```ts
1116
+ * import {
1117
+ * done,
1118
+ * exponential,
1119
+ * testCreateDeps,
1120
+ * untilScheduleInput,
1121
+ * type Typed,
1122
+ * } from "@evolu/common";
1123
+ *
857
1124
  * interface MyError extends Typed<"Transient" | "Fatal"> {}
858
1125
  *
859
- * // Stop retrying on fatal errors
1126
+ * // Stop retrying when an error is fatal.
860
1127
  * const stopOnFatal = untilScheduleInput(
861
1128
  * (error: MyError) => error.type === "Fatal",
862
1129
  * )(exponential("100ms"));
1130
+ * const step = stopOnFatal(testCreateDeps());
1131
+ * expectOk(step({ type: "Transient" }), [100, 100]);
1132
+ * expectErr(step({ type: "Fatal" }), done());
863
1133
  * ```
864
1134
  *
865
1135
  * @group Filtering
@@ -869,8 +1139,13 @@ export const untilScheduleInput =
869
1139
  <Output>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
870
1140
  (deps) => {
871
1141
  const step = schedule(deps);
1142
+ let stopped = false;
872
1143
  return (input) => {
873
- if (predicate(input)) return err(done());
1144
+ if (stopped) return err(done());
1145
+ if (predicate(input)) {
1146
+ stopped = true;
1147
+ return err(done());
1148
+ }
874
1149
  return step(input);
875
1150
  };
876
1151
  };
@@ -880,13 +1155,26 @@ export const untilScheduleInput =
880
1155
  *
881
1156
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `false`.
882
1157
  *
883
- * ### Example
1158
+ * ### Continuing by output
884
1159
  *
885
1160
  * ```ts
886
- * // Continue while delay is under 5 seconds
1161
+ * import {
1162
+ * done,
1163
+ * exponential,
1164
+ * testCreateDeps,
1165
+ * whileScheduleOutput,
1166
+ * type Millis,
1167
+ * } from "@evolu/common";
1168
+ *
1169
+ * // Continue while the exponential delay is below five seconds.
887
1170
  * const capped = whileScheduleOutput((delay: Millis) => delay < 5000)(
888
1171
  * exponential("1s"),
889
1172
  * );
1173
+ * const step = capped(testCreateDeps());
1174
+ * step(undefined);
1175
+ * step(undefined);
1176
+ * step(undefined);
1177
+ * expectErr(step(undefined), done());
890
1178
  * ```
891
1179
  *
892
1180
  * @group Filtering
@@ -896,10 +1184,15 @@ export const whileScheduleOutput =
896
1184
  <Input>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
897
1185
  (deps) => {
898
1186
  const step = schedule(deps);
1187
+ let stopped = false;
899
1188
  return (input) => {
1189
+ if (stopped) return err(done());
900
1190
  const result = step(input);
901
1191
  if (!result.ok) return result;
902
- if (!predicate(result.value[0])) return err(done());
1192
+ if (!predicate(result.value[0])) {
1193
+ stopped = true;
1194
+ return err(done());
1195
+ }
903
1196
  return result;
904
1197
  };
905
1198
  };
@@ -909,13 +1202,27 @@ export const whileScheduleOutput =
909
1202
  *
910
1203
  * Stops (returns `Err(Done<void>)`) when {@link Predicate} returns `true`.
911
1204
  *
912
- * ### Example
1205
+ * ### Stopping by output
913
1206
  *
914
1207
  * ```ts
915
- * // Stop when delay reaches 1 second
1208
+ * import {
1209
+ * done,
1210
+ * exponential,
1211
+ * testCreateDeps,
1212
+ * untilScheduleOutput,
1213
+ * type Millis,
1214
+ * } from "@evolu/common";
1215
+ *
1216
+ * // Stop once the exponential delay reaches at least one second.
916
1217
  * const limited = untilScheduleOutput((delay: Millis) => delay >= 1000)(
917
1218
  * exponential("100ms"),
918
1219
  * );
1220
+ * const step = limited(testCreateDeps());
1221
+ * step(undefined);
1222
+ * step(undefined);
1223
+ * step(undefined);
1224
+ * step(undefined);
1225
+ * expectErr(step(undefined), done());
919
1226
  * ```
920
1227
  *
921
1228
  * @group Filtering
@@ -925,33 +1232,48 @@ export const untilScheduleOutput =
925
1232
  <Input>(schedule: Schedule<Output, Input>): Schedule<Output, Input> =>
926
1233
  (deps) => {
927
1234
  const step = schedule(deps);
1235
+ let stopped = false;
928
1236
  return (input) => {
1237
+ if (stopped) return err(done());
929
1238
  const result = step(input);
930
1239
  if (!result.ok) return result;
931
- if (predicate(result.value[0])) return err(done());
1240
+ if (predicate(result.value[0])) {
1241
+ stopped = true;
1242
+ return err(done());
1243
+ }
932
1244
  return result;
933
1245
  };
934
1246
  };
935
1247
 
936
1248
  /**
937
- * Resets the schedule after a period of inactivity.
1249
+ * Resets a running schedule after a period of inactivity.
938
1250
  *
939
- * If `elapsedSincePrevious` is greater than or equal to `duration`, creates a
940
- * fresh state. Useful for circuit breakers that should "forget" failures after
941
- * idle time.
1251
+ * Before each step, if the time since the previous step is at least `duration`,
1252
+ * replaces the wrapped schedule with fresh state. Once the wrapped schedule
1253
+ * returns `Done`, termination is final.
942
1254
  *
943
- * ### Example
1255
+ * ### Resetting after inactivity
944
1256
  *
945
1257
  * ```ts
946
- * // Reset retry count after 1 minute of success
947
- * const circuitBreaker = resetScheduleAfter("1m")(
948
- * take(5)(exponential("1s")),
949
- * );
1258
+ * import {
1259
+ * exponential,
1260
+ * resetScheduleAfter,
1261
+ * testCreateDeps,
1262
+ * } from "@evolu/common";
1263
+ *
1264
+ * const backoff = resetScheduleAfter("1m")(exponential("1s"));
1265
+ * const deps = testCreateDeps();
1266
+ * const step = backoff(deps);
1267
+ * expectOk(step(undefined), [1000, 1000]);
1268
+ * deps.time.advance("1m");
1269
+ * expectOk(step(undefined), [1000, 1000]);
950
1270
  * ```
951
1271
  *
952
1272
  * @group State
953
1273
  */
954
- export const resetScheduleAfter = (duration: Duration) => {
1274
+ export const resetScheduleAfter = (
1275
+ duration: DurationLiteral | PositiveMillis,
1276
+ ) => {
955
1277
  const resetMs = durationToMillis(duration);
956
1278
  return <Output, Input>(
957
1279
  schedule: Schedule<Output, Input>,
@@ -959,12 +1281,16 @@ export const resetScheduleAfter = (duration: Duration) => {
959
1281
  (deps) => {
960
1282
  let step = schedule(deps);
961
1283
  const metrics = createScheduleStepMetrics(deps);
1284
+ let stopped = false;
962
1285
  return (input) => {
1286
+ if (stopped) return err(done());
963
1287
  const { elapsedSincePrevious } = metrics();
964
1288
  if (elapsedSincePrevious >= resetMs) {
965
1289
  step = schedule(deps);
966
1290
  }
967
- return step(input);
1291
+ const result = step(input);
1292
+ if (!result.ok) stopped = true;
1293
+ return result;
968
1294
  };
969
1295
  };
970
1296
  };
@@ -974,15 +1300,22 @@ export const resetScheduleAfter = (duration: Duration) => {
974
1300
  *
975
1301
  * The delay (second tuple element) remains unchanged.
976
1302
  *
977
- * ### Example
1303
+ * ### Mapping schedule output
978
1304
  *
979
1305
  * ```ts
980
- * import { exponential, mapSchedule } from "@evolu/common";
1306
+ * import {
1307
+ * exponential,
1308
+ * mapSchedule,
1309
+ * testCreateDeps,
1310
+ * type Millis,
1311
+ * } from "@evolu/common";
981
1312
  *
982
- * const schedule = mapSchedule((delay) => ({
1313
+ * const schedule = mapSchedule((delay: Millis) => ({
983
1314
  * delay,
984
1315
  * doubled: delay * 2,
985
1316
  * }))(exponential("100ms"));
1317
+ * const step = schedule(testCreateDeps());
1318
+ * expectOk(step(undefined), [{ delay: 100, doubled: 200 }, 100]);
986
1319
  * ```
987
1320
  *
988
1321
  * @group Transform
@@ -1008,20 +1341,23 @@ export const mapSchedule =
1008
1341
  * directly (the "identity" schedule). When called with a schedule, wraps it to
1009
1342
  * preserve timing behavior but replace output with input.
1010
1343
  *
1011
- * ### Example
1344
+ * ### Passing through input
1012
1345
  *
1013
1346
  * ```ts
1014
- * import { exponential, passthrough } from "@evolu/common";
1347
+ * import { exponential, passthrough, testCreateDeps } from "@evolu/common";
1015
1348
  *
1016
1349
  * interface MyError {
1017
1350
  * readonly message: string;
1018
1351
  * }
1019
1352
  *
1020
- * // Constructor: output equals input
1353
+ * // Constructor form emits input immediately; combinator form keeps timing.
1021
1354
  * const identity = passthrough<MyError>();
1022
- *
1023
- * // Combinator: preserve timing, replace output
1024
1355
  * const withInput = passthrough(exponential("100ms"));
1356
+ * const error = { message: "Unavailable" };
1357
+ * const deps = testCreateDeps();
1358
+ *
1359
+ * expectOk(identity(deps)(error), [error, 0]);
1360
+ * expectOk(withInput(deps)(error), [error, 100]);
1025
1361
  * ```
1026
1362
  *
1027
1363
  * @group Constructors
@@ -1053,31 +1389,45 @@ export function passthrough<Output, Input>(
1053
1389
  * Each step outputs the accumulated value. Useful for tracking totals,
1054
1390
  * collecting outputs, or building up metadata across attempts.
1055
1391
  *
1056
- * ### Example
1392
+ * ### Folding schedule output
1057
1393
  *
1058
1394
  * ```ts
1059
- * // Track total delay spent
1395
+ * import {
1396
+ * exponential,
1397
+ * foldSchedule,
1398
+ * minMillis,
1399
+ * spaced,
1400
+ * take,
1401
+ * testCreateDeps,
1402
+ * type Millis,
1403
+ * } from "@evolu/common";
1404
+ *
1405
+ * // Accumulate totals, complete output histories, or structured metadata.
1060
1406
  * const withTotal = foldSchedule(
1061
1407
  * 0,
1062
1408
  * (total: number, delay: Millis) => total + delay,
1063
1409
  * )(exponential("100ms"));
1064
- * // Outputs: 100, 300, 700, 1500, ... (cumulative)
1065
- *
1066
- * // Collect all outputs
1067
1410
  * const collected = foldSchedule<ReadonlyArray<Millis>, Millis>(
1068
1411
  * [],
1069
- * (acc, delay) => [...acc, delay],
1412
+ * (outputs, delay) => [...outputs, delay],
1070
1413
  * )(take(3)(spaced("1s")));
1071
- * // Outputs: [1000], [1000, 1000], [1000, 1000, 1000]
1072
- *
1073
- * // Count attempts with custom output
1074
1414
  * const counted = foldSchedule(
1075
1415
  * { attempts: 0, lastDelay: minMillis },
1076
- * (acc, delay: Millis) => ({
1077
- * attempts: acc.attempts + 1,
1416
+ * (state, delay: Millis) => ({
1417
+ * attempts: state.attempts + 1,
1078
1418
  * lastDelay: delay,
1079
1419
  * }),
1080
1420
  * )(exponential("100ms"));
1421
+ *
1422
+ * const deps = testCreateDeps();
1423
+ * const totalStep = withTotal(deps);
1424
+ * totalStep(undefined);
1425
+ * expectOk(totalStep(undefined), [300, 200]);
1426
+ * expectOk(collected(deps)(undefined), [[1000], 1000]);
1427
+ * expectOk(counted(deps)(undefined), [
1428
+ * { attempts: 1, lastDelay: 100 },
1429
+ * 100,
1430
+ * ]);
1081
1431
  * ```
1082
1432
  *
1083
1433
  * @group Transform
@@ -1104,12 +1454,15 @@ export const foldSchedule =
1104
1454
  * Outputs 0, 1, 2, ... while preserving the underlying schedule's timing and
1105
1455
  * termination behavior.
1106
1456
  *
1107
- * ### Example
1457
+ * ### Counting repetitions
1108
1458
  *
1109
1459
  * ```ts
1110
- * // Track how many retries occurred
1111
- * const counted = repetitions(exponential("100ms"));
1112
- * // Outputs: 0, 1, 2, ... with exponential delays
1460
+ * import { exponential, repetitions, testCreateDeps } from "@evolu/common";
1461
+ *
1462
+ * // Count retries while preserving exponential timing.
1463
+ * const step = repetitions(exponential("100ms"))(testCreateDeps());
1464
+ * expectOk(step(undefined), [0, 100]);
1465
+ * expectOk(step(undefined), [1, 200]);
1113
1466
  * ```
1114
1467
  *
1115
1468
  * @group Transform
@@ -1124,17 +1477,28 @@ export const repetitions = <Output, Input>(
1124
1477
  * Wraps a schedule to output its delay (in milliseconds) instead of the
1125
1478
  * original output. Useful for monitoring or logging delay patterns.
1126
1479
  *
1127
- * ### Example
1480
+ * ### Exposing and observing delays
1128
1481
  *
1129
1482
  * ```ts
1130
- * // Monitor exponential delays
1483
+ * import {
1484
+ * delays,
1485
+ * exponential,
1486
+ * tapScheduleOutput,
1487
+ * testCreateDeps,
1488
+ * type Millis,
1489
+ * } from "@evolu/common";
1490
+ *
1491
+ * // Expose delays for monitoring, or observe them without changing output.
1131
1492
  * const monitorDelays = delays(exponential("100ms"));
1132
- * // Outputs: 100, 200, 400, 800, ... (the delays themselves)
1493
+ * const observed: Array<Millis> = [];
1494
+ * const logged = tapScheduleOutput((delay: Millis) => {
1495
+ * observed.push(delay);
1496
+ * })(delays(exponential("100ms")));
1497
+ * const deps = testCreateDeps();
1133
1498
  *
1134
- * // Log delays for debugging
1135
- * const logged = tapScheduleOutput(console.log)(
1136
- * delays(exponential("100ms")),
1137
- * );
1499
+ * expectOk(monitorDelays(deps)(undefined), [100, 100]);
1500
+ * expectOk(logged(deps)(undefined), [100, 100]);
1501
+ * expect(observed).toEqual([100]);
1138
1502
  * ```
1139
1503
  *
1140
1504
  * @group Transform
@@ -1154,14 +1518,25 @@ export const delays =
1154
1518
  /**
1155
1519
  * Collects all outputs into an array.
1156
1520
  *
1157
- * Each step outputs an array containing all outputs so far.
1521
+ * Each step outputs a new snapshot containing all outputs so far. Because all
1522
+ * outputs are retained and copied on each step, use this combinator with finite
1523
+ * schedules.
1158
1524
  *
1159
- * ### Example
1525
+ * ### Collecting outputs
1160
1526
  *
1161
1527
  * ```ts
1162
- * // Collect all delays
1528
+ * import {
1529
+ * collectAllScheduleOutputs,
1530
+ * spaced,
1531
+ * take,
1532
+ * testCreateDeps,
1533
+ * } from "@evolu/common";
1534
+ *
1535
+ * // Retain every delay produced by the finite schedule.
1163
1536
  * const collected = collectAllScheduleOutputs(take(3)(spaced("100ms")));
1164
- * // Outputs: [100], [100, 100], [100, 100, 100]
1537
+ * const step = collected(testCreateDeps());
1538
+ * step(undefined);
1539
+ * expectOk(step(undefined), [[100, 100], 100]);
1165
1540
  * ```
1166
1541
  *
1167
1542
  * @group Collection
@@ -1179,14 +1554,24 @@ export const collectAllScheduleOutputs = <Output, Input>(
1179
1554
  * Each step outputs an array containing all inputs received so far. Mirror of
1180
1555
  * {@link collectAllScheduleOutputs} but for inputs.
1181
1556
  *
1182
- * ### Example
1557
+ * ### Collecting inputs
1183
1558
  *
1184
1559
  * ```ts
1185
- * // Collect all errors during retry
1186
- * const errorHistory = collectScheduleInputs(
1187
- * take(3)(exponential("100ms")),
1188
- * );
1189
- * // After 3 retries, outputs array of all error inputs
1560
+ * import {
1561
+ * collectScheduleInputs,
1562
+ * exponential,
1563
+ * take,
1564
+ * testCreateDeps,
1565
+ * type Millis,
1566
+ * type Schedule,
1567
+ * } from "@evolu/common";
1568
+ *
1569
+ * const retries: Schedule<Millis, string> = take(3)(exponential("100ms"));
1570
+ * // Keep every error received during retry.
1571
+ * const errorHistory = collectScheduleInputs(retries);
1572
+ * const step = errorHistory(testCreateDeps());
1573
+ * step("network");
1574
+ * expectOk(step("timeout"), [["network", "timeout"], 200]);
1190
1575
  * ```
1191
1576
  *
1192
1577
  * @group Collection
@@ -1202,14 +1587,27 @@ export const collectScheduleInputs = <Output, Input>(
1202
1587
  * More flexible than {@link collectAllScheduleOutputs} — stops collecting when
1203
1588
  * the predicate returns false.
1204
1589
  *
1205
- * ### Example
1590
+ * ### Collecting while output matches
1206
1591
  *
1207
1592
  * ```ts
1208
- * // Collect delays while under 1 second
1593
+ * import {
1594
+ * collectWhileScheduleOutput,
1595
+ * done,
1596
+ * exponential,
1597
+ * testCreateDeps,
1598
+ * type Millis,
1599
+ * } from "@evolu/common";
1600
+ *
1601
+ * // Collect delays only while they remain below one second.
1209
1602
  * const smallDelays = collectWhileScheduleOutput(
1210
1603
  * (delay: Millis) => delay < 1000,
1211
1604
  * )(exponential("100ms"));
1212
- * // Outputs: [100], [100, 200], [100, 200, 400], [100, 200, 400, 800], stops
1605
+ * const step = smallDelays(testCreateDeps());
1606
+ * step(undefined);
1607
+ * step(undefined);
1608
+ * step(undefined);
1609
+ * expectOk(step(undefined), [[100, 200, 400, 800], 800]);
1610
+ * expectErr(step(undefined), done());
1213
1611
  * ```
1214
1612
  *
1215
1613
  * @group Collection
@@ -1225,16 +1623,29 @@ export const collectWhileScheduleOutput =
1225
1623
  * Collects outputs until a predicate becomes true.
1226
1624
  *
1227
1625
  * Mirror of {@link collectWhileScheduleOutput} — stops collecting when the
1228
- * predicate returns true (inclusive of the matching output).
1626
+ * predicate returns true.
1229
1627
  *
1230
- * ### Example
1628
+ * ### Collecting until output matches
1231
1629
  *
1232
1630
  * ```ts
1233
- * // Collect delays until reaching 1 second
1631
+ * import {
1632
+ * collectUntilScheduleOutput,
1633
+ * done,
1634
+ * exponential,
1635
+ * testCreateDeps,
1636
+ * type Millis,
1637
+ * } from "@evolu/common";
1638
+ *
1639
+ * // Collect delays until the next delay reaches at least one second.
1234
1640
  * const untilLarge = collectUntilScheduleOutput(
1235
1641
  * (delay: Millis) => delay >= 1000,
1236
1642
  * )(exponential("100ms"));
1237
- * // Outputs: [100], [100, 200], [100, 200, 400], [100, 200, 400, 800], stops
1643
+ * const step = untilLarge(testCreateDeps());
1644
+ * step(undefined);
1645
+ * step(undefined);
1646
+ * step(undefined);
1647
+ * expectOk(step(undefined), [[100, 200, 400, 800], 800]);
1648
+ * expectErr(step(undefined), done());
1238
1649
  * ```
1239
1650
  *
1240
1651
  * @group Collection
@@ -1252,16 +1663,27 @@ export const collectUntilScheduleOutput =
1252
1663
  * Useful for adaptive strategies that start aggressive and become more
1253
1664
  * conservative over time.
1254
1665
  *
1255
- * ### Example
1666
+ * ### Sequencing strategies
1256
1667
  *
1257
1668
  * ```ts
1258
- * // Fast retries first, then slower, then final fallback
1259
- * const adaptive = sequenceSchedules(
1669
+ * import {
1670
+ * exponential,
1671
+ * fixed,
1672
+ * sequenceSchedules,
1673
+ * take,
1674
+ * testCreateDeps,
1675
+ * } from "@evolu/common";
1676
+ *
1677
+ * // Fast retries first, then slower retries, then a steady fallback.
1678
+ * const step = sequenceSchedules(
1260
1679
  * take(3)(exponential("100ms")),
1261
1680
  * take(5)(fixed("500ms")),
1262
1681
  * fixed("1s"),
1263
- * );
1264
- * // Runs: 100ms, 200ms, 400ms, then 500ms×5, then 1s forever
1682
+ * )(testCreateDeps());
1683
+ * step(undefined);
1684
+ * step(undefined);
1685
+ * step(undefined);
1686
+ * expectOk(step(undefined), [0, 500]);
1265
1687
  * ```
1266
1688
  *
1267
1689
  * @group Composition
@@ -1273,8 +1695,7 @@ export const sequenceSchedules =
1273
1695
  (deps) => {
1274
1696
  let index = 0;
1275
1697
  type Step =
1276
- | ((input: Input) => NextResult<readonly [Output, Millis]>)
1277
- | null;
1698
+ ((input: Input) => NextResult<readonly [Output, Millis]>) | null;
1278
1699
  let currentStep: Step = schedules.length > 0 ? schedules[0](deps) : null;
1279
1700
  return (input) => {
1280
1701
  while (currentStep !== null) {
@@ -1294,14 +1715,28 @@ export const sequenceSchedules =
1294
1715
  *
1295
1716
  * Continues only while both schedules want to continue. Uses the maximum delay.
1296
1717
  *
1297
- * ### Example
1718
+ * ### Combining constraints with AND
1298
1719
  *
1299
1720
  * ```ts
1300
- * // Retry up to 5 times AND within 30 seconds (both conditions must be met)
1301
- * const both = intersectSchedules(
1721
+ * import {
1722
+ * done,
1723
+ * exponential,
1724
+ * forever,
1725
+ * intersectSchedules,
1726
+ * maxElapsed,
1727
+ * take,
1728
+ * testCreateDeps,
1729
+ * } from "@evolu/common";
1730
+ *
1731
+ * // Retry at most 5 times and only within 30 seconds.
1732
+ * const deps = testCreateDeps();
1733
+ * const step = intersectSchedules(
1302
1734
  * take(5)(exponential("1s")),
1303
1735
  * maxElapsed("30s")(forever),
1304
- * );
1736
+ * )(deps);
1737
+ * expectOk(step(undefined), [[1000, 0], 1000]);
1738
+ * deps.time.advance("30s");
1739
+ * expectErr(step(undefined), done());
1305
1740
  * ```
1306
1741
  *
1307
1742
  * @group Composition
@@ -1314,13 +1749,18 @@ export const intersectSchedules =
1314
1749
  (deps) => {
1315
1750
  const stepA = a(deps);
1316
1751
  const stepB = b(deps);
1752
+ let stopped = false;
1317
1753
  return (input) => {
1754
+ if (stopped) return err(done());
1318
1755
  const resultA = stepA(input);
1319
1756
  const resultB = stepB(input);
1320
- if (!resultA.ok || !resultB.ok) return err(done());
1757
+ if (!resultA.ok || !resultB.ok) {
1758
+ stopped = true;
1759
+ return err(done());
1760
+ }
1321
1761
  const [outputA, delayA] = resultA.value;
1322
1762
  const [outputB, delayB] = resultB.value;
1323
- return ok([[outputA, outputB], Millis.orThrow(Math.max(delayA, delayB))]);
1763
+ return ok([[outputA, outputB], max(delayA, delayB)]);
1324
1764
  };
1325
1765
  };
1326
1766
 
@@ -1329,14 +1769,26 @@ export const intersectSchedules =
1329
1769
  *
1330
1770
  * Continues while either schedule wants to continue. Uses the minimum delay.
1331
1771
  *
1332
- * ### Example
1772
+ * ### Combining constraints with OR
1333
1773
  *
1334
1774
  * ```ts
1335
- * // Retry up to 5 times OR up to 30 seconds, whichever is longer
1775
+ * import {
1776
+ * done,
1777
+ * spaced,
1778
+ * take,
1779
+ * testCreateDeps,
1780
+ * unionSchedules,
1781
+ * } from "@evolu/common";
1782
+ *
1783
+ * // The second policy keeps the union alive after the first one stops.
1336
1784
  * const either = unionSchedules(
1337
- * take(5)(exponential("1s")),
1338
- * maxElapsed("30s")(forever),
1785
+ * take(1)(spaced("100ms")),
1786
+ * take(2)(spaced("200ms")),
1339
1787
  * );
1788
+ * const step = either(testCreateDeps());
1789
+ * expectOk(step(undefined), [100, 100]);
1790
+ * expectOk(step(undefined), [200, 200]);
1791
+ * expectErr(step(undefined), done());
1340
1792
  * ```
1341
1793
  *
1342
1794
  * @group Composition
@@ -1347,20 +1799,28 @@ export const unionSchedules =
1347
1799
  b: Schedule<OutputB, Input>,
1348
1800
  ): Schedule<OutputA | OutputB, Input> =>
1349
1801
  (deps) => {
1350
- const stepA = a(deps);
1351
- const stepB = b(deps);
1802
+ let stepA:
1803
+ ((input: Input) => NextResult<readonly [OutputA, Millis]>) | null =
1804
+ a(deps);
1805
+ let stepB:
1806
+ ((input: Input) => NextResult<readonly [OutputB, Millis]>) | null =
1807
+ b(deps);
1352
1808
  return (input) => {
1353
- const resultA = stepA(input);
1354
- const resultB = stepB(input);
1809
+ if (stepA === null && stepB === null) return err(done());
1355
1810
 
1356
- if (!resultA.ok && !resultB.ok) return err(done());
1357
- if (!resultA.ok) return resultB;
1358
- if (!resultB.ok) return resultA;
1811
+ const resultA = stepA?.(input) ?? null;
1812
+ const resultB = stepB?.(input) ?? null;
1813
+
1814
+ if (resultA !== null && !resultA.ok) stepA = null;
1815
+ if (resultB !== null && !resultB.ok) stepB = null;
1816
+
1817
+ if (!resultA?.ok) return resultB?.ok ? resultB : err(done());
1818
+ if (!resultB?.ok) return resultA;
1359
1819
 
1360
1820
  const [outputA, delayA] = resultA.value;
1361
1821
  const [outputB, delayB] = resultB.value;
1362
1822
  // Use minimum delay, output from the one with smaller delay
1363
- const minDelay = Millis.orThrow(Math.min(delayA, delayB));
1823
+ const minDelay = min(delayA, delayB);
1364
1824
  return delayA <= delayB
1365
1825
  ? ok([outputA, minDelay])
1366
1826
  : ok([outputB, minDelay]);
@@ -1374,15 +1834,36 @@ export const unionSchedules =
1374
1834
  * base schedule. Useful for implementing error-aware backoff where certain
1375
1835
  * errors (e.g., throttling) use different delays.
1376
1836
  *
1377
- * ### Example
1837
+ * Each branch has independent state. Place combinators such as {@link take}
1838
+ * outside `whenInput` when their state must be shared across both branches.
1839
+ *
1840
+ * ### Selecting a schedule by input
1378
1841
  *
1379
1842
  * ```ts
1843
+ * import {
1844
+ * done,
1845
+ * exponential,
1846
+ * take,
1847
+ * testCreateDeps,
1848
+ * whenInput,
1849
+ * type Millis,
1850
+ * type Typed,
1851
+ * } from "@evolu/common";
1852
+ *
1380
1853
  * interface MyError extends Typed<"Throttled" | "NetworkError"> {}
1381
1854
  *
1382
- * const awsWithThrottling = whenInput<MyError, Millis>(
1383
- * (error) => error.type === "Throttled",
1384
- * exponential("1s"), // throttled: 1s base
1385
- * )(exponential("100ms")); // normal: 100ms base
1855
+ * // The outer take shares one retry limit across both error branches.
1856
+ * const awsWithThrottling = take(3)(
1857
+ * whenInput<MyError, Millis>(
1858
+ * (error) => error.type === "Throttled",
1859
+ * exponential("1s"),
1860
+ * )(exponential("100ms")),
1861
+ * );
1862
+ * const step = awsWithThrottling(testCreateDeps());
1863
+ * expectOk(step({ type: "Throttled" }), [1000, 1000]);
1864
+ * expectOk(step({ type: "NetworkError" }), [100, 100]);
1865
+ * expectOk(step({ type: "Throttled" }), [2000, 2000]);
1866
+ * expectErr(step({ type: "NetworkError" }), done());
1386
1867
  * ```
1387
1868
  *
1388
1869
  * @group Composition
@@ -1396,9 +1877,12 @@ export const whenInput =
1396
1877
  (deps) => {
1397
1878
  const normalStep = schedule(deps);
1398
1879
  const altStep = altSchedule(deps);
1880
+ let stopped = false;
1399
1881
  return (input) => {
1400
- if (predicate(input)) return altStep(input);
1401
- return normalStep(input);
1882
+ if (stopped) return err(done());
1883
+ const result = predicate(input) ? altStep(input) : normalStep(input);
1884
+ if (!result.ok) stopped = true;
1885
+ return result;
1402
1886
  };
1403
1887
  };
1404
1888
 
@@ -1408,19 +1892,34 @@ export const whenInput =
1408
1892
  * Useful for logging, monitoring, or debugging without changing schedule
1409
1893
  * behavior.
1410
1894
  *
1411
- * ### Example
1895
+ * ### Observing schedule output
1412
1896
  *
1413
1897
  * ```ts
1414
- * // Log each delay for debugging
1898
+ * import {
1899
+ * exponential,
1900
+ * retryStrategyAws,
1901
+ * tapScheduleOutput,
1902
+ * testCreateDeps,
1903
+ * type Millis,
1904
+ * } from "@evolu/common";
1905
+ *
1906
+ * // Log each delay for debugging.
1907
+ * const messages: Array<string> = [];
1415
1908
  * const logged = tapScheduleOutput((delay: Millis) => {
1416
- * console.log(`Next delay: ${delay}ms`);
1909
+ * messages.push(`Next delay: ${delay}ms`);
1417
1910
  * })(exponential("100ms"));
1418
1911
  *
1419
- * // Track metrics
1420
- * const recorded: Array<Millis> = [];
1421
- * const tracked = tapScheduleOutput((delay: Millis) => {
1422
- * recorded.push(delay);
1912
+ * // Track the preset's pre-jitter delay candidates without changing it.
1913
+ * const recordedCandidates: Array<Millis> = [];
1914
+ * const tracked = tapScheduleOutput((candidate: Millis) => {
1915
+ * recordedCandidates.push(candidate);
1423
1916
  * })(retryStrategyAws);
1917
+ * const deps = testCreateDeps();
1918
+ * logged(deps)(undefined);
1919
+ * tracked(deps)(undefined);
1920
+ *
1921
+ * expect(messages).toEqual(["Next delay: 100ms"]);
1922
+ * expect(recordedCandidates).toEqual([50]);
1424
1923
  * ```
1425
1924
  *
1426
1925
  * @group Side effects
@@ -1444,23 +1943,38 @@ export const tapScheduleOutput =
1444
1943
  * Useful for logging errors during retry or monitoring what values are being
1445
1944
  * processed.
1446
1945
  *
1447
- * ### Example
1946
+ * ### Observing schedule input
1448
1947
  *
1449
1948
  * ```ts
1450
- * interface MyError extends Typed<string> {}
1949
+ * import {
1950
+ * exponential,
1951
+ * tapScheduleInput,
1952
+ * testCreateDeps,
1953
+ * type Millis,
1954
+ * type Schedule,
1955
+ * type Typed,
1956
+ * } from "@evolu/common";
1451
1957
  *
1958
+ * interface MyError extends Typed<"NetworkError" | "Timeout"> {}
1452
1959
  * const retrySchedule: Schedule<Millis, MyError> = exponential("100ms");
1453
- *
1454
- * // Log each error during retry
1960
+ * // Log errors for debugging.
1961
+ * const messages: Array<string> = [];
1455
1962
  * const logged = tapScheduleInput((error: MyError) => {
1456
- * console.log(`Retrying after error: ${error.type}`);
1963
+ * messages.push(`Retrying after error: ${error.type}`);
1457
1964
  * })(retrySchedule);
1458
1965
  *
1459
- * // Track retry reasons
1966
+ * // Or retain just the retry reasons for metrics.
1460
1967
  * const reasons: Array<string> = [];
1461
1968
  * const tracked = tapScheduleInput((error: MyError) => {
1462
1969
  * reasons.push(error.type);
1463
1970
  * })(retrySchedule);
1971
+ * const deps = testCreateDeps();
1972
+ * const error: MyError = { type: "NetworkError" };
1973
+ * logged(deps)(error);
1974
+ * tracked(deps)(error);
1975
+ *
1976
+ * expect(messages).toEqual(["Retrying after error: NetworkError"]);
1977
+ * expect(reasons).toEqual(["NetworkError"]);
1464
1978
  * ```
1465
1979
  *
1466
1980
  * @group Side effects
@@ -1477,17 +1991,19 @@ export const tapScheduleInput =
1477
1991
  };
1478
1992
 
1479
1993
  /**
1480
- * AWS standard retry strategy.
1994
+ * AWS SDK for Java 2.1 ordinary-failure retry timing.
1481
1995
  *
1482
- * Exponential backoff (100ms base), max 2 retries (3 total attempts), 20s cap,
1996
+ * Exponential backoff (50ms base), max 2 retries (3 total attempts), 20s cap,
1483
1997
  * full jitter.
1484
1998
  *
1999
+ * This schedule does not model throttling-specific timing, token accounting, or
2000
+ * circuit breaking.
2001
+ *
1485
2002
  * @group Retry Strategies
1486
- * @see https://github.com/aws/aws-sdk-java-v2/blob/master/core/retries/src/main/java/software/amazon/awssdk/retries/StandardRetryStrategy.java
1487
- * @see https://github.com/aws/aws-sdk-java-v2/blob/master/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
2003
+ * @see https://github.com/aws/aws-sdk-java-v2/blob/b69b75f07b6ebd93fd44b032d49b76a3b71fbb90/core/retries/src/main/java/software/amazon/awssdk/retries/DefaultRetryStrategy.java
1488
2004
  */
1489
- export const retryStrategyAws: Schedule<Millis> = /*#__PURE__*/ jitter(1)(
2005
+ export const retryStrategyAws: Schedule<Millis> = /*#__PURE__*/ jitter("100%")(
1490
2006
  /*#__PURE__*/ maxDelay("20s")(
1491
- /*#__PURE__*/ take(2)(/*#__PURE__*/ exponential("100ms")),
2007
+ /*#__PURE__*/ take(2)(/*#__PURE__*/ exponential("50ms")),
1492
2008
  ),
1493
2009
  );