@evolu/common 6.0.1-preview.8 → 7.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 (184) hide show
  1. package/README.md +2 -2
  2. package/dist/src/Array.d.ts +256 -12
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +215 -9
  5. package/dist/src/Assert.d.ts +0 -13
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +0 -15
  8. package/dist/src/Brand.d.ts +75 -0
  9. package/dist/src/Brand.d.ts.map +1 -0
  10. package/dist/src/Brand.js +1 -0
  11. package/dist/src/Buffer.d.ts +1 -1
  12. package/dist/src/Buffer.d.ts.map +1 -1
  13. package/dist/src/Buffer.js +8 -7
  14. package/dist/src/Cache.d.ts +44 -0
  15. package/dist/src/Cache.d.ts.map +1 -0
  16. package/dist/src/Cache.js +52 -0
  17. package/dist/src/Callbacks.d.ts +45 -12
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Callbacks.js +14 -7
  20. package/dist/src/Console.d.ts +31 -6
  21. package/dist/src/Console.d.ts.map +1 -1
  22. package/dist/src/Console.js +72 -9
  23. package/dist/src/Crypto.d.ts +56 -42
  24. package/dist/src/Crypto.d.ts.map +1 -1
  25. package/dist/src/Crypto.js +40 -53
  26. package/dist/src/Evolu/Db.d.ts +162 -74
  27. package/dist/src/Evolu/Db.d.ts.map +1 -1
  28. package/dist/src/Evolu/Db.js +284 -702
  29. package/dist/src/Evolu/Diff.d.ts +3 -3
  30. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  31. package/dist/src/Evolu/Diff.js +7 -5
  32. package/dist/src/Evolu/Evolu.d.ts +214 -134
  33. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  34. package/dist/src/Evolu/Evolu.js +189 -180
  35. package/dist/src/Evolu/Internal.d.ts +0 -2
  36. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  37. package/dist/src/Evolu/Internal.js +0 -2
  38. package/dist/src/Evolu/LocalAuth.d.ts +150 -0
  39. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  40. package/dist/src/Evolu/LocalAuth.js +174 -0
  41. package/dist/src/Evolu/Owner.d.ts +273 -120
  42. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  43. package/dist/src/Evolu/Owner.js +130 -104
  44. package/dist/src/Evolu/Platform.d.ts +9 -7
  45. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  46. package/dist/src/Evolu/Protocol.d.ts +268 -240
  47. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  48. package/dist/src/Evolu/Protocol.js +600 -454
  49. package/dist/src/Evolu/Public.d.ts +6 -8
  50. package/dist/src/Evolu/Public.d.ts.map +1 -1
  51. package/dist/src/Evolu/Public.js +2 -3
  52. package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
  53. package/dist/src/Evolu/PublicKysely.js +3 -4
  54. package/dist/src/Evolu/Query.d.ts +2 -1
  55. package/dist/src/Evolu/Query.d.ts.map +1 -1
  56. package/dist/src/Evolu/Query.js +1 -1
  57. package/dist/src/Evolu/Relay.d.ts +91 -8
  58. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  59. package/dist/src/Evolu/Relay.js +214 -88
  60. package/dist/src/Evolu/Schema.d.ts +125 -47
  61. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  62. package/dist/src/Evolu/Schema.js +175 -31
  63. package/dist/src/Evolu/Storage.d.ts +249 -27
  64. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  65. package/dist/src/Evolu/Storage.js +198 -92
  66. package/dist/src/Evolu/Sync.d.ts +68 -13
  67. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  68. package/dist/src/Evolu/Sync.js +469 -20
  69. package/dist/src/Evolu/Timestamp.d.ts +83 -30
  70. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  71. package/dist/src/Evolu/Timestamp.js +79 -34
  72. package/dist/src/Identicon.d.ts +35 -0
  73. package/dist/src/Identicon.d.ts.map +1 -0
  74. package/dist/src/Identicon.js +143 -0
  75. package/dist/src/Instances.d.ts +34 -0
  76. package/dist/src/Instances.d.ts.map +1 -0
  77. package/dist/src/Instances.js +44 -0
  78. package/dist/src/Number.d.ts +4 -3
  79. package/dist/src/Number.d.ts.map +1 -1
  80. package/dist/src/Number.js +5 -4
  81. package/dist/src/Object.d.ts +10 -4
  82. package/dist/src/Object.d.ts.map +1 -1
  83. package/dist/src/Object.js +9 -3
  84. package/dist/src/Platform.d.ts +20 -0
  85. package/dist/src/Platform.d.ts.map +1 -0
  86. package/dist/src/Platform.js +22 -0
  87. package/dist/src/Random.d.ts +3 -2
  88. package/dist/src/Random.d.ts.map +1 -1
  89. package/dist/src/Relation.d.ts +79 -0
  90. package/dist/src/Relation.d.ts.map +1 -0
  91. package/dist/src/Relation.js +127 -0
  92. package/dist/src/Resources.d.ts +118 -0
  93. package/dist/src/Resources.d.ts.map +1 -0
  94. package/dist/src/Resources.js +197 -0
  95. package/dist/src/Result.d.ts +184 -52
  96. package/dist/src/Result.d.ts.map +1 -1
  97. package/dist/src/Result.js +30 -241
  98. package/dist/src/Skiplist.js +2 -1
  99. package/dist/src/Sqlite.d.ts +89 -5
  100. package/dist/src/Sqlite.d.ts.map +1 -1
  101. package/dist/src/Sqlite.js +136 -9
  102. package/dist/src/Task.d.ts +586 -0
  103. package/dist/src/Task.d.ts.map +1 -0
  104. package/dist/src/Task.js +469 -0
  105. package/dist/src/Time.d.ts +66 -1
  106. package/dist/src/Time.d.ts.map +1 -1
  107. package/dist/src/Time.js +99 -5
  108. package/dist/src/Type.d.ts +676 -343
  109. package/dist/src/Type.d.ts.map +1 -1
  110. package/dist/src/Type.js +718 -467
  111. package/dist/src/Types.d.ts +1 -75
  112. package/dist/src/Types.d.ts.map +1 -1
  113. package/dist/src/WebSocket.d.ts +5 -2
  114. package/dist/src/WebSocket.d.ts.map +1 -1
  115. package/dist/src/WebSocket.js +12 -18
  116. package/dist/src/Worker.d.ts +39 -11
  117. package/dist/src/Worker.d.ts.map +1 -1
  118. package/dist/src/Worker.js +22 -4
  119. package/dist/src/index.d.ts +8 -3
  120. package/dist/src/index.d.ts.map +1 -1
  121. package/dist/src/index.js +8 -3
  122. package/package.json +15 -14
  123. package/src/Array.ts +301 -19
  124. package/src/Assert.ts +0 -21
  125. package/src/Brand.ts +75 -0
  126. package/src/Buffer.ts +7 -7
  127. package/src/Cache.ts +85 -0
  128. package/src/Callbacks.ts +62 -22
  129. package/src/Console.ts +91 -11
  130. package/src/Crypto.ts +94 -90
  131. package/src/Evolu/Db.ts +519 -1026
  132. package/src/Evolu/Diff.ts +7 -5
  133. package/src/Evolu/Evolu.ts +477 -361
  134. package/src/Evolu/Internal.ts +0 -2
  135. package/src/Evolu/LocalAuth.ts +463 -0
  136. package/src/Evolu/Owner.ts +355 -228
  137. package/src/Evolu/Platform.ts +9 -9
  138. package/src/Evolu/Protocol.ts +842 -742
  139. package/src/Evolu/Public.ts +7 -14
  140. package/src/Evolu/PublicKysely.ts +4 -5
  141. package/src/Evolu/Query.ts +3 -2
  142. package/src/Evolu/Relay.ts +406 -103
  143. package/src/Evolu/Schema.ts +323 -91
  144. package/src/Evolu/Storage.ts +559 -137
  145. package/src/Evolu/Sync.ts +819 -36
  146. package/src/Evolu/Timestamp.ts +90 -58
  147. package/src/Identicon.ts +197 -0
  148. package/src/Instances.ts +90 -0
  149. package/src/Number.ts +6 -10
  150. package/src/Object.ts +13 -5
  151. package/src/Platform.ts +26 -0
  152. package/src/Random.ts +3 -2
  153. package/src/Relation.ts +234 -0
  154. package/src/Resources.ts +367 -0
  155. package/src/Result.ts +191 -54
  156. package/src/Skiplist.ts +1 -1
  157. package/src/Sqlite.ts +152 -17
  158. package/src/Task.ts +901 -0
  159. package/src/Time.ts +180 -5
  160. package/src/Type.ts +1135 -730
  161. package/src/Types.ts +1 -77
  162. package/src/WebSocket.ts +27 -25
  163. package/src/Worker.ts +72 -23
  164. package/src/index.ts +8 -3
  165. package/dist/src/Evolu/Config.d.ts +0 -69
  166. package/dist/src/Evolu/Config.d.ts.map +0 -1
  167. package/dist/src/Evolu/Config.js +0 -9
  168. package/dist/src/Evolu/Kysely.d.ts +0 -6
  169. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  170. package/dist/src/Evolu/Kysely.js +0 -21
  171. package/dist/src/ManyToManyMap.d.ts +0 -26
  172. package/dist/src/ManyToManyMap.d.ts.map +0 -1
  173. package/dist/src/ManyToManyMap.js +0 -92
  174. package/dist/src/NanoId.d.ts +0 -27
  175. package/dist/src/NanoId.d.ts.map +0 -1
  176. package/dist/src/NanoId.js +0 -6
  177. package/dist/src/Promise.d.ts +0 -180
  178. package/dist/src/Promise.d.ts.map +0 -1
  179. package/dist/src/Promise.js +0 -176
  180. package/src/Evolu/Config.ts +0 -83
  181. package/src/Evolu/Kysely.ts +0 -38
  182. package/src/ManyToManyMap.ts +0 -140
  183. package/src/NanoId.ts +0 -39
  184. package/src/Promise.ts +0 -295
package/src/Task.ts ADDED
@@ -0,0 +1,901 @@
1
+ /**
2
+ * ⚡ Lazy, cancellable Promise that returns Result instead of throwing
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { isNonEmptyArray, shiftArray } from "./Array.js";
8
+ import { Result, err, ok } from "./Result.js";
9
+ import { Duration, durationToNonNegativeInt } from "./Time.js";
10
+ import { NonNegativeInt, PositiveInt } from "./Type.js";
11
+
12
+ /**
13
+ * `Task` is a lazy, cancellable Promise that returns {@link Result} instead of
14
+ * throwing.
15
+ *
16
+ * In other words, Task is a function that creates a Promise when it's called.
17
+ * This laziness allows safe composition, e.g. retry logic because it prevents
18
+ * eager execution.
19
+ *
20
+ * ### Cancellation
21
+ *
22
+ * Tasks support optional cancellation via signal in {@link TaskContext}. When a
23
+ * Task is called without a signal, it cannot be cancelled and {@link AbortError}
24
+ * will never be returned. When called with a signal, the Task can be cancelled
25
+ * and AbortError is added to the error union with precise type safety.
26
+ *
27
+ * When composing Tasks, we typically have context and want to abort ASAP by
28
+ * passing it through. However, there are valid cases where we don't want to
29
+ * abort because we need some atomic unit to complete. For simple scripts and
30
+ * tests, omitting context is fine.
31
+ *
32
+ * ### Task Helpers
33
+ *
34
+ * - {@link toTask} - Convert async function to Task
35
+ * - {@link wait} - Delay execution for a specified {@link Duration}
36
+ * - {@link timeout} - Add timeout to any Task
37
+ * - {@link retry} - Retry failed Tasks with configurable backoff
38
+ *
39
+ * ### Example
40
+ *
41
+ * ```ts
42
+ * interface FetchError {
43
+ * readonly type: "FetchError";
44
+ * readonly error: unknown;
45
+ * }
46
+ *
47
+ * // Task version of fetch with proper error handling and cancellation support.
48
+ * const fetch = (url: string) =>
49
+ * toTask((context) =>
50
+ * tryAsync(
51
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
52
+ * (error): FetchError => ({ type: "FetchError", error }),
53
+ * ),
54
+ * );
55
+ *
56
+ * // `satisfies` shows the expected type signature.
57
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
58
+ *
59
+ * // Add timeout to prevent hanging
60
+ * const fetchWithTimeout = (url: string) => timeout("30s", fetch(url));
61
+ *
62
+ * fetchWithTimeout satisfies (
63
+ * url: string,
64
+ * ) => Task<Response, TimeoutError | FetchError>;
65
+ *
66
+ * // Add retry for resilience
67
+ * const fetchWithRetry = (url: string) =>
68
+ * retry(
69
+ * {
70
+ * retries: PositiveInt.orThrow(3),
71
+ * initialDelay: "100ms",
72
+ * },
73
+ * fetchWithTimeout(url),
74
+ * );
75
+ *
76
+ * fetchWithRetry satisfies (
77
+ * url: string,
78
+ * ) => Task<
79
+ * Response,
80
+ * TimeoutError | FetchError | RetryError<TimeoutError | FetchError>
81
+ * >;
82
+ *
83
+ * const semaphore = createSemaphore(PositiveInt.orThrow(2));
84
+ *
85
+ * // Control concurrency with semaphore
86
+ * const fetchWithPermit = (url: string) =>
87
+ * semaphore.withPermit(fetchWithRetry(url));
88
+ *
89
+ * fetchWithPermit satisfies (url: string) => Task<
90
+ * Response,
91
+ * | TimeoutError
92
+ * | FetchError
93
+ * | AbortError // Semaphore dispose aborts Tasks
94
+ * | RetryError<TimeoutError | FetchError>
95
+ * >;
96
+ *
97
+ * // Usage
98
+ * const results = await Promise.all(
99
+ * [
100
+ * "https://api.example.com/users",
101
+ * "https://api.example.com/posts",
102
+ * "https://api.example.com/comments",
103
+ * ]
104
+ * .map(fetchWithPermit)
105
+ * .map((task) => task()),
106
+ * );
107
+ *
108
+ * results satisfies Array<
109
+ * Result<
110
+ * Response,
111
+ * | AbortError
112
+ * | TimeoutError
113
+ * | FetchError
114
+ * | RetryError<TimeoutError | FetchError>
115
+ * >
116
+ * >;
117
+ *
118
+ * // Handle results
119
+ * for (const result of results) {
120
+ * if (result.ok) {
121
+ * // Process successful response
122
+ * const response = result.value;
123
+ * expect(response).toBeInstanceOf(Response);
124
+ * } else {
125
+ * // Handle error (TimeoutError, FetchError, RetryError, or AbortError)
126
+ * expect(result.error).toBeDefined();
127
+ * }
128
+ * }
129
+ *
130
+ * // Cancellation support
131
+ * const controller = new AbortController();
132
+ * const cancelableTask = fetchWithPermit("https://api.example.com/data");
133
+ *
134
+ * // Start task
135
+ * const promise = cancelableTask(controller);
136
+ *
137
+ * // Cancel after some time
138
+ * setTimeout(() => {
139
+ * controller.abort("User cancelled");
140
+ * }, 1000);
141
+ *
142
+ * const _result = await promise;
143
+ * // Result will be AbortError if cancelled
144
+ * ```
145
+ *
146
+ * ### Dependency Injection Integration
147
+ *
148
+ * Tasks integrate naturally with Evolu's DI pattern. Use `deps` for static
149
+ * dependencies and `TaskContext` for execution context like cancellation. Usage
150
+ * follows the pattern: deps → arguments → execution context.
151
+ */
152
+ export interface Task<T, E> {
153
+ /**
154
+ * Invoke the Task.
155
+ *
156
+ * Provide a context with an AbortSignal to enable cancellation. When called
157
+ * without a signal, {@link AbortError} cannot occur and the error type narrows
158
+ * accordingly.
159
+ *
160
+ * ### Example
161
+ *
162
+ * ```ts
163
+ * interface FetchError {
164
+ * readonly type: "FetchError";
165
+ * readonly error: unknown;
166
+ * }
167
+ *
168
+ * // Task version of fetch with proper error handling and cancellation support.
169
+ * const fetch = (url: string) =>
170
+ * toTask((context) =>
171
+ * tryAsync(
172
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
173
+ * (error): FetchError => ({ type: "FetchError", error }),
174
+ * ),
175
+ * );
176
+ *
177
+ * // `satisfies` shows the expected type signature.
178
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
179
+ *
180
+ * const result1 = await fetch("https://api.example.com/data")();
181
+ * expectTypeOf(result1).toEqualTypeOf<Result<Response, FetchError>>();
182
+ *
183
+ * // With AbortController
184
+ * const controller = new AbortController();
185
+ * const result2 = await fetch("https://api.example.com/data")(
186
+ * controller,
187
+ * );
188
+ * expectTypeOf(result2).toEqualTypeOf<
189
+ * Result<Response, FetchError | AbortError>
190
+ * >();
191
+ * ```
192
+ */
193
+ // eslint-disable-next-line @typescript-eslint/prefer-function-type
194
+ <TContext extends TaskContext | undefined = undefined>(
195
+ context?: TContext,
196
+ ): Promise<
197
+ Result<T, TContext extends { signal: AbortSignal } ? E | AbortError : E>
198
+ >;
199
+ }
200
+
201
+ /** Context passed to {@link Task}s for cancellation. */
202
+ export interface TaskContext {
203
+ /** Signal for cancellation */
204
+ readonly signal?: AbortSignal;
205
+ }
206
+
207
+ /** Error returned when a {@link Task} is cancelled via AbortSignal. */
208
+ export interface AbortError {
209
+ readonly type: "AbortError";
210
+ readonly reason?: unknown;
211
+ }
212
+
213
+ /** Narrower check to detect AbortError objects at runtime. */
214
+ const isAbortError = (error: unknown): error is AbortError =>
215
+ typeof error === "object" &&
216
+ error !== null &&
217
+ (error as { type?: unknown }).type === "AbortError";
218
+
219
+ // For React Native
220
+ if (typeof AbortSignal.any !== "function") {
221
+ AbortSignal.any = function (signals: Array<AbortSignal>): AbortSignal {
222
+ const controller = new AbortController();
223
+
224
+ const onAbort = (event: Event) => {
225
+ controller.abort((event.target as AbortSignal).reason);
226
+ cleanup();
227
+ };
228
+
229
+ const cleanup = () => {
230
+ for (const s of signals) s.removeEventListener("abort", onAbort);
231
+ };
232
+
233
+ for (const s of signals) {
234
+ if (s.aborted) {
235
+ controller.abort(s.reason);
236
+ return controller.signal;
237
+ }
238
+ s.addEventListener("abort", onAbort);
239
+ }
240
+
241
+ return controller.signal;
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Combines user signal from context with an internal signal.
247
+ *
248
+ * If the context has a signal, combines both signals using AbortSignal.any().
249
+ * Otherwise, returns just the internal signal.
250
+ */
251
+ const combineSignal = (
252
+ context: TaskContext | undefined,
253
+ internalSignal: AbortSignal,
254
+ ): AbortSignal =>
255
+ context?.signal
256
+ ? AbortSignal.any([context.signal, internalSignal])
257
+ : internalSignal;
258
+
259
+ /**
260
+ * Converts async function returning {@link Result} to a {@link Task}.
261
+ *
262
+ * ### Example
263
+ *
264
+ * ```ts
265
+ * interface FetchError {
266
+ * readonly type: "FetchError";
267
+ * readonly error: unknown;
268
+ * }
269
+ *
270
+ * // Task version of fetch with proper error handling and cancellation support.
271
+ * const fetch = (url: string) =>
272
+ * toTask((context) =>
273
+ * tryAsync(
274
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
275
+ * (error): FetchError => ({ type: "FetchError", error }),
276
+ * ),
277
+ * );
278
+ *
279
+ * // `satisfies` shows the expected type signature.
280
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
281
+ *
282
+ * const result1 = await fetch("https://api.example.com/data")();
283
+ * result1 satisfies Result<Response, FetchError>;
284
+ *
285
+ * // With AbortController
286
+ * const controller = new AbortController();
287
+ * const result2 = await fetch("https://api.example.com/data")(controller);
288
+ * result2 satisfies Result<Response, FetchError | AbortError>;
289
+ * ```
290
+ */
291
+ export const toTask = <T, E>(
292
+ fn: (context?: TaskContext) => Promise<Result<T, E>>,
293
+ ): Task<T, E> =>
294
+ // Note: Not using async to avoid Promise wrapper overhead in fast path
295
+ ((context) => {
296
+ const signal = context?.signal;
297
+
298
+ // Fast path when no signal – return promise directly
299
+ if (!signal) {
300
+ // Preserve future context fields (e.g., tracing) even without a signal
301
+ return fn(context);
302
+ }
303
+
304
+ if (signal.aborted) {
305
+ return Promise.resolve(
306
+ err({ type: "AbortError", reason: signal.reason as unknown }),
307
+ );
308
+ }
309
+
310
+ // Use Promise.withResolvers for clean abort handling and cleanup
311
+ const { promise: abortPromise, resolve: resolveAbort } =
312
+ Promise.withResolvers<Result<never, AbortError>>();
313
+
314
+ const handleAbort = () => {
315
+ resolveAbort(
316
+ err({ type: "AbortError", reason: signal.reason as unknown }),
317
+ );
318
+ };
319
+
320
+ signal.addEventListener("abort", handleAbort, { once: true });
321
+
322
+ // No finally: we expect no throws in normal flow; Result path removes listener.
323
+ // Unexpected throws indicate a bug and are allowed to crash (no recovery here).
324
+ return Promise.race([
325
+ abortPromise,
326
+ fn(context).then((result) => {
327
+ signal.removeEventListener("abort", handleAbort);
328
+ return result;
329
+ }),
330
+ ]);
331
+ }) as Task<T, E>;
332
+
333
+ // For React Native
334
+ if (typeof AbortSignal.timeout !== "function") {
335
+ AbortSignal.timeout = function (ms: number): AbortSignal {
336
+ const controller = new AbortController();
337
+ const id = setTimeout(() => {
338
+ controller.abort();
339
+ }, ms);
340
+ // clear timeout if aborted early
341
+ controller.signal.addEventListener("abort", () => {
342
+ clearTimeout(id);
343
+ });
344
+ return controller.signal;
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Creates a {@link Task} that waits for the specified duration.
350
+ *
351
+ * ### Example
352
+ *
353
+ * ```ts
354
+ * const result1 = await wait("10ms")();
355
+ * result1 satisfies Result<void, never>;
356
+ *
357
+ * // With AbortController
358
+ * const controller = new AbortController();
359
+ * const result2 = await wait("10ms")(controller);
360
+ * result2 satisfies Result<void, AbortError>;
361
+ * ```
362
+ */
363
+ export const wait = (duration: Duration): Task<void, never> =>
364
+ toTask(
365
+ (context) =>
366
+ new Promise<Result<void, never>>((resolve) => {
367
+ const ms = durationToNonNegativeInt(duration);
368
+ const timeoutSignal = AbortSignal.timeout(ms);
369
+
370
+ const signal = combineSignal(context, timeoutSignal);
371
+
372
+ // Listen for abort - either from timeout completion or external abort
373
+ signal.addEventListener(
374
+ "abort",
375
+ () => {
376
+ resolve(ok());
377
+ },
378
+ { once: true },
379
+ );
380
+ }),
381
+ );
382
+
383
+ /** Error returned when {@link timeout} exceeds the specified duration. */
384
+ export interface TimeoutError {
385
+ readonly type: "TimeoutError";
386
+ readonly timeoutMs: number;
387
+ }
388
+
389
+ /**
390
+ * Adds timeout behavior to a {@link Task}.
391
+ *
392
+ * ### Example
393
+ *
394
+ * ```ts
395
+ * interface FetchError {
396
+ * readonly type: "FetchError";
397
+ * readonly error: unknown;
398
+ * }
399
+ *
400
+ * // Task version of fetch with proper error handling and cancellation support.
401
+ * const fetch = (url: string) =>
402
+ * toTask((context) =>
403
+ * tryAsync(
404
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
405
+ * (error): FetchError => ({ type: "FetchError", error }),
406
+ * ),
407
+ * );
408
+ *
409
+ * // `satisfies` shows the expected type signature.
410
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
411
+ *
412
+ * const fetchWithTimeout = (url: string) => timeout("2m", fetch(url));
413
+ *
414
+ * const result1 = await fetchWithTimeout("https://api.example.com/data")();
415
+ * result1 satisfies Result<Response, FetchError | TimeoutError>;
416
+ *
417
+ * // With AbortController
418
+ * const controller = new AbortController();
419
+ * const result2 = await fetchWithTimeout("https://api.example.com/data")(
420
+ * controller,
421
+ * );
422
+ * result2 satisfies Result<
423
+ * Response,
424
+ * FetchError | TimeoutError | AbortError
425
+ * >;
426
+ * ```
427
+ */
428
+ export const timeout = <T, E>(
429
+ duration: Duration,
430
+ task: Task<T, E>,
431
+ ): Task<T, E | TimeoutError> =>
432
+ toTask(async (context) => {
433
+ const timeoutMs = durationToNonNegativeInt(duration);
434
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
435
+
436
+ const signal = combineSignal(context, timeoutSignal);
437
+
438
+ const result = await task({ signal });
439
+
440
+ if (timeoutSignal.aborted) {
441
+ return err({ type: "TimeoutError", timeoutMs });
442
+ }
443
+
444
+ return result as Result<T, E | TimeoutError>;
445
+ });
446
+
447
+ /** Options for configuring {@link retry} behavior. */
448
+ export interface RetryOptions<E> {
449
+ /** Number of retry attempts after the initial failure. */
450
+ readonly retries: PositiveInt;
451
+
452
+ /**
453
+ * Initial delay for exponential backoff (1st retry uses this, 2nd uses
454
+ * this×factor, 3rd uses this×factor², etc.). Actual delays are randomized by
455
+ * {@link RetryOptions.jitter}.
456
+ */
457
+ readonly initialDelay?: Duration;
458
+
459
+ /** Maximum delay between retries. */
460
+ readonly maxDelay?: Duration;
461
+
462
+ /** Exponential backoff multiplier. */
463
+ readonly factor?: number;
464
+
465
+ /** Random jitter factor (0-1) to prevent thundering herd. */
466
+ readonly jitter?: number;
467
+
468
+ /**
469
+ * Predicate to determine if error should trigger retry. Receives AbortError
470
+ * too.
471
+ */
472
+ readonly retryable?: (error: E | AbortError) => boolean;
473
+
474
+ /** Callback invoked before each retry attempt. */
475
+ readonly onRetry?: (error: E, attempt: number, delay: number) => void;
476
+ }
477
+
478
+ /** Error returned when {@link retry} exhausts all retry attempts. */
479
+ export interface RetryError<E> {
480
+ readonly type: "RetryError";
481
+ readonly cause: E;
482
+ readonly attempts: number;
483
+ }
484
+
485
+ /**
486
+ * Adds retry logic with exponential backoff and jitter to a {@link Task}.
487
+ *
488
+ * ### Example
489
+ *
490
+ * ```ts
491
+ * interface FetchError {
492
+ * readonly type: "FetchError";
493
+ * readonly error: unknown;
494
+ * }
495
+ *
496
+ * // Task version of fetch with proper error handling and cancellation support.
497
+ * const fetch = (url: string) =>
498
+ * toTask((context) =>
499
+ * tryAsync(
500
+ * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
501
+ * (error): FetchError => ({ type: "FetchError", error }),
502
+ * ),
503
+ * );
504
+ *
505
+ * // `satisfies` shows the expected type signature.
506
+ * fetch satisfies (url: string) => Task<Response, FetchError>;
507
+ *
508
+ * const fetchWithRetry = (url: string) =>
509
+ * retry({ retries: PositiveInt.orThrow(3) }, fetch(url));
510
+ *
511
+ * const result1 = await fetchWithRetry("https://api.example.com/data")();
512
+ * result1 satisfies Result<Response, FetchError | RetryError<FetchError>>;
513
+ *
514
+ * // With AbortController
515
+ * const controller = new AbortController();
516
+ * const result2 = await fetchWithRetry("https://api.example.com/data")(
517
+ * controller,
518
+ * );
519
+ * result2 satisfies Result<
520
+ * Response,
521
+ * FetchError | RetryError<FetchError> | AbortError
522
+ * >;
523
+ * ```
524
+ */
525
+ export const retry = <T, E>(
526
+ {
527
+ retries,
528
+ initialDelay = "1s",
529
+ maxDelay = "30s",
530
+ factor = 2,
531
+ jitter = 0.5,
532
+ retryable = (error: E | AbortError) => !isAbortError(error),
533
+ onRetry,
534
+ }: RetryOptions<E>,
535
+ task: Task<T, E>,
536
+ ): Task<T, E | RetryError<E>> =>
537
+ toTask(async (context): Promise<Result<T, E | RetryError<E>>> => {
538
+ const initialDelayMs = durationToNonNegativeInt(initialDelay);
539
+ const maxDelayMs = durationToNonNegativeInt(maxDelay);
540
+ const maxRetries = PositiveInt.orThrow(retries);
541
+
542
+ let attempt = 0;
543
+
544
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
545
+ while (true) {
546
+ const result = await task(context);
547
+
548
+ if (result.ok) {
549
+ return result;
550
+ }
551
+
552
+ // Never retry on AbortError; propagate it directly
553
+ if (isAbortError(result.error)) {
554
+ return err(result.error) as Result<T, E | RetryError<E>>;
555
+ }
556
+
557
+ attempt += 1;
558
+
559
+ if (attempt > maxRetries || !retryable(result.error)) {
560
+ return err({
561
+ type: "RetryError",
562
+ cause: result.error,
563
+ attempts: attempt,
564
+ });
565
+ }
566
+
567
+ // Calculate delay with exponential backoff
568
+ const exponentialDelay = initialDelayMs * Math.pow(factor, attempt - 1);
569
+ const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
570
+
571
+ // Apply jitter to prevent thundering herd problem
572
+ const randomFactor = 1 - jitter + Math.random() * jitter * 2;
573
+ const delay = Math.floor(cappedDelay * randomFactor);
574
+
575
+ if (onRetry) {
576
+ onRetry(result.error, attempt, delay);
577
+ }
578
+
579
+ // Wait before retry
580
+ const delayResult = await wait(NonNegativeInt.orThrow(delay))(context);
581
+ if (!delayResult.ok) {
582
+ // If delay was aborted, return AbortError (will be handled by toTask)
583
+ return delayResult;
584
+ }
585
+ }
586
+ });
587
+
588
+ /**
589
+ * A semaphore that limits the number of concurrent async Tasks.
590
+ *
591
+ * For mutual exclusion (limiting to exactly one Task), consider using
592
+ * {@link Mutex} instead.
593
+ *
594
+ * @see {@link createSemaphore} to create a semaphore instance.
595
+ */
596
+ export interface Semaphore extends Disposable {
597
+ /**
598
+ * Executes a Task while holding a semaphore permit.
599
+ *
600
+ * The Task will wait until a permit is available before executing. Supports
601
+ * cancellation via AbortSignal - if the signal is aborted while waiting for a
602
+ * permit or during execution, the Task is cancelled and permits are properly
603
+ * released.
604
+ */
605
+ readonly withPermit: <T, E>(task: Task<T, E>) => Task<T, E | AbortError>;
606
+ }
607
+
608
+ /**
609
+ * Creates a semaphore that limits concurrent async Tasks to the specified
610
+ * count.
611
+ *
612
+ * A semaphore controls access to a resource by maintaining a count of available
613
+ * permits. Tasks acquire a permit before executing and release it when
614
+ * complete.
615
+ *
616
+ * For mutual exclusion (exactly one Task at a time), consider using
617
+ * {@link createMutex} instead.
618
+ *
619
+ * ### Example
620
+ *
621
+ * ```ts
622
+ * // Allow maximum 3 concurrent Tasks
623
+ * const semaphore = createSemaphore(PositiveInt.orThrow(3));
624
+ *
625
+ * let currentConcurrent = 0;
626
+ * const events: Array<string> = [];
627
+ *
628
+ * const fetchData = (id: number) =>
629
+ * toTask<number, never>(async (context) => {
630
+ * currentConcurrent++;
631
+ * events.push(`start ${id} (concurrent: ${currentConcurrent})`);
632
+ *
633
+ * await wait("10ms")(context);
634
+ *
635
+ * currentConcurrent--;
636
+ * events.push(`end ${id} (concurrent: ${currentConcurrent})`);
637
+ * return ok(id * 10);
638
+ * });
639
+ *
640
+ * // These will execute with at most 3 running concurrently
641
+ * const results = await Promise.all([
642
+ * semaphore.withPermit(fetchData(1))(),
643
+ * semaphore.withPermit(fetchData(2))(),
644
+ * semaphore.withPermit(fetchData(3))(),
645
+ * semaphore.withPermit(fetchData(4))(), // waits for one above to complete
646
+ * semaphore.withPermit(fetchData(5))(), // waits for permit
647
+ * ]);
648
+ *
649
+ * expect(results.map(getOrThrow)).toEqual([10, 20, 30, 40, 50]);
650
+ * expect(events).toMatchInlineSnapshot(`
651
+ * [
652
+ * "start 1 (concurrent: 1)",
653
+ * "start 2 (concurrent: 2)",
654
+ * "start 3 (concurrent: 3)",
655
+ * "end 1 (concurrent: 2)",
656
+ * "start 4 (concurrent: 3)",
657
+ * "end 2 (concurrent: 2)",
658
+ * "start 5 (concurrent: 3)",
659
+ * "end 3 (concurrent: 2)",
660
+ * "end 4 (concurrent: 1)",
661
+ * "end 5 (concurrent: 0)",
662
+ * ]
663
+ * `);
664
+ * ```
665
+ */
666
+ export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => {
667
+ let isDisposed = false;
668
+ let availablePermits = maxConcurrent;
669
+ const waitingQueue: Array<() => void> = [];
670
+ const semaphoreController = new AbortController();
671
+
672
+ const acquire = (): Promise<void> => {
673
+ if (availablePermits > 0) {
674
+ availablePermits--;
675
+ return Promise.resolve();
676
+ }
677
+
678
+ return new Promise<void>((resolve) => {
679
+ waitingQueue.push(resolve);
680
+ });
681
+ };
682
+
683
+ const release = (): void => {
684
+ if (isNonEmptyArray(waitingQueue)) {
685
+ shiftArray(waitingQueue)();
686
+ } else {
687
+ availablePermits++;
688
+ }
689
+ };
690
+
691
+ return {
692
+ withPermit: <T, E>(task: Task<T, E>): Task<T, E | AbortError> =>
693
+ toTask(async (context): Promise<Result<T, E | AbortError>> => {
694
+ await acquire();
695
+
696
+ // Check if semaphore was disposed while waiting
697
+ if (isDisposed) {
698
+ return err({
699
+ type: "AbortError",
700
+ reason: "Semaphore disposed",
701
+ });
702
+ }
703
+
704
+ const signal = combineSignal(context, semaphoreController.signal);
705
+
706
+ const result = await task({ signal });
707
+
708
+ release();
709
+
710
+ return result;
711
+ }),
712
+
713
+ [Symbol.dispose]: () => {
714
+ if (isDisposed) return;
715
+ isDisposed = true;
716
+
717
+ // Cancel all running and waiting tasks
718
+ semaphoreController.abort("Semaphore disposed");
719
+
720
+ // Release all waiting tasks so they can continue and check isDisposed
721
+ while (isNonEmptyArray(waitingQueue)) {
722
+ shiftArray(waitingQueue)();
723
+ }
724
+ },
725
+ };
726
+ };
727
+
728
+ /**
729
+ * A mutex (mutual exclusion) that ensures only one Task runs at a time.
730
+ *
731
+ * This is a specialized version of a {@link Semaphore} with a permit count of 1.
732
+ *
733
+ * @see {@link createMutex} to create a mutex instance.
734
+ */
735
+ export interface Mutex extends Disposable {
736
+ /**
737
+ * Executes a Task while holding the mutex lock.
738
+ *
739
+ * Only one Task can hold the lock at a time. Other Tasks will wait until the
740
+ * lock is released. Supports cancellation via AbortSignal.
741
+ */
742
+ readonly withLock: <T, E>(task: Task<T, E>) => Task<T, E | AbortError>;
743
+ }
744
+
745
+ /**
746
+ * Creates a new mutex for ensuring mutual exclusion.
747
+ *
748
+ * A mutex is a {@link createSemaphore} with exactly one permit, ensuring that
749
+ * only one Task can execute at a time.
750
+ *
751
+ * ### Example
752
+ *
753
+ * ```ts
754
+ * const mutex = createMutex();
755
+ *
756
+ * const updateTask = (id: number) =>
757
+ * toTask((context) =>
758
+ * tryAsync(
759
+ * () => updateSharedResource(id, context),
760
+ * (error): UpdateError => ({ type: "UpdateError", error }),
761
+ * ),
762
+ * );
763
+ *
764
+ * // These Tasks will execute one at a time
765
+ * const results = await Promise.all([
766
+ * mutex.withLock(updateTask(1))(),
767
+ * mutex.withLock(updateTask(2))(),
768
+ * mutex.withLock(updateTask(3))(),
769
+ * ]);
770
+ * ```
771
+ */
772
+ export const createMutex = (): Mutex => {
773
+ const mutex = createSemaphore(PositiveInt.orThrow(1));
774
+
775
+ return {
776
+ withLock: mutex.withPermit,
777
+ [Symbol.dispose]: mutex[Symbol.dispose],
778
+ };
779
+ };
780
+
781
+ /**
782
+ * Schedule a task to run after all interactions (animations, gestures,
783
+ * navigation) have completed.
784
+ *
785
+ * This uses `requestIdleCallback` when available, otherwise falls back to
786
+ * `setTimeout(0)` for cross-platform compatibility.
787
+ *
788
+ * ### Example
789
+ *
790
+ * ```ts
791
+ * const processDataTask: Task<void, ProcessError> = toTask(async () => {
792
+ * // Heavy processing work
793
+ * return ok();
794
+ * });
795
+ *
796
+ * // Schedule the task to run when idle
797
+ * void requestIdleTask(processDataTask)();
798
+ * ```
799
+ */
800
+ export const requestIdleTask = <T, E>(task: Task<T, E>): Task<T, E> =>
801
+ toTask(
802
+ async (context?: TaskContext) =>
803
+ new Promise<Result<T, E>>((resolve) => {
804
+ idleCallback(() => {
805
+ void task(context).then(resolve);
806
+ });
807
+ }),
808
+ );
809
+
810
+ const idleCallback: (callback: () => void) => void =
811
+ typeof globalThis.requestIdleCallback === "function"
812
+ ? globalThis.requestIdleCallback
813
+ : (callback) => setTimeout(callback, 0);
814
+
815
+ /**
816
+ * Represents a value that can be either synchronous or asynchronous.
817
+ *
818
+ * This type is useful for functions that may complete synchronously or
819
+ * asynchronously depending on runtime conditions (e.g., cache hit vs network
820
+ * fetch).
821
+ *
822
+ * ### Why MaybeAsync?
823
+ *
824
+ * When a function can be sync or async, the typical approaches are:
825
+ *
826
+ * 1. **Always return Promise** - Simple but forces microtask overhead even for
827
+ * sync values (see "await always adds microtask" test in Task.test.ts)
828
+ * 2. **Use callbacks** - Can avoid microtask, but calling code must still `await`
829
+ * for sane composition, which adds microtask anyway
830
+ * 3. **Return `T | PromiseLike<T>`** - Calling code can check the value and only
831
+ * `await` when needed, avoiding microtask overhead for sync cases
832
+ *
833
+ * The third approach (MaybeAsync) provides:
834
+ *
835
+ * - **Performance**: No microtask overhead for synchronous operations
836
+ * - **Reliability**: No interleaving via microtask queue when operations are
837
+ * _synchronous_, reducing need for mutexes to protect shared state
838
+ *
839
+ * ### Example
840
+ *
841
+ * ```ts
842
+ * // Function that may be sync or async
843
+ * const getData = (id: string): MaybeAsync<Data> => {
844
+ * const cached = cache.get(id);
845
+ * if (cached) return cached; // Sync path
846
+ * return fetchData(id); // Async path
847
+ * };
848
+ *
849
+ * // Caller can optimize based on actual behavior
850
+ * const result = getData(id);
851
+ * const data = isAsync(result) ? await result : result;
852
+ * ```
853
+ *
854
+ * ### Alternative Approaches
855
+ *
856
+ * It's possible to eliminate the sync/async distinction using complex
857
+ * frameworks with custom schedulers. However, such frameworks require depending
858
+ * on other people's code that controls how your code executes, resulting in
859
+ * more complex stack traces and debugging experiences. With MaybeAsync, we
860
+ * don't need that machinery - it works directly with JavaScript's native
861
+ * primitives and TypeScript's type system.
862
+ *
863
+ * ### TODO: Consider
864
+ *
865
+ * Use MaybeAsync in Task and Task helpers to preserve synchronous execution
866
+ * when possible (e.g., mutex with available permit, retry on first success).
867
+ */
868
+ export type MaybeAsync<T> = T | PromiseLike<T>;
869
+
870
+ /**
871
+ * Type guard to check if a {@link MaybeAsync} value is async (a promise).
872
+ *
873
+ * This function narrows the type of a {@link MaybeAsync} value, allowing you to
874
+ * conditionally `await` only when necessary.
875
+ *
876
+ * ### Example
877
+ *
878
+ * ```ts
879
+ * const getData = (id: string): MaybeAsync<Data> => {
880
+ * const cached = cache.get(id);
881
+ * if (cached) return cached; // Sync path
882
+ * return fetchData(id); // Async path
883
+ * };
884
+ *
885
+ * const result = getData(id);
886
+ * const data = isAsync(result) ? await result : result;
887
+ * // No microtask overhead when cached!
888
+ * ```
889
+ */
890
+ export const isAsync = <T>(
891
+ value: MaybeAsync<T>,
892
+ ): value is T extends PromiseLike<unknown> ? never : PromiseLike<T> =>
893
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
894
+ typeof (value as any)?.then === "function";
895
+
896
+ // TODO: Add tracing support
897
+ // - Extend TaskContext with optional tracing field
898
+ // - Add traced(name, task) helper that wraps Task execution
899
+ // - Collect span data (name, timing, parent-child relationships, status)
900
+ // - Support OpenTelemetry export format with proper traceId/spanId generation
901
+ // - Automatic parent-child span relationships through context propagation