@evolu/common 7.4.0 → 8.0.0-next.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 (251) hide show
  1. package/README.md +4 -61
  2. package/dist/src/Array.d.ts +343 -102
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +181 -85
  5. package/dist/src/Assert.d.ts +57 -11
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +47 -11
  8. package/dist/src/BigInt.d.ts +6 -1
  9. package/dist/src/BigInt.d.ts.map +1 -1
  10. package/dist/src/BigInt.js +5 -0
  11. package/dist/src/Brand.d.ts +40 -12
  12. package/dist/src/Brand.d.ts.map +1 -1
  13. package/dist/src/Brand.js +5 -0
  14. package/dist/src/Buffer.d.ts +6 -1
  15. package/dist/src/Buffer.d.ts.map +1 -1
  16. package/dist/src/Buffer.js +7 -2
  17. package/dist/src/Cache.d.ts +10 -2
  18. package/dist/src/Cache.d.ts.map +1 -1
  19. package/dist/src/Cache.js +6 -0
  20. package/dist/src/Callbacks.d.ts +10 -7
  21. package/dist/src/Callbacks.d.ts.map +1 -1
  22. package/dist/src/Callbacks.js +23 -13
  23. package/dist/src/Console.d.ts +361 -69
  24. package/dist/src/Console.d.ts.map +1 -1
  25. package/dist/src/Console.js +217 -117
  26. package/dist/src/Crypto.d.ts +70 -21
  27. package/dist/src/Crypto.d.ts.map +1 -1
  28. package/dist/src/Crypto.js +72 -27
  29. package/dist/src/Eq.d.ts +22 -2
  30. package/dist/src/Eq.d.ts.map +1 -1
  31. package/dist/src/Eq.js +21 -1
  32. package/dist/src/Error.d.ts +23 -12
  33. package/dist/src/Error.d.ts.map +1 -1
  34. package/dist/src/Error.js +27 -11
  35. package/dist/src/Function.d.ts +128 -56
  36. package/dist/src/Function.d.ts.map +1 -1
  37. package/dist/src/Function.js +118 -14
  38. package/dist/src/Identicon.d.ts +5 -0
  39. package/dist/src/Identicon.d.ts.map +1 -1
  40. package/dist/src/Identicon.js +6 -1
  41. package/dist/src/Lookup.d.ts +160 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +192 -0
  44. package/dist/src/Microtask.d.ts +21 -0
  45. package/dist/src/Microtask.d.ts.map +1 -0
  46. package/dist/src/Microtask.js +37 -0
  47. package/dist/src/Number.d.ts +18 -4
  48. package/dist/src/Number.d.ts.map +1 -1
  49. package/dist/src/Number.js +23 -1
  50. package/dist/src/Object.d.ts +110 -5
  51. package/dist/src/Object.d.ts.map +1 -1
  52. package/dist/src/Object.js +108 -5
  53. package/dist/src/Option.d.ts +58 -0
  54. package/dist/src/Option.d.ts.map +1 -0
  55. package/dist/src/Option.js +43 -0
  56. package/dist/src/Order.d.ts +5 -0
  57. package/dist/src/Order.d.ts.map +1 -1
  58. package/dist/src/Order.js +8 -3
  59. package/dist/src/Platform.d.ts +36 -2
  60. package/dist/src/Platform.d.ts.map +1 -1
  61. package/dist/src/Platform.js +10 -6
  62. package/dist/src/Polyfills.d.ts +27 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +299 -0
  65. package/dist/src/Random.d.ts +18 -7
  66. package/dist/src/Random.d.ts.map +1 -1
  67. package/dist/src/Random.js +9 -7
  68. package/dist/src/Redacted.d.ts +7 -4
  69. package/dist/src/Redacted.d.ts.map +1 -1
  70. package/dist/src/Redacted.js +5 -0
  71. package/dist/src/Ref.d.ts +31 -16
  72. package/dist/src/Ref.d.ts.map +1 -1
  73. package/dist/src/Ref.js +35 -7
  74. package/dist/src/RefCount.d.ts +62 -0
  75. package/dist/src/RefCount.d.ts.map +1 -0
  76. package/dist/src/RefCount.js +83 -0
  77. package/dist/src/Relation.d.ts +84 -29
  78. package/dist/src/Relation.d.ts.map +1 -1
  79. package/dist/src/Relation.js +83 -105
  80. package/dist/src/Resource.d.ts +263 -0
  81. package/dist/src/Resource.d.ts.map +1 -0
  82. package/dist/src/Resource.js +389 -0
  83. package/dist/src/Result.d.ts +390 -374
  84. package/dist/src/Result.d.ts.map +1 -1
  85. package/dist/src/Result.js +142 -70
  86. package/dist/src/Schedule.d.ts +953 -0
  87. package/dist/src/Schedule.d.ts.map +1 -0
  88. package/dist/src/Schedule.js +1199 -0
  89. package/dist/src/Set.d.ts +181 -0
  90. package/dist/src/Set.d.ts.map +1 -0
  91. package/dist/src/Set.js +137 -0
  92. package/dist/src/Skiplist.d.ts +6 -1
  93. package/dist/src/Skiplist.d.ts.map +1 -1
  94. package/dist/src/Skiplist.js +5 -0
  95. package/dist/src/Sqlite.d.ts +160 -60
  96. package/dist/src/Sqlite.d.ts.map +1 -1
  97. package/dist/src/Sqlite.js +290 -190
  98. package/dist/src/Store.d.ts +36 -28
  99. package/dist/src/Store.d.ts.map +1 -1
  100. package/dist/src/Store.js +55 -15
  101. package/dist/src/String.d.ts +5 -0
  102. package/dist/src/String.d.ts.map +1 -1
  103. package/dist/src/String.js +5 -0
  104. package/dist/src/Task.d.ts +2263 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1488 -348
  107. package/dist/src/Test.d.ts +122 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +66 -0
  110. package/dist/src/Time.d.ts +201 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +190 -86
  113. package/dist/src/Tracer.d.ts +48 -0
  114. package/dist/src/Tracer.d.ts.map +1 -0
  115. package/dist/src/Tracer.js +6 -0
  116. package/dist/src/Type.d.ts +582 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +362 -240
  119. package/dist/src/Types.d.ts +160 -6
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/Types.js +22 -2
  122. package/dist/src/WebSocket.d.ts +113 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +210 -118
  125. package/dist/src/Worker.d.ts +245 -60
  126. package/dist/src/Worker.d.ts.map +1 -1
  127. package/dist/src/Worker.js +220 -64
  128. package/dist/src/index.d.ts +25 -3
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +19 -3
  131. package/dist/src/local-first/Db.d.ts +24 -213
  132. package/dist/src/local-first/Db.d.ts.map +1 -1
  133. package/dist/src/local-first/Db.js +622 -293
  134. package/dist/src/local-first/Error.d.ts +12 -0
  135. package/dist/src/local-first/Error.d.ts.map +1 -0
  136. package/dist/src/local-first/Error.js +6 -0
  137. package/dist/src/local-first/Evolu.d.ts +330 -285
  138. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  139. package/dist/src/local-first/Evolu.js +472 -402
  140. package/dist/src/local-first/LocalAuth.d.ts +10 -10
  141. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  142. package/dist/src/local-first/LocalAuth.js +6 -1
  143. package/dist/src/local-first/Owner.d.ts +70 -27
  144. package/dist/src/local-first/Owner.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.js +27 -14
  146. package/dist/src/local-first/Protocol.d.ts +58 -50
  147. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  148. package/dist/src/local-first/Protocol.js +253 -155
  149. package/dist/src/local-first/Query.d.ts +133 -43
  150. package/dist/src/local-first/Query.d.ts.map +1 -1
  151. package/dist/src/local-first/Query.js +139 -90
  152. package/dist/src/local-first/Relay.d.ts +18 -38
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -171
  155. package/dist/src/local-first/Schema.d.ts +139 -144
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +78 -163
  158. package/dist/src/local-first/Shared.d.ts +125 -0
  159. package/dist/src/local-first/Shared.d.ts.map +1 -0
  160. package/dist/src/local-first/Shared.js +569 -0
  161. package/dist/src/local-first/Storage.d.ts +50 -53
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +121 -173
  164. package/dist/src/local-first/Timestamp.d.ts +28 -35
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +24 -25
  167. package/dist/src/local-first/index.d.ts +2 -15
  168. package/dist/src/local-first/index.d.ts.map +1 -1
  169. package/dist/src/local-first/index.js +2 -15
  170. package/package.json +36 -26
  171. package/src/Array.ts +467 -106
  172. package/src/Assert.ts +87 -12
  173. package/src/BigInt.ts +7 -1
  174. package/src/Brand.ts +41 -12
  175. package/src/Buffer.ts +10 -4
  176. package/src/Cache.ts +11 -2
  177. package/src/Callbacks.ts +33 -23
  178. package/src/Console.ts +567 -178
  179. package/src/Crypto.ts +111 -62
  180. package/src/Eq.ts +30 -3
  181. package/src/Error.ts +50 -34
  182. package/src/Function.ts +140 -68
  183. package/src/Identicon.ts +7 -1
  184. package/src/Lookup.ts +415 -0
  185. package/src/Microtask.ts +58 -0
  186. package/src/Number.ts +40 -7
  187. package/src/Object.ts +155 -16
  188. package/src/Option.ts +74 -0
  189. package/src/Order.ts +11 -3
  190. package/src/Platform.ts +43 -7
  191. package/src/Polyfills.ts +465 -0
  192. package/src/Random.ts +25 -11
  193. package/src/Redacted.ts +8 -4
  194. package/src/Ref.ts +72 -21
  195. package/src/RefCount.ts +170 -0
  196. package/src/Relation.ts +188 -136
  197. package/src/Resource.ts +864 -0
  198. package/src/Result.ts +548 -381
  199. package/src/Schedule.ts +1471 -0
  200. package/src/Set.ts +247 -0
  201. package/src/Skiplist.ts +7 -1
  202. package/src/Sqlite.ts +409 -304
  203. package/src/Store.ts +94 -48
  204. package/src/String.ts +6 -0
  205. package/src/Task.ts +3685 -740
  206. package/src/Test.ts +162 -0
  207. package/src/Time.ts +348 -145
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +1064 -630
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +311 -178
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +871 -597
  215. package/src/local-first/Error.ts +17 -0
  216. package/src/local-first/Evolu.ts +884 -823
  217. package/src/local-first/LocalAuth.ts +10 -10
  218. package/src/local-first/Owner.ts +94 -42
  219. package/src/local-first/Protocol.ts +440 -303
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +91 -252
  222. package/src/local-first/Schema.ts +284 -394
  223. package/src/local-first/Shared.ts +932 -0
  224. package/src/local-first/Storage.ts +209 -282
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/LICENSE +0 -21
  228. package/dist/src/Instances.d.ts +0 -34
  229. package/dist/src/Instances.d.ts.map +0 -1
  230. package/dist/src/Instances.js +0 -44
  231. package/dist/src/Resources.d.ts +0 -118
  232. package/dist/src/Resources.d.ts.map +0 -1
  233. package/dist/src/Resources.js +0 -197
  234. package/dist/src/local-first/Platform.d.ts +0 -25
  235. package/dist/src/local-first/Platform.d.ts.map +0 -1
  236. package/dist/src/local-first/Platform.js +0 -1
  237. package/dist/src/local-first/Public.d.ts +0 -22
  238. package/dist/src/local-first/Public.d.ts.map +0 -1
  239. package/dist/src/local-first/Public.js +0 -15
  240. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  241. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  242. package/dist/src/local-first/PublicKysely.js +0 -184
  243. package/dist/src/local-first/Sync.d.ts +0 -112
  244. package/dist/src/local-first/Sync.d.ts.map +0 -1
  245. package/dist/src/local-first/Sync.js +0 -529
  246. package/src/Instances.ts +0 -90
  247. package/src/Resources.ts +0 -367
  248. package/src/local-first/Platform.ts +0 -27
  249. package/src/local-first/Public.ts +0 -42
  250. package/src/local-first/PublicKysely.ts +0 -239
  251. package/src/local-first/Sync.ts +0 -960
package/src/Task.ts CHANGED
@@ -1,896 +1,3841 @@
1
- import { isNonEmptyArray, shiftArray } from "./Array.js";
2
- import { Result, err, ok } from "./Result.js";
3
- import { Duration, durationToNonNegativeInt } from "./Time.js";
4
- import { NonNegativeInt, PositiveInt } from "./Type.js";
5
-
6
1
  /**
7
- * `Task` is a function that creates and returns an optionally cancellable
8
- * Promise using {@link Result}.
2
+ * JavaScript-native structured concurrency.
9
3
  *
10
- * The laziness allows safe composition, e.g. retry logic, because it prevents
11
- * eager execution until the Task is actually invoked.
4
+ * @module
5
+ */
6
+
7
+ import {
8
+ arrayFrom,
9
+ emptyArray,
10
+ isNonEmptyArray,
11
+ mapArray,
12
+ type NonEmptyReadonlyArray,
13
+ } from "./Array.js";
14
+ import type { assertNotAborted } from "./Assert.js";
15
+ import { assert } from "./Assert.js";
16
+ import { type Console, type ConsoleDep, createConsole } from "./Console.js";
17
+ import type { RandomBytes, RandomBytesDep } from "./Crypto.js";
18
+ import { createRandomBytes } from "./Crypto.js";
19
+ import { eqArrayStrict } from "./Eq.js";
20
+ import { identity, lazyTrue, lazyVoid } from "./Function.js";
21
+ import { createLookupMap, type Lookup, type LookupOption } from "./Lookup.js";
22
+ import { decrement, increment } from "./Number.js";
23
+ import {
24
+ createRecord,
25
+ emptyRecord,
26
+ isFunction,
27
+ isIterable,
28
+ mapObject,
29
+ } from "./Object.js";
30
+ import type { Random, RandomDep, RandomNumber } from "./Random.js";
31
+ import { createRandom } from "./Random.js";
32
+ import { createRef, type Ref } from "./Ref.js";
33
+ import type { Done, NextResult, Ok, Result } from "./Result.js";
34
+ import { err, getOrThrow, ok, tryAsync } from "./Result.js";
35
+ import type { Schedule, ScheduleStep } from "./Schedule.js";
36
+ import { addToSet, deleteFromSet, emptySet } from "./Set.js";
37
+ import type { testCreateRun } from "./Test.js";
38
+ import type { Duration, Time, TimeDep } from "./Time.js";
39
+ import { createTime, durationToMillis, Millis } from "./Time.js";
40
+ import {
41
+ brand,
42
+ createId,
43
+ Id,
44
+ type InferType,
45
+ maxPositiveInt,
46
+ type Name,
47
+ NonNegativeInt,
48
+ object,
49
+ onePositiveInt,
50
+ PositiveInt,
51
+ type Typed,
52
+ typed,
53
+ union,
54
+ Unknown,
55
+ UnknownResult,
56
+ zeroNonNegativeInt,
57
+ } from "./Type.js";
58
+ import type { isPromiseLike } from "./Types.js";
59
+ import {
60
+ type Awaitable,
61
+ type Callback,
62
+ type CallbackWithTeardown,
63
+ type Int1To100,
64
+ type Mutable,
65
+ type NewKeys,
66
+ type Predicate,
67
+ } from "./Types.js";
68
+
69
+ /**
70
+ * JavaScript-native structured concurrency.
12
71
  *
13
- * ### Cancellation
72
+ * Structured concurrency is a simple idea: async operations form a tree where
73
+ * no child can outlive its parent — ending a parent aborts its children and
74
+ * waits for them to complete. This eliminates resource leaking and "fire and
75
+ * forget" bugs.
14
76
  *
15
- * Tasks support optional cancellation via signal in {@link TaskContext}. When a
16
- * Task is called without a signal, it cannot be cancelled and {@link AbortError}
17
- * will never be returned. When called with a signal, the Task can be cancelled
18
- * and AbortError is added to the error union with precise type safety.
77
+ * - **Automatic cancellation** abort propagates to all descendants
78
+ * - **Guaranteed cleanup** resources always cleaned up
79
+ * - **Observable state** inspect what’s running and why
19
80
  *
20
- * When composing Tasks, we typically have context and want to abort ASAP by
21
- * passing it through. However, there are valid cases where we don't want to
22
- * abort because we need some atomic unit to complete. For simple scripts and
23
- * tests, omitting context is fine.
81
+ * Evolu implements structured concurrency with these types:
24
82
  *
25
- * ### Task Helpers
83
+ * - **{@link Task}** — a function that takes Run and returns {@link Awaitable}
84
+ * (sync or async) {@link Result}
85
+ * - **{@link Run}** — a callable object that runs Tasks, manages their lifecycle,
86
+ * provides dependencies, and creates Fibers
87
+ * - **{@link Fiber}** — awaitable, abortable/disposable handle to a running Task
26
88
  *
27
- * - {@link toTask} - Convert async function to Task
28
- * - {@link wait} - Delay execution for a specified {@link Duration}
29
- * - {@link timeout} - Add timeout to any Task
30
- * - {@link retry} - Retry failed Tasks with configurable backoff
89
+ * Evolu's structured concurrency core is minimal — one function with
90
+ * closed-over state and a few attached properties and helper methods using
91
+ * native APIs.
31
92
  *
32
93
  * ### Example
33
94
  *
34
95
  * ```ts
35
- * interface FetchError {
36
- * readonly type: "FetchError";
96
+ * // A dependency — wraps native fetch for testability.
97
+ * interface NativeFetchDep {
98
+ * readonly fetch: typeof globalThis.fetch;
99
+ * }
100
+ *
101
+ * interface FetchError extends Typed<"FetchError"> {
37
102
  * readonly error: unknown;
38
103
  * }
39
104
  *
40
- * // Task version of fetch with proper error handling and cancellation support.
41
- * const fetch = (url: string) =>
42
- * toTask((context) =>
105
+ * // A Task wrapping native fetch adds abortability.
106
+ * const fetch =
107
+ * (url: string): Task<Response, FetchError, NativeFetchDep> =>
108
+ * ({ deps, signal }) =>
43
109
  * tryAsync(
44
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
45
- * (error): FetchError => ({ type: "FetchError", error }),
46
- * ),
47
- * );
110
+ * () => deps.fetch(url, { signal }),
111
+ * (error): FetchError | AbortError => {
112
+ * if (AbortError.is(error)) return error;
113
+ * return { type: "FetchError", error };
114
+ * },
115
+ * );
116
+ *
117
+ * // In a composition root…
118
+ * const deps: NativeFetchDep = {
119
+ * fetch: globalThis.fetch.bind(globalThis),
120
+ * };
121
+ *
122
+ * // Create a Run with dependencies.
123
+ * await using run = createRun(deps);
124
+ *
125
+ * // Running a Task returns a Fiber that can be awaited.
126
+ * const result = await run(fetch("/users/123"));
127
+ * expectTypeOf(result).toEqualTypeOf<
128
+ * Result<Response, FetchError | AbortError>
129
+ * >();
130
+ *
131
+ * // A Fiber can also be aborted (or disposed with `using`).
132
+ * const fiber = run(fetch("/users/456"));
133
+ * fiber.abort();
134
+ *
135
+ * // When this block ends, `await using` disposes the Run — aborting all Fibers.
136
+ * ```
137
+ *
138
+ * In composition roots, prefer Evolu platform `createRun` adapters when one
139
+ * exists. `@evolu/web`, `@evolu/nodejs`, and `@evolu/react-native` build on the
140
+ * common {@link createRun} and add platform-specific global error handling.
141
+ *
142
+ * ## Composition
143
+ *
144
+ * | Category | Helper | Description |
145
+ * | ---------- | ------------------ | ----------------------------------- |
146
+ * | Collection | {@link all} | fail-fast on first error |
147
+ * | | {@link allSettled} | complete all regardless of failures |
148
+ * | | {@link map} | values to Tasks, fail-fast |
149
+ * | | {@link mapSettled} | values to Tasks, complete all |
150
+ * | Timing | {@link sleep} | pause execution |
151
+ * | | {@link timeout} | time-bounded execution |
152
+ * | | {@link repeat} | repeat with schedule |
153
+ * | | {@link yieldNow} | yield to event loop |
154
+ * | Racing | {@link race} | first to complete wins |
155
+ * | | {@link any} | first success wins |
156
+ * | Resilience | {@link retry} | retry with backoff |
157
+ * | Interop | {@link callback} | wrap callback APIs |
158
+ * | | {@link fetch} | HTTP requests with abort handling |
159
+ *
160
+ * Collection helpers run sequentially by default. Use {@link concurrently} to
161
+ * run Tasks concurrently. Note helpers like {@link race} always run
162
+ * concurrently; sequential execution wouldn't make sense for their semantics.
163
+ *
164
+ * ## Style
165
+ *
166
+ * Imperative code is the preferred way to compose sequential {@link Task}
167
+ * operations inside another Task.
168
+ *
169
+ * ```ts
170
+ * const user = await run(fetchUser(id));
171
+ * if (!user.ok) return user;
172
+ *
173
+ * const profile = await run(fetchProfile(user.value.id));
174
+ * if (!profile.ok) return profile;
175
+ *
176
+ * return ok({ user: user.value, profile: profile.value });
177
+ * ```
48
178
  *
49
- * // `satisfies` shows the expected type signature.
50
- * fetch satisfies (url: string) => Task<Response, FetchError>;
179
+ * This is an intentional style choice. Evolu keeps helpers for operations with
180
+ * distinct semantics that plain control flow does not express well, such as
181
+ * concurrency, racing, retries, timeouts, and collection processing. It
182
+ * intentionally does not provide generic chain, flatMap, or pipe-style helpers
183
+ * for ordinary sequential Task composition, because that would duplicate plain
184
+ * control flow and create API ambiguity. While it may seem verbose, it is
185
+ * explicit, transparent, and avoids pipes and nested helpers, which are harder
186
+ * to debug.
187
+ *
188
+ * ### Building a better fetch
189
+ *
190
+ * Use {@link timeout} to prevent hanging:
191
+ *
192
+ * ```ts
193
+ * const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s");
51
194
  *
52
- * // Add timeout to prevent hanging
53
- * const fetchWithTimeout = (url: string) => timeout("30s", fetch(url));
195
+ * expectTypeOf(fetchWithTimeout).toEqualTypeOf<
196
+ * (
197
+ * url: string,
198
+ * ) => Task<Response, FetchError | TimeoutError, NativeFetchDep>
199
+ * >();
200
+ * ```
54
201
  *
55
- * fetchWithTimeout satisfies (
56
- * url: string,
57
- * ) => Task<Response, TimeoutError | FetchError>;
202
+ * Add {@link retry} for resilience:
58
203
  *
59
- * // Add retry for resilience
204
+ * ```ts
60
205
  * const fetchWithRetry = (url: string) =>
61
206
  * retry(
62
- * {
63
- * retries: PositiveInt.orThrow(3),
64
- * initialDelay: "100ms",
65
- * },
66
207
  * fetchWithTimeout(url),
208
+ * // A jittered, capped, limited exponential backoff.
209
+ * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
67
210
  * );
68
211
  *
69
- * fetchWithRetry satisfies (
70
- * url: string,
71
- * ) => Task<
72
- * Response,
73
- * TimeoutError | FetchError | RetryError<TimeoutError | FetchError>
74
- * >;
212
+ * expectTypeOf(fetchWithRetry).toEqualTypeOf<
213
+ * (
214
+ * url: string,
215
+ * ) => Task<
216
+ * Response,
217
+ * RetryError<FetchError | TimeoutError>,
218
+ * NativeFetchDep
219
+ * >
220
+ * >();
221
+ * ```
75
222
  *
76
- * const semaphore = createSemaphore(PositiveInt.orThrow(2));
223
+ * Run composed tasks with {@link concurrently} and {@link map}:
77
224
  *
78
- * // Control concurrency with semaphore
79
- * const fetchWithPermit = (url: string) =>
80
- * semaphore.withPermit(fetchWithRetry(url));
81
- *
82
- * fetchWithPermit satisfies (url: string) => Task<
83
- * Response,
84
- * | TimeoutError
85
- * | FetchError
86
- * | AbortError // Semaphore dispose aborts Tasks
87
- * | RetryError<TimeoutError | FetchError>
88
- * >;
89
- *
90
- * // Usage
91
- * const results = await Promise.all(
92
- * [
93
- * "https://api.example.com/users",
94
- * "https://api.example.com/posts",
95
- * "https://api.example.com/comments",
96
- * ]
97
- * .map(fetchWithPermit)
98
- * .map((task) => task()),
99
- * );
225
+ * ```ts
226
+ * await using run = createRun();
100
227
  *
101
- * results satisfies Array<
228
+ * const urls = [
229
+ * "https://api.example.com/users",
230
+ * "https://api.example.com/posts",
231
+ * "https://api.example.com/comments",
232
+ * ];
233
+ *
234
+ * // At most 2 concurrent requests
235
+ * const result = await run(concurrently(2, map(urls, fetchWithRetry)));
236
+ *
237
+ * expectTypeOf(result).toEqualTypeOf<
102
238
  * Result<
103
- * Response,
104
- * | AbortError
105
- * | TimeoutError
106
- * | FetchError
107
- * | RetryError<TimeoutError | FetchError>
239
+ * readonly Response[],
240
+ * AbortError | RetryError<TimeoutError | FetchError>
108
241
  * >
109
- * >;
242
+ * >();
243
+ * ```
110
244
  *
111
- * // Handle results
112
- * for (const result of results) {
113
- * if (result.ok) {
114
- * // Process successful response
115
- * const response = result.value;
116
- * expect(response).toBeInstanceOf(Response);
117
- * } else {
118
- * // Handle error (TimeoutError, FetchError, RetryError, or AbortError)
119
- * expect(result.error).toBeDefined();
120
- * }
121
- * }
245
+ * ## Dependency Injection
246
+ *
247
+ * Assumes familiarity with
248
+ * {@link https://www.evolu.dev/docs/dependency-injection | Evolu Pure DI}. Task
249
+ * DI is the same but without manually passing deps.
250
+ *
251
+ * Tasks declare dependencies via the `D` type parameter and access them via
252
+ * `run.deps`:
253
+ *
254
+ * ```ts
255
+ * const fetchUser =
256
+ * (id: UserId): Task<User, FetchUserError, FetchDep> =>
257
+ * async (run) => {
258
+ * const { fetch } = run.deps;
259
+ * // ...
260
+ * };
261
+ * ```
262
+ *
263
+ * Provide dependencies when creating a Run:
264
+ *
265
+ * ```ts
266
+ * const deps: FetchDep = {
267
+ * fetch: globalThis.fetch.bind(globalThis),
268
+ * };
269
+ *
270
+ * await using run = createRun(deps);
271
+ * await run(fetchUser(123));
272
+ * ```
273
+ *
274
+ * For runtime-created dependencies, use {@link Run#addDeps}.
275
+ *
276
+ * ### Built-in dependencies
277
+ *
278
+ * {@link createRun} provides default {@link RunDeps} available to all Tasks
279
+ * without declaring `D`:
280
+ *
281
+ * - {@link Console} — logging with hierarchical context via `child()`
282
+ * - {@link Time} — current time
283
+ * - {@link Random} — random number generation
284
+ * - {@link RandomBytes} — cryptographic random bytes
285
+ *
286
+ * For example, using `Console`:
287
+ *
288
+ * ```ts
289
+ * const myTask: Task<void> = async (run) => {
290
+ * const { console } = run.deps;
291
+ * console.log("started");
292
+ * // ...
293
+ * };
294
+ * ```
295
+ *
296
+ * Custom Console with formatted output:
297
+ *
298
+ * ```ts
299
+ * const deps = {
300
+ * console: createConsole({
301
+ * formatter: createConsoleFormatter()({
302
+ * timestampFormat: "absolute",
303
+ * }),
304
+ * }),
305
+ * };
122
306
  *
123
- * // Cancellation support
124
- * const controller = new AbortController();
125
- * const cancelableTask = fetchWithPermit("https://api.example.com/data");
307
+ * await using run = createRun(deps);
126
308
  *
127
- * // Start task
128
- * const promise = cancelableTask(controller);
309
+ * const console = run.deps.console.child("main");
129
310
  *
130
- * // Cancel after some time
131
- * setTimeout(() => {
132
- * controller.abort("User cancelled");
133
- * }, 1000);
311
+ * console.log("started");
312
+ * // 21:20:25.588 [main] started
313
+ * ```
314
+ *
315
+ * For testing, use {@link testCreateRun} to get deterministic, controllable
316
+ * implementations of all RunDeps.
317
+ *
318
+ * ## Resource management
319
+ *
320
+ * Evolu uses standard JavaScript
321
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Resource_management | resource management}.
322
+ *
323
+ * Use the
324
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncDisposableStack | AsyncDisposableStack}
325
+ * for async resource ownership.
326
+ *
327
+ * ### Example
328
+ *
329
+ * ```ts
330
+ * await using stack = new AsyncDisposableStack();
331
+ *
332
+ * const fooResult = await run(createFoo());
333
+ * if (!fooResult.ok) return fooResult;
334
+ * const foo = stack.use(fooResult.value);
335
+ *
336
+ * stack.defer(async () => {
337
+ * await foo.close();
338
+ * });
339
+ * stack.adopt(session, async (session) => {
340
+ * await session.logout();
341
+ * });
342
+ * ```
343
+ *
344
+ * ## Awaitable
345
+ *
346
+ * ```ts
347
+ * type Awaitable<T> = T | PromiseLike<T>;
348
+ * ```
349
+ *
350
+ * Even though {@link Task} returns {@link Awaitable} (allowing sync or async
351
+ * results), the {@link Run} itself is always async. This is a deliberate design
352
+ * choice:
353
+ *
354
+ * - **Sync** → {@link Result}, native `using` / `DisposableStack`
355
+ * - **Async** → {@link Task}, {@link Run}, {@link Fiber}, `await using` /
356
+ * `AsyncDisposableStack`
357
+ *
358
+ * Benefits:
359
+ *
360
+ * - **No API ambiguity** — Task means async, Result means sync
361
+ * - **Zero overhead** — sync code stays with zero overhead
362
+ *
363
+ * While a unified sync/async API is technically possible — with
364
+ * {@link isPromiseLike} detection and two-phase disposal (sync first, async if
365
+ * needed, and a flag for callers) — Evolu prefers plain functions for sync code
366
+ * because most operations involve I/O, which is inherently async, and when we
367
+ * need sync, it's for simplicity (ideally no dependencies) and performance
368
+ * (zero abstraction overhead).
369
+ *
370
+ * Sync functions should be fast, so there's no need to monitor them. They
371
+ * should take values, not dependencies — following the
372
+ * {@link https://blog.ploeh.dk/2017/02/02/dependency-rejection/ | impure/pure/impure sandwich}
373
+ * pattern where impure code gathers data, pure functions process it, and impure
374
+ * code performs effects with the result. Sync functions taking deps often
375
+ * indicate a design that could be improved — for example, a function taking
376
+ * {@link Random} could instead accept {@link RandomNumber} as a value.
377
+ *
378
+ * Slow sync operations (parsing large JSON, sorting millions of items, complex
379
+ * cryptography) belong in workers. The async boundary to the worker is a
380
+ * {@link Task} with full monitoring — timeout, cancellation, tracing. The sync
381
+ * code inside the worker needs no monitoring; the async call to the worker
382
+ * provides it.
383
+ *
384
+ * ## Glossary
385
+ *
386
+ * - **Cleanup** — generic umbrella term when the exact lifecycle operation is not
387
+ * important.
388
+ * - **Dispose / disposal** — owner-driven cleanup via JavaScript resource
389
+ * management (`Symbol.dispose`, `Symbol.asyncDispose`, `using`,
390
+ * `AsyncDisposableStack`).
391
+ * - **Create** — construct a new value or a resource.
392
+ * - **Acquire** — obtain a usable resource. Acquisition may create a new
393
+ * resource, borrow one, open one, or take a lease/lock.
394
+ * - **Release** — relinquish a previously acquired resource or lease. Release
395
+ * pairs with acquire and need not mean disposal; examples include unlock,
396
+ * logout, or returning a pooled resource.
397
+ *
398
+ * ## FAQ
399
+ *
400
+ * ### Why can `Task<T, never, D>` still return `AbortError`?
401
+ *
402
+ * The `E` type parameter represents domain errors, not abort control flow.
403
+ *
404
+ * `AbortError` comes from the {@link Run} runtime. A Task can still return it:
405
+ *
406
+ * - Before execution, when the parent or root {@link Run} is already stopped
407
+ * - During execution, when an abortable Task is aborted
408
+ * - At settlement, when abort was requested before the Task result was observed
134
409
  *
135
- * const _result = await promise;
136
- * // Result will be AbortError if cancelled
410
+ * So `Task<T, never, D>` means "no domain errors", not "cannot fail at all".
411
+ *
412
+ * This also applies to {@link unabortable}. `unabortable(task)` only prevents
413
+ * abort from interrupting the Task after it has started running. It cannot
414
+ * force execution to start on a parent or root {@link Run} that is already
415
+ * disposing or settled, so it can still return {@link AbortError} before
416
+ * execution begins. If that abort would indicate a lifecycle bug in your code,
417
+ * use {@link assertNotAborted} to crash immediately instead of threading the
418
+ * impossible case through domain logic.
419
+ *
420
+ * ### How do I type an anonymous Task callback?
421
+ *
422
+ * For one-off inline Tasks, put the type arguments on the {@link Run} call:
423
+ *
424
+ * ```ts
425
+ * run<void, never>(async (run) => {
426
+ * const result = await run(waitForLeader);
427
+ * if (!result.ok) return result;
428
+ *
429
+ * doSomething(result.value);
430
+ * return ok();
431
+ * });
137
432
  * ```
138
433
  *
139
- * ### Dependency Injection Integration
434
+ * This constrains the callback to the same shape as `Task<void, never, D>`
435
+ * without extracting a named constant.
436
+ *
437
+ * This pattern is useful for child Tasks started at the call site, especially
438
+ * when success has no meaningful value and domain errors are not expected. In
439
+ * that case, `void, never` documents an abort-only Task whose result can be
440
+ * ignored while the parent {@link Run} still owns its lifecycle.
441
+ *
442
+ * If the Task is reused, exported, or deserves a name, prefer an explicit
443
+ * `const task: Task<T, E, D> = async (run) => { ... }` instead.
444
+ *
445
+ * ### Where is fork and join?
446
+ *
447
+ * For those familiar with other structured concurrency implementations:
448
+ *
449
+ * - **Fork** — `run(task)` creates a {@link Fiber}
450
+ * - **Join** — `await fiber` waits for completion
451
+ *
452
+ * @group Core Types
453
+ */
454
+ export type Task<T, E = never, D = unknown> = (
455
+ run: Run<D>,
456
+ ) => Awaitable<Result<T, E | AbortError>>;
457
+
458
+ /**
459
+ * Shorthand for a {@link Task} with `any` type parameters.
460
+ *
461
+ * @group Type utilities
462
+ */
463
+ export type AnyTask = Task<any, any, any>;
464
+
465
+ /**
466
+ * Extracts the value type from a {@link Task}.
467
+ *
468
+ * @group Type utilities
469
+ */
470
+ export type InferTaskOk<R extends AnyTask> =
471
+ R extends Task<infer T, any, any> ? T : never;
472
+
473
+ /**
474
+ * Extracts the error type from a {@link Task}.
475
+ *
476
+ * @group Type utilities
477
+ */
478
+ export type InferTaskErr<R extends AnyTask> =
479
+ R extends Task<any, infer E, any> ? E : never;
480
+
481
+ /**
482
+ * Extracts the deps type from a {@link Task}.
483
+ *
484
+ * @group Type utilities
485
+ */
486
+ export type InferTaskDeps<R extends AnyTask> =
487
+ R extends Task<any, any, infer D> ? D : never;
488
+
489
+ /**
490
+ * A {@link Task} that can complete with a value, signal done, or fail.
491
+ *
492
+ * Forms a pair with {@link NextResult}:
493
+ *
494
+ * - `Result<A, E>` → `NextResult<A, E, D>`
495
+ * - `Task<T, E>` → `NextTask<T, E, D>`
496
+ *
497
+ * Use for pull-based protocols like iterators where `Done<D>` signals normal
498
+ * completion rather than an error.
499
+ *
500
+ * @group Core Types
501
+ */
502
+ export type NextTask<T, E = never, D = void> = Task<T, E | Done<D>>;
503
+
504
+ /**
505
+ * Extracts the done value type from a {@link NextTask}.
506
+ *
507
+ * @group Type utilities
508
+ */
509
+ export type InferTaskDone<T extends AnyTask> =
510
+ InferTaskErr<T> extends infer Errors
511
+ ? Errors extends Done<infer D>
512
+ ? D
513
+ : never
514
+ : never;
515
+
516
+ /**
517
+ * Error returned when a {@link Task} is aborted via
518
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal}.
519
+ *
520
+ * This is structured-concurrency control flow, not a domain error. It plays a
521
+ * role similar to an interrupt: in most code, propagate it unchanged or ignore
522
+ * it when cleanup is already handled by the runtime.
523
+ *
524
+ * The `reason` field is `unknown` by design — use typed errors for business
525
+ * logic. If you need to inspect the reason, use type guards like
526
+ * `RaceLostError.is(reason)`.
527
+ *
528
+ * When a piece of logic must continue once started, wrap it with
529
+ * {@link unabortable}. That prevents ordinary mid-flight interruption, but it
530
+ * does not erase {@link AbortError} from the type because the Task can still be
531
+ * rejected before it starts, for example on an already-stopped {@link Run}. In
532
+ * those cases, abort usually indicates a lifecycle bug, so use
533
+ * `assertNotAborted` if you want to fail fast.
534
+ *
535
+ * @group Core Types
536
+ */
537
+ export const AbortError = /*#__PURE__*/ typed("AbortError", {
538
+ reason: Unknown,
539
+ });
540
+ export interface AbortError extends InferType<typeof AbortError> {}
541
+
542
+ /**
543
+ * Runs a {@link Task} with
544
+ * {@link https://en.wikipedia.org/wiki/Structured_concurrency | structured concurrency}
545
+ * semantics.
546
+ *
547
+ * Each `Run` forms a Task tree: child Tasks are bound to it, abort propagates
548
+ * through that tree, and state is observable via snapshots and events.
549
+ *
550
+ * `Run` is a callable object — callable because it's convenient to run Tasks as
551
+ * `run(task)`, and an object because it holds state.
552
+ *
553
+ * Calling `run(task)` creates a child `Run`, passes it to the Task, and returns
554
+ * a {@link Fiber}. The child is tracked in `getChildren()`/events while running,
555
+ * then disposed and removed when settled.
556
+ *
557
+ * Before Task execution, `run(task)` applies two short-circuit checks:
558
+ *
559
+ * - If this Run is not `Running`, the child is aborted with
560
+ * {@link runStoppedError} and the Task is replaced with `err(AbortError)`.
561
+ * - If this Run's signal is already aborted and the child is abortable
562
+ * (`abortMask === 0`), the child is aborted with the same reason and the Task
563
+ * is replaced with `err(AbortError)`.
140
564
  *
141
- * Tasks integrate naturally with Evolu's DI pattern. Use `deps` for static
142
- * dependencies and `TaskContext` for execution context like cancellation. Usage
143
- * follows the pattern: deps arguments execution context.
565
+ * After execution, the child stores both values: `outcome` (what the Task
566
+ * returned) and `result` (what callers observe). If the child signal is aborted
567
+ * at settlement time, `result` is forced to `err(AbortError)` even when
568
+ * `outcome` is `ok(...)`.
569
+ *
570
+ * That's the whole mechanism: {@link Task} is a function that takes a `Run` and
571
+ * returns an {@link Awaitable} {@link Result}. `run(task)` runs the Task via
572
+ * `Promise.try(task, run)` with aforementioned logic.
573
+ *
574
+ * @group Core Types
575
+ * @see {@link createRun}
576
+ * @see {@link Task}
144
577
  */
145
- export interface Task<T, E> {
578
+ export interface Run<D = unknown> extends AsyncDisposable {
579
+ /** Runs a {@link Task} and returns a {@link Fiber} handle. */
580
+ <T, E>(task: Task<T, E, D>): Fiber<T, E, D>;
581
+
582
+ /**
583
+ * Runs a {@link Task} and throws if the returned {@link Result} is an error.
584
+ *
585
+ * Use this where failure should crash the current flow instead of being
586
+ * handled locally.
587
+ *
588
+ * This is the async equivalent of {@link getOrThrow}. It runs the Task, awaits
589
+ * its {@link Result}, and returns the value on success.
590
+ *
591
+ * **When to use:**
592
+ *
593
+ * - Application startup or composition-root setup where errors must stop the
594
+ * program immediately. In Evolu apps, errors are handled by
595
+ * platform-specific `createRun` adapters at the app boundary.
596
+ * - Module-level constants
597
+ * - Test setup with values that are expected to be valid
598
+ *
599
+ * Prefer `await run(task)` with an explicit `if (!result.ok)` check in
600
+ * ordinary application logic where the caller can recover, retry, or choose a
601
+ * different flow.
602
+ *
603
+ * Throws: `Error` with the original Task error attached as `cause`.
604
+ */
605
+ readonly orThrow: <T, E>(task: Task<T, E, D>) => Promise<T>;
606
+
607
+ /** Unique {@link Id} for this Run. */
608
+ readonly id: Id;
609
+
610
+ /** The parent {@link Run}, if this Run was created as a child. */
611
+ readonly parent: Run<D> | null;
612
+
613
+ /** @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */
614
+ readonly signal: AbortSignal;
615
+
616
+ /** The abort mask depth. `0` means abortable, `>= 1` means unabortable. */
617
+ readonly abortMask: AbortMask;
618
+
619
+ /**
620
+ * Registers a callback to run when abort is requested.
621
+ *
622
+ * This is a convenience wrapper around subscribing to this Run's abort
623
+ * signal. The callback receives the abort reason extracted from
624
+ * {@link AbortError.reason} rather than the whole {@link AbortError}.
625
+ *
626
+ * If already aborted, the callback is invoked immediately. For
627
+ * {@link unabortable} Tasks, the callback is never invoked because their
628
+ * signal never aborts.
629
+ *
630
+ * Intentionally synchronous and not awaited. The callback runs in the abort
631
+ * request path, which may already be transitioning this Run to `Disposing` or
632
+ * `Settled`, so it is too late to start normal Tasks from there.
633
+ *
634
+ * Use for immediate abort-time reactions such as removing listeners, clearing
635
+ * timers, removing waiters from queues, or resolving pending promises. Do not
636
+ * use it for awaited cleanup or resource ownership. For that, use standard
637
+ * JavaScript resource management with `AsyncDisposableStack`.
638
+ */
639
+ readonly onAbort: (callback: Callback<unknown>) => void;
640
+
641
+ /** Returns the current {@link RunState}. */
642
+ readonly getState: () => RunState;
643
+
644
+ /** Returns the current child {@link Fiber}s. */
645
+ readonly getChildren: () => ReadonlySet<Fiber<any, any, D>>;
646
+
146
647
  /**
147
- * Invoke the Task.
648
+ * Creates a memoized {@link RunSnapshot} of this Run.
148
649
  *
149
- * Provide a context with an AbortSignal to enable cancellation. When called
150
- * without a signal, {@link AbortError} cannot occur and the error type narrows
151
- * accordingly.
650
+ * Use for monitoring, debugging, or building UI that visualizes Task trees.
152
651
  *
153
652
  * ### Example
154
653
  *
155
654
  * ```ts
156
- * interface FetchError {
157
- * readonly type: "FetchError";
158
- * readonly error: unknown;
159
- * }
160
- *
161
- * // Task version of fetch with proper error handling and cancellation support.
162
- * const fetch = (url: string) =>
163
- * toTask((context) =>
164
- * tryAsync(
165
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
166
- * (error): FetchError => ({ type: "FetchError", error }),
167
- * ),
655
+ * // React integration with useSyncExternalStore
656
+ * const useRunSnapshot = (run: Run) =>
657
+ * useSyncExternalStore(
658
+ * (callback) => {
659
+ * run.onEvent = callback;
660
+ * return () => {
661
+ * run.onEvent = undefined;
662
+ * };
663
+ * },
664
+ * () => run.snapshot(),
168
665
  * );
666
+ * ```
667
+ */
668
+ readonly snapshot: () => RunSnapshot;
669
+
670
+ /**
671
+ * Callback for monitoring Run events.
672
+ *
673
+ * Called when this Run or any descendant emits a {@link RunEvent}. Events
674
+ * bubble up through parent runs, enabling centralized monitoring. Only
675
+ * emitted when {@link RunConfig.eventsEnabled} is `true`.
676
+ */
677
+ onEvent: ((event: RunEvent) => void) | undefined;
678
+
679
+ /**
680
+ * The root {@link Run} of this Task tree.
681
+ *
682
+ * It is called `daemon` because that is how it should be used: for
683
+ * long-running work that must not be disposed when the current Task settles.
684
+ * Normal child Runs are disposed by their parent when they settle. The root
685
+ * Run has no parent, so work started with `run.daemon(task)` is attached to
686
+ * that root Run instead of the current Run and keeps running until the root
687
+ * Run is disposed manually.
688
+ *
689
+ * In application code, that usually means disposing the root Run on process
690
+ * shutdown in Node.js or when another platform-specific lifecycle hook is
691
+ * available. Browsers do not provide a fully reliable app termination hook.
692
+ *
693
+ * ### Example
694
+ *
695
+ * ```ts
696
+ * const myTask: Task<void, never> = async (run) => {
697
+ * // Aborted when myTask ends
698
+ * run(helperTask);
169
699
  *
170
- * // `satisfies` shows the expected type signature.
171
- * fetch satisfies (url: string) => Task<Response, FetchError>;
700
+ * // Outlives myTask, aborted when the root Run is disposed
701
+ * const backgroundFiber = run.daemon(backgroundSync);
172
702
  *
173
- * const result1 = await fetch("https://api.example.com/data")();
174
- * expectTypeOf(result1).toEqualTypeOf<Result<Response, FetchError>>();
703
+ * // Can still be aborted manually if needed
704
+ * backgroundFiber.abort();
175
705
  *
176
- * // With AbortController
177
- * const controller = new AbortController();
178
- * const result2 = await fetch("https://api.example.com/data")(
179
- * controller,
180
- * );
181
- * expectTypeOf(result2).toEqualTypeOf<
182
- * Result<Response, FetchError | AbortError>
183
- * >();
706
+ * return ok();
707
+ * };
184
708
  * ```
709
+ *
710
+ * For a long-lived reusable {@link Run}, use {@link Run.create}.
185
711
  */
186
- // eslint-disable-next-line @typescript-eslint/prefer-function-type
187
- <TContext extends TaskContext | undefined = undefined>(
188
- context?: TContext,
189
- ): Promise<
190
- Result<T, TContext extends { signal: AbortSignal } ? E | AbortError : E>
191
- >;
192
- }
712
+ readonly daemon: Run<D>;
193
713
 
194
- /** Context passed to {@link Task}s for cancellation. */
195
- export interface TaskContext {
196
- /** Signal for cancellation */
197
- readonly signal?: AbortSignal;
198
- }
714
+ /**
715
+ * Creates a {@link Run} from this Run.
716
+ *
717
+ * Like {@link createRun}, the returned Run is daemon: it stays running until
718
+ * disposed. Unlike {@link createRun}, it shares the same Deps as this Run.
719
+ *
720
+ * Use this for long-lived disposable resources that need to own async work.
721
+ * The resource creates one internal Run with `run.create()` and uses that Run
722
+ * for all of its work. Disposing the resource then disposes that internal
723
+ * Run, which aborts in-flight child Tasks, waits for them to settle, and
724
+ * rejects later calls through it.
725
+ *
726
+ * Typical examples are database clients, connection pools, workers, or other
727
+ * reusable resources with async methods and an async dispose operation.
728
+ *
729
+ * To run a single Task as daemon, use {@link Run.daemon}.
730
+ */
731
+ readonly create: () => Run<D>;
199
732
 
200
- /** Error returned when a {@link Task} is cancelled via AbortSignal. */
201
- export interface AbortError {
202
- readonly type: "AbortError";
203
- readonly reason?: unknown;
204
- }
733
+ /** Returns the dependencies passed to {@link createRun}. */
734
+ readonly deps: RunDeps & D;
205
735
 
206
- /** Narrower check to detect AbortError objects at runtime. */
207
- const isAbortError = (error: unknown): error is AbortError =>
208
- typeof error === "object" &&
209
- error !== null &&
210
- (error as { type?: unknown }).type === "AbortError";
736
+ /**
737
+ * @see {@link Concurrency}
738
+ * @see {@link concurrently}
739
+ */
740
+ readonly concurrency: Concurrency;
211
741
 
212
- // For React Native
213
- if (typeof AbortSignal.any !== "function") {
214
- AbortSignal.any = function (signals: Array<AbortSignal>): AbortSignal {
215
- const controller = new AbortController();
742
+ /**
743
+ * Adds additional dependencies to this Run and returns it.
744
+ *
745
+ * Use for runtime-created dependencies — dependencies that cannot be created
746
+ * in the composition root (e.g., app start).
747
+ *
748
+ * ### Example
749
+ *
750
+ * ```ts
751
+ * // One-shot
752
+ * await run.addDeps({ db })(getUser(123));
753
+ *
754
+ * // Multiple deps at once
755
+ * await run.addDeps({ db, cache })(task);
756
+ *
757
+ * // Reusable — config comes from outside (message, file, etc.)
758
+ * type DbWorkerDeps = DbDep; // or DbDep & CacheDep & ...
759
+ *
760
+ * const init =
761
+ * (config: Config): Task<void, InitError, CreateDbDep> =>
762
+ * async (run) => {
763
+ * const { createDb } = run.deps;
764
+ * await using stack = new AsyncDisposableStack();
765
+ *
766
+ * const db = stack.use(await run.orThrow(startApp()));
767
+ * if (!db.ok) return db;
768
+ *
769
+ * const runWithDb = run.addDeps({ db: db.value });
770
+ *
771
+ * await runWithDb(getUser(123));
772
+ * await runWithDb(insertUser(user));
773
+ * return ok();
774
+ * };
775
+ * ```
776
+ *
777
+ * ## FAQ
778
+ *
779
+ * ### How does it work?
780
+ *
781
+ * This is the whole implementation:
782
+ *
783
+ * ```ts
784
+ * run.addDeps = <E extends NewKeys<E, D>>(newDeps: E): Run<D & E> => {
785
+ * depsRef.modify((currentDeps) => {
786
+ * const duplicate = Object.keys(newDeps).find(
787
+ * (k) => k in currentDeps,
788
+ * );
789
+ * assert(!duplicate, `Dependency '${duplicate}' already added.`);
790
+ * return [undefined, { ...currentDeps, ...newDeps }];
791
+ * });
792
+ * return self as unknown as Run<D & E>;
793
+ * };
794
+ * ```
795
+ *
796
+ * Dependencies are stored in a shared {@link Ref}, so `addDeps` propagates to
797
+ * all runs. The runtime assertion ensures dependencies are created once —
798
+ * automatic deduplication would mask poor design (dependencies should have a
799
+ * single, clear point of creation).
800
+ */
801
+ readonly addDeps: <E extends NewKeys<E, D>>(extraDeps: E) => Run<D & E>;
802
+ }
216
803
 
217
- const onAbort = (event: Event) => {
218
- controller.abort((event.target as AbortSignal).reason);
219
- cleanup();
220
- };
804
+ /**
805
+ * `Fiber` is a handle to a running {@link Task} that can be awaited, aborted, or
806
+ * disposed.
807
+ *
808
+ * ### Example
809
+ *
810
+ * ```ts
811
+ * await using run = createRun();
812
+ *
813
+ * // Await to get Result
814
+ * const result = await run(fetchData);
815
+ *
816
+ * // Abort manually
817
+ * const fiber = run(longRunningTask);
818
+ * fiber.abort();
819
+ * const aborted = await fiber; // Result contains AbortError (unless unabortable)
820
+ *
821
+ * // Auto-abort with `using`
822
+ * {
823
+ * using background = run(backgroundSync);
824
+ * await someOtherWork();
825
+ * } // background.abort() called automatically here
826
+ *
827
+ * // Run child tasks in fiber's scope
828
+ * fiber.run(childTask);
829
+ *
830
+ * // Monitor via the Run
831
+ * fiber.run.onEvent = (event) => {
832
+ * // handle event
833
+ * };
834
+ * ```
835
+ *
836
+ * Because `Fiber` is a {@link PromiseLike} object, Fibers can be composed with
837
+ * `Promise.all`, `Promise.race`, etc.
838
+ *
839
+ * Microtask timing: Run wraps the Task's promise with `.then` and `.finally`,
840
+ * which adds microtasks between Task completion and Fiber settlement. Do not
841
+ * write code that relies on a specific number of microtask yields between
842
+ * Tasks. Use explicit synchronization primitives instead.
843
+ *
844
+ * @group Core Types
845
+ */
846
+ export interface Fiber<T = unknown, E = unknown, D = unknown>
847
+ extends PromiseLike<Result<T, E | AbortError>>, Disposable {
848
+ readonly then: PromiseLike<Result<T, E | AbortError>>["then"];
221
849
 
222
- const cleanup = () => {
223
- for (const s of signals) s.removeEventListener("abort", onAbort);
224
- };
850
+ /**
851
+ * A {@link Run} of this Fiber.
852
+ *
853
+ * Tasks run via this Run are aborted when the Fiber ends.
854
+ *
855
+ * ### Example
856
+ *
857
+ * ```ts
858
+ * const fiber = run(longRunningTask);
859
+ *
860
+ * // helperTask is aborted when longRunningTask ends
861
+ * fiber.run(helperTask);
862
+ *
863
+ * // Monitor this Fiber's Run
864
+ * fiber.run.onEvent = (event) => {
865
+ * console.log(event);
866
+ * };
867
+ * ```
868
+ */
869
+ readonly run: Run<D>;
225
870
 
226
- for (const s of signals) {
227
- if (s.aborted) {
228
- controller.abort(s.reason);
229
- return controller.signal;
230
- }
231
- s.addEventListener("abort", onAbort);
232
- }
871
+ /**
872
+ * Requests abort for this Fiber (and any child it started).
873
+ *
874
+ * ### Example
875
+ *
876
+ * ```ts
877
+ * const fiber = run(fetchData);
878
+ * fiber.abort();
879
+ * const result = await fiber; // err(AbortError)
880
+ * ```
881
+ *
882
+ * When abort is requested, the Fiber's result becomes {@link AbortError} even
883
+ * if the Task completed successfully. This keeps behavior predictable —
884
+ * calling `abort()` always yields `AbortError`.
885
+ *
886
+ * The optional reason is stored in `AbortError.reason`. Since any value can
887
+ * be passed, abort reasons are `unknown` — use typed errors for business
888
+ * logic. To inspect the reason, use type guards like
889
+ * `RaceLostError.is(reason)`.
890
+ *
891
+ * Abort is idempotent — calling multiple times has no additional effect
892
+ * beyond the first call.
893
+ */
894
+ abort(reason?: unknown): void;
895
+
896
+ /** Returns the current {@link RunState} of this Fiber's {@link Run}. */
897
+ getState(): RunState<T, E>;
898
+ }
899
+
900
+ /**
901
+ * Extracts the value type from a {@link Fiber}.
902
+ *
903
+ * @group Type utilities
904
+ */
905
+ export type InferFiberOk<F extends Fiber<any, any, any>> =
906
+ F extends Fiber<infer T, any, any> ? T : never;
907
+
908
+ /**
909
+ * Extracts the error type from a {@link Fiber}.
910
+ *
911
+ * @group Type utilities
912
+ */
913
+ export type InferFiberErr<F extends Fiber<any, any, any>> =
914
+ F extends Fiber<any, infer E, any> ? E : never;
915
+
916
+ /**
917
+ * Extracts the deps type from a {@link Fiber}.
918
+ *
919
+ * @group Type utilities
920
+ */
921
+ export type InferFiberDeps<F extends Fiber<any, any, any>> =
922
+ F extends Fiber<any, any, infer D> ? D : never;
923
+
924
+ /**
925
+ * Abort mask depth for a {@link Run} or {@link Fiber}.
926
+ *
927
+ * - `0` — abortable (default)
928
+ * - `>= 1` — inside {@link unabortable}, abort requests are ignored
929
+ *
930
+ * The mask tracks nested unabortable regions. When abort is requested, the
931
+ * signal only propagates if `mask === 0`.
932
+ *
933
+ * - {@link unabortable} increments the mask — Task becomes protected
934
+ * - {@link unabortableMask} provides `restore` to restore the previous mask
935
+ * - Tasks inherit their parent's mask by default
936
+ *
937
+ * This enables nested resource lifecycle patterns where each level can have its
938
+ * own abortable section while outer acquisitions remain protected.
939
+ *
940
+ * UI/debugging tools can use this to visually distinguish protected Tasks
941
+ * (e.g., different icon or color) and explain why abort requests are ignored.
942
+ *
943
+ * @group Abort masking
944
+ */
945
+ export const AbortMask = /*#__PURE__*/ brand("AbortMask", NonNegativeInt);
946
+ export type AbortMask = typeof AbortMask.Type;
947
+
948
+ /**
949
+ * Maximum number of concurrent Tasks.
950
+ *
951
+ * Default is 1 (sequential). Use 1-100 as a literal or {@link PositiveInt} for
952
+ * larger values.
953
+ *
954
+ * @group Concurrency primitives
955
+ * @see {@link concurrently}
956
+ * @see {@link createSemaphore}
957
+ */
958
+ export type Concurrency = Int1To100 | PositiveInt;
959
+
960
+ /**
961
+ * The lifecycle state of a {@link Run}.
962
+ *
963
+ * - `Running` — Task running, no result yet
964
+ * - `Disposing` — abort requested, waiting for children to settle
965
+ * - `Settled` — settled with result and outcome
966
+ *
967
+ * @group Core Types
968
+ */
969
+ export type RunState<T = unknown, E = unknown> =
970
+ | RunStateRunning
971
+ | RunStateDisposing
972
+ | RunStateSettled<T, E>;
973
+
974
+ export interface RunStateRunning extends Typed<"Running"> {}
975
+
976
+ export interface RunStateDisposing extends Typed<"Disposing"> {}
977
+
978
+ export interface RunStateSettled<
979
+ T = unknown,
980
+ E = unknown,
981
+ > extends Typed<"Settled"> {
982
+ /**
983
+ * The Run's completion value.
984
+ *
985
+ * If abort was requested, this is {@link AbortError} even if the Task
986
+ * completed successfully — see `outcome` for what the Task actually
987
+ * returned.
988
+ */
989
+ readonly result: Result<T, E>;
990
+
991
+ /**
992
+ * What the Task actually returned.
993
+ *
994
+ * Unlike `result`, not overridden by abort.
995
+ */
996
+ readonly outcome: Result<T, E>;
997
+ }
998
+
999
+ /**
1000
+ * {@link RunSnapshot} state Type.
1001
+ *
1002
+ * @group Monitoring
1003
+ */
1004
+ export const RunSnapshotState = /*#__PURE__*/ union(
1005
+ /*#__PURE__*/ typed("Running"),
1006
+ /*#__PURE__*/ typed("Disposing"),
1007
+ /*#__PURE__*/ typed("Settled", {
1008
+ result: UnknownResult,
1009
+ outcome: UnknownResult,
1010
+ }),
1011
+ );
1012
+ export type RunSnapshotState = typeof RunSnapshotState.Type;
1013
+
1014
+ /**
1015
+ * A recursive snapshot of a {@link Run} tree.
1016
+ *
1017
+ * Snapshots use structural sharing — unchanged subtrees return the same object
1018
+ * reference. This is useful for UI libraries like React that leverage
1019
+ * referential transparency to skip re-rendering unchanged parts. Snapshots are
1020
+ * computed on demand rather than pushed on every change. Push would require
1021
+ * O(depth) new snapshot objects per mutation.
1022
+ *
1023
+ * @group Core Types
1024
+ * @see {@link Run.snapshot}
1025
+ */
1026
+ export interface RunSnapshot {
1027
+ /** The {@link Run.id} this snapshot represents. */
1028
+ readonly id: Id;
1029
+
1030
+ /** The current lifecycle state. */
1031
+ readonly state: RunSnapshotState;
1032
+
1033
+ /** Child snapshots in run order. */
1034
+ readonly children: ReadonlyArray<RunSnapshot>;
1035
+
1036
+ /** The abort mask depth. `0` means abortable, `>= 1` means unabortable. */
1037
+ readonly abortMask: AbortMask;
1038
+ }
1039
+
1040
+ /**
1041
+ * The event-specific payload of a {@link RunEvent}.
1042
+ *
1043
+ * @group Monitoring
1044
+ */
1045
+ export const RunEventData = /*#__PURE__*/ union(
1046
+ /*#__PURE__*/ typed("ChildAdded", { childId: Id }),
1047
+ /*#__PURE__*/ typed("ChildRemoved", { childId: Id }),
1048
+ /*#__PURE__*/ typed("StateChanged", { state: RunSnapshotState }),
1049
+ );
1050
+ export type RunEventData = typeof RunEventData.Type;
1051
+
1052
+ /**
1053
+ * Events emitted by a {@link Run} for monitoring and debugging.
1054
+ *
1055
+ * Events bubble up through parent runs, enabling centralized monitoring at the
1056
+ * root. Use with {@link Run.onEvent} to track Run lifecycle.
1057
+ *
1058
+ * @group Monitoring
1059
+ */
1060
+ export const RunEvent = /*#__PURE__*/ object({
1061
+ id: Id,
1062
+ timestamp: Millis,
1063
+ data: RunEventData,
1064
+ });
1065
+ export interface RunEvent extends InferType<typeof RunEvent> {}
1066
+
1067
+ /**
1068
+ * Configuration for {@link Run} behavior.
1069
+ *
1070
+ * @group Monitoring
1071
+ */
1072
+ export interface RunConfig {
1073
+ /**
1074
+ * Whether to emit {@link RunEvent}s.
1075
+ *
1076
+ * Use a {@link Ref} to enable/disable at runtime without recreating the Run.
1077
+ * Disabled by default for zero overhead in production.
1078
+ */
1079
+ readonly eventsEnabled: Ref<boolean>;
1080
+ }
1081
+
1082
+ export interface RunConfigDep {
1083
+ readonly runConfig: RunConfig;
1084
+ }
1085
+
1086
+ /** Default deps provided by {@link createRun}. */
1087
+ export type RunDeps = ConsoleDep &
1088
+ RandomBytesDep &
1089
+ RandomDep &
1090
+ TimeDep &
1091
+ Partial<RunConfigDep>;
1092
+
1093
+ // TODO:
1094
+ // Partial<TracerConfigDep> & // TODO:
1095
+ // Partial<TracerDep>; // TODO:
1096
+
1097
+ const runDeps: RunDeps = {
1098
+ console: createConsole(),
1099
+ randomBytes: createRandomBytes(),
1100
+ random: createRandom(),
1101
+ time: createTime(),
1102
+ };
1103
+
1104
+ /**
1105
+ * Factory type for creating root {@link Run} instances.
1106
+ *
1107
+ * @group Creating Run
1108
+ */
1109
+ export interface CreateRun<BaseDeps> {
1110
+ (): Run<BaseDeps>;
1111
+ <D>(deps: D): Run<BaseDeps & D>;
1112
+ }
1113
+
1114
+ /**
1115
+ * Creates root {@link Run}.
1116
+ *
1117
+ * The root Run is also the daemon Run: it stays running until disposed. Child
1118
+ * Runs created by `run(task)` are disposed by their parent once they settle.
1119
+ *
1120
+ * Call once per entry point (main thread, worker, etc.) and dispose on
1121
+ * shutdown. All Tasks run as descendants of this root Run.
1122
+ *
1123
+ * This common {@link createRun} is platform-agnostic. At application entry
1124
+ * points, prefer the platform adapter when one exists. `@evolu/web` adds
1125
+ * browser `error` and `unhandledrejection` handlers, `@evolu/nodejs` adds
1126
+ * Node.js `uncaughtException`, `unhandledRejection`, and graceful shutdown
1127
+ * handling, and `@evolu/react-native` adds React Native global error handling.
1128
+ *
1129
+ * {@link RunDeps} provides default dependencies:
1130
+ *
1131
+ * - {@link Time}
1132
+ * - {@link Console}
1133
+ * - {@link Random}
1134
+ * - {@link RandomBytes}
1135
+ *
1136
+ * ### Example
1137
+ *
1138
+ * ```ts
1139
+ * // App entry point
1140
+ * await using run = createRun();
1141
+ *
1142
+ * const result = await run(fetchData);
1143
+ * ```
1144
+ *
1145
+ * ### Example with custom dependencies
1146
+ *
1147
+ * ```ts
1148
+ * // Define dependency interfaces
1149
+ * interface Config {
1150
+ * readonly apiUrl: string;
1151
+ * }
1152
+ *
1153
+ * interface ConfigDep {
1154
+ * readonly config: Config;
1155
+ * }
1156
+ *
1157
+ * // Task declares its dependencies via the D type parameter
1158
+ * const fetchUser =
1159
+ * (id: string): Task<User, FetchError, ConfigDep> =>
1160
+ * async (run) => {
1161
+ * const { config } = run.deps;
1162
+ * const response = await fetch(`${config.apiUrl}/users/${id}`);
1163
+ * // ...
1164
+ * };
1165
+ *
1166
+ * // Composition root: create a Run with custom deps
1167
+ * type AppDeps = RunDeps & ConfigDep;
1168
+ *
1169
+ * const appDeps: AppDeps = {
1170
+ * ...testCreateDeps(), // or spread individual deps
1171
+ * config: { apiUrl: "https://api.example.com" },
1172
+ * };
1173
+ *
1174
+ * await using run = createRun(appDeps);
1175
+ *
1176
+ * // Run type is inferred from the deps argument
1177
+ * const result = await run(fetchUser("123"));
1178
+ *
1179
+ * // TypeScript catches missing deps at compile time:
1180
+ * // await using run2 = createRun(); // Run<RunDeps>
1181
+ * // run2(fetchUser("123")); // Error: Property 'config' is missing
1182
+ * ```
1183
+ *
1184
+ * @group Creating Run
1185
+ */
1186
+ export const createRun: CreateRun<RunDeps> = <D>(
1187
+ deps?: D,
1188
+ ): Run<RunDeps & D> => {
1189
+ const mergedDeps = { ...runDeps, ...deps } as RunDeps & D;
1190
+ return createRunInternal(createRef(mergedDeps))();
1191
+ };
1192
+
1193
+ /** Internal Run properties, hidden from public API via TypeScript types. */
1194
+ interface RunInternal<D extends RunDeps = RunDeps> extends Run<D> {
1195
+ ownTaskSettled: PromiseWithResolvers<void> | null;
1196
+
1197
+ readonly requestAbort: (reason: unknown) => void;
1198
+ readonly requestSignal: AbortSignal;
1199
+
1200
+ /**
1201
+ * Stores the fulfilled task outcome and derives the externally visible
1202
+ * result.
1203
+ */
1204
+ readonly handleTaskFulfilled: <T, E>(
1205
+ outcome: Result<T, E | AbortError>,
1206
+ ) => Result<T, E | AbortError>;
1207
+
1208
+ /** Resolves this Run's own-task settled promise after the task settles. */
1209
+ readonly handleTaskSettled: () => void;
1210
+ }
1211
+
1212
+ const createRunInternal =
1213
+ <D extends RunDeps>(depsRef: Ref<D>) =>
1214
+ (
1215
+ parent?: RunInternal<D>,
1216
+ daemon?: RunInternal<D>,
1217
+ abortBehavior?: AbortBehavior,
1218
+ concurrencyBehavior?: Concurrency,
1219
+ ): RunInternal<D> => {
1220
+ const parentMask = parent?.abortMask ?? isAbortable;
1221
+
1222
+ let abortMask: AbortMask;
1223
+ switch (abortBehavior) {
1224
+ case undefined:
1225
+ abortMask = parentMask;
1226
+ break;
1227
+ case "unabortable":
1228
+ abortMask = increment(parentMask) as AbortMask;
1229
+ break;
1230
+ default:
1231
+ assert(
1232
+ abortBehavior <= parentMask,
1233
+ "restore used outside its unabortableMask",
1234
+ );
1235
+ abortMask = abortBehavior;
1236
+ }
1237
+
1238
+ const requestController = new AbortController();
1239
+ const signalController = new AbortController();
1240
+
1241
+ let state: RunState = running;
1242
+ let result: UnknownResult | undefined;
1243
+ let outcome: UnknownResult | undefined;
1244
+ let children: ReadonlySet<Fiber<any, any, D>> = emptySet;
1245
+
1246
+ const requestAbort = (reason: unknown) => {
1247
+ const abortError = reason as AbortError;
1248
+ if (abortMask === isAbortable) signalController.abort(abortError);
1249
+ requestController.abort(abortError);
1250
+ };
1251
+
1252
+ if (parent) {
1253
+ subscribeToAbort(
1254
+ parent.requestSignal,
1255
+ () => requestAbort(parent.requestSignal.reason),
1256
+ { signal: requestController.signal },
1257
+ );
1258
+ }
1259
+
1260
+ const emitEvent = (data: RunEventData) => {
1261
+ const deps = depsRef.get();
1262
+ if (!deps.runConfig?.eventsEnabled.get()) return;
1263
+ const e: RunEvent = { id: self.id, timestamp: deps.time.now(), data };
1264
+ for (let node: Run<D> | null = self; node; node = node.parent) {
1265
+ node.onEvent?.(e);
1266
+ }
1267
+ };
1268
+
1269
+ const run = <T, E>(task: Task<T, E, D>): Fiber<T, E, D> => {
1270
+ const childRun = createRunInternal(depsRef)(
1271
+ self,
1272
+ daemon ?? self,
1273
+ getAbortBehavior(task),
1274
+ getConcurrencyBehavior(task),
1275
+ );
1276
+
1277
+ if (state !== running) {
1278
+ childRun.requestAbort(runStoppedAbortError);
1279
+ task = () => err(runStoppedAbortError);
1280
+ } else if (
1281
+ signalController.signal.aborted &&
1282
+ childRun.abortMask === isAbortable
1283
+ ) {
1284
+ childRun.requestAbort(signalController.signal.reason);
1285
+ task = () => err(signalController.signal.reason);
1286
+ }
1287
+
1288
+ const childFiber: Fiber<T, E, D> = Object.assign(
1289
+ Promise.try(task, childRun)
1290
+ .then(childRun.handleTaskFulfilled)
1291
+ .finally(childRun.handleTaskSettled)
1292
+ .finally(childRun[Symbol.asyncDispose])
1293
+ .finally(() => {
1294
+ children = deleteFromSet(children, childFiber);
1295
+ emitEvent({ type: "ChildRemoved", childId: childRun.id });
1296
+ }),
1297
+ {
1298
+ run: childRun,
1299
+ abort: (reason?: unknown): void => {
1300
+ childRun.requestAbort(createAbortError(reason));
1301
+ },
1302
+ getState: () => childRun.getState() as RunState<T, E>,
1303
+ [Symbol.dispose]: () => {
1304
+ childFiber.abort();
1305
+ },
1306
+ },
1307
+ );
1308
+
1309
+ children = addToSet(children, childFiber);
1310
+ emitEvent({ type: "ChildAdded", childId: childRun.id });
1311
+
1312
+ return childFiber;
1313
+ };
1314
+
1315
+ const self = run as RunInternal<D>;
1316
+
1317
+ {
1318
+ const run = self as Mutable<RunInternal<D>>;
1319
+ const id = createId(depsRef.get());
1320
+
1321
+ let snapshot: RunSnapshot | null = null;
1322
+ let disposingPromise: Promise<void> | null = null;
1323
+
1324
+ run.orThrow = async (task) => getOrThrow(await self(task));
1325
+ run.id = id;
1326
+ run.parent = parent ?? null;
1327
+
1328
+ run.signal = signalController.signal;
1329
+ run.abortMask = abortMask;
1330
+ run.onAbort = (callback) => {
1331
+ if (abortMask !== isAbortable) return;
1332
+ subscribeToAbort(
1333
+ signalController.signal,
1334
+ () => callback((signalController.signal.reason as AbortError).reason),
1335
+ { once: true, signal: requestController.signal },
1336
+ );
1337
+ };
1338
+ run.getState = () => state;
1339
+ run.getChildren = () => children;
1340
+
1341
+ run.snapshot = () => {
1342
+ const childSnapshots = Array.from(children).map((fiber) =>
1343
+ fiber.run.snapshot(),
1344
+ );
1345
+ if (
1346
+ snapshot?.state !== state ||
1347
+ !eqArrayStrict(snapshot.children, childSnapshots)
1348
+ ) {
1349
+ snapshot = {
1350
+ id,
1351
+ state: state as RunSnapshotState,
1352
+ children: childSnapshots,
1353
+ abortMask,
1354
+ };
1355
+ }
1356
+ return snapshot;
1357
+ };
1358
+
1359
+ run.daemon = daemon ?? self;
1360
+
1361
+ run.create = () => run.daemon(createDeferred().task).run;
1362
+
1363
+ Object.defineProperty(run, "deps", { get: depsRef.get });
1364
+
1365
+ run.concurrency =
1366
+ concurrencyBehavior ?? parent?.concurrency ?? defaultConcurrency;
1367
+
1368
+ run.addDeps = <E extends NewKeys<E, D>>(newDeps: E): Run<D & E> => {
1369
+ depsRef.modify((currentDeps) => {
1370
+ const duplicate = Object.keys(newDeps).find((k) => k in currentDeps);
1371
+ assert(
1372
+ !duplicate,
1373
+ `Dependency '${duplicate}' already added. ` +
1374
+ `This assert ensures dependencies are created once. ` +
1375
+ `Automatic deduplication would mask bugs.`,
1376
+ );
1377
+ return [undefined, { ...currentDeps, ...newDeps }];
1378
+ });
1379
+ return self as unknown as Run<D & E>;
1380
+ };
1381
+
1382
+ run[Symbol.asyncDispose] = () => {
1383
+ if (disposingPromise) return disposingPromise;
1384
+
1385
+ state = { type: "Disposing" };
1386
+ emitEvent({ type: "StateChanged", state });
1387
+ requestAbort(runStoppedAbortError);
1388
+
1389
+ disposingPromise = Promise.allSettled(
1390
+ (run.ownTaskSettled
1391
+ ? [run.ownTaskSettled.promise, ...children]
1392
+ : children) as Iterable<PromiseLike<unknown>>,
1393
+ )
1394
+ .then(lazyVoid)
1395
+ .finally(() => {
1396
+ /**
1397
+ * Root and daemon Runs have no own Task, so
1398
+ * `run.handleTaskFulfilled` never populates their terminal values.
1399
+ * In that case disposal publishes `ok()` for both `result` and
1400
+ * `outcome`. Task-backed Runs normally reach this point with both
1401
+ * values already set.
1402
+ */
1403
+ [result, outcome] = [result ?? ok(), outcome ?? ok()];
1404
+ state = { type: "Settled", result, outcome };
1405
+ emitEvent({ type: "StateChanged", state });
1406
+ });
1407
+
1408
+ return disposingPromise;
1409
+ };
1410
+
1411
+ // Internal
1412
+ run.ownTaskSettled = parent ? Promise.withResolvers<void>() : null;
1413
+
1414
+ run.requestAbort = requestAbort;
1415
+ run.requestSignal = requestController.signal;
1416
+
1417
+ run.handleTaskFulfilled = (taskOutcome) => {
1418
+ const taskResult = run.signal.aborted
1419
+ ? (err(run.signal.reason as AbortError) as typeof taskOutcome)
1420
+ : taskOutcome;
1421
+ result = taskResult;
1422
+ outcome = taskOutcome;
1423
+ return taskResult;
1424
+ };
1425
+
1426
+ run.handleTaskSettled = () => {
1427
+ run.ownTaskSettled?.resolve();
1428
+ };
1429
+ }
1430
+
1431
+ return self;
1432
+ };
1433
+
1434
+ const running: RunState = { type: "Running" };
1435
+
1436
+ /**
1437
+ * Abort reason indicating a {@link Run} can no longer start new Tasks.
1438
+ *
1439
+ * Covers both disposing and settled Runs.
1440
+ *
1441
+ * @group Creating Run
1442
+ */
1443
+ export const RunStoppedError = /*#__PURE__*/ typed("RunStoppedError");
1444
+ export interface RunStoppedError extends InferType<typeof RunStoppedError> {}
1445
+
1446
+ /**
1447
+ * Shared {@link RunStoppedError} instance used as the default
1448
+ * {@link AbortError.reason} when a Task is started on a non-running {@link Run}.
1449
+ *
1450
+ * @group Creating Run
1451
+ */
1452
+ export const runStoppedError: RunStoppedError = {
1453
+ type: "RunStoppedError",
1454
+ };
1455
+
1456
+ const createAbortError = (reason: unknown): AbortError => ({
1457
+ type: "AbortError",
1458
+ reason,
1459
+ });
1460
+
1461
+ const subscribeToAbort = (
1462
+ signal: AbortSignal,
1463
+ handler: () => void,
1464
+ options: AddEventListenerOptions,
1465
+ ): void => {
1466
+ if (signal.aborted) handler();
1467
+ else signal.addEventListener("abort", handler, options);
1468
+ };
1469
+
1470
+ const runStoppedAbortError: AbortError = createAbortError(runStoppedError);
1471
+
1472
+ const isAbortable = AbortMask.orThrow(0);
1473
+ type AbortBehavior = "unabortable" | AbortMask;
1474
+ const abortBehaviorSymbol = Symbol("evolu.Task.abortBehavior");
1475
+
1476
+ const getAbortBehavior = (task: AnyTask): AbortBehavior | undefined =>
1477
+ (task as never)[abortBehaviorSymbol];
1478
+
1479
+ const abortBehavior =
1480
+ (behavior: AbortBehavior) =>
1481
+ <T, E, D>(task: Task<T, E, D>): Task<T, E, D> =>
1482
+ Object.assign((run: Run<D>) => run(task), {
1483
+ [abortBehaviorSymbol]: behavior,
1484
+ });
1485
+
1486
+ /**
1487
+ * Makes a {@link Task} unabortable.
1488
+ *
1489
+ * Once started, an unabortable Task always completes — abort requests are
1490
+ * ignored and `signal.aborted` remains `false`.
1491
+ *
1492
+ * If the parent {@link Run} is already disposing or settled, `run(task)`
1493
+ * short-circuits before task execution and returns `err(AbortError)` with
1494
+ * {@link runStoppedError} as reason. So `unabortable` means “do not interrupt
1495
+ * this Task once it has started”, not “remove AbortError from its type”.
1496
+ *
1497
+ * When that pre-start abort would be a programmer error, assert it explicitly
1498
+ * with `assertNotAborted` after awaiting the result.
1499
+ *
1500
+ * ### Example
1501
+ *
1502
+ * ```ts
1503
+ * await using run = createRun();
1504
+ *
1505
+ * const events: Array<string> = [];
1506
+ * const canComplete = Promise.withResolvers<void>();
1507
+ * let signalAbortedInAnalytics = true;
1508
+ *
1509
+ * // Simulate async analytics API (abortable by default)
1510
+ * const sendToAnalytics =
1511
+ * (event: number): Task<void, never> =>
1512
+ * async ({ signal }) => {
1513
+ * await canComplete.promise;
1514
+ * signalAbortedInAnalytics = signal.aborted;
1515
+ * events.push(`sent ${event}`);
1516
+ * return ok();
1517
+ * };
1518
+ *
1519
+ * // Important events must be sent even if the user navigates away
1520
+ * const trackImportantEvent = (event: number) =>
1521
+ * unabortable(sendToAnalytics(event));
1522
+ *
1523
+ * // User clicks, we start tracking (Task runs until first await)
1524
+ * const fiber = run(trackImportantEvent(123));
1525
+ *
1526
+ * // User navigates away (abort requested while task is running)
1527
+ * fiber.abort();
1528
+ * canComplete.resolve();
1529
+ *
1530
+ * const result = await fiber;
1531
+ *
1532
+ * expect(signalAbortedInAnalytics).toBe(false);
1533
+ * // Analytics was sent despite abort
1534
+ * expect(events).toEqual(["sent 123"]);
1535
+ * expect(result).toEqual(ok());
1536
+ * ```
1537
+ *
1538
+ * @group Abort masking
1539
+ */
1540
+ // TODO: Clear AbortError from unabortable task results.
1541
+ export const unabortable = /*#__PURE__*/ abortBehavior("unabortable");
1542
+
1543
+ /**
1544
+ * Like {@link unabortable}, but provides `restore` to restore abortability for
1545
+ * specific tasks.
1546
+ *
1547
+ * Tasks inherit abort masking from their parent. This means:
1548
+ *
1549
+ * - Tasks run inside `unabortableMask` are unabortable by default
1550
+ * - Tasks wrapped with `restore()` restore the previous abortability
1551
+ *
1552
+ * @group Abort masking
1553
+ */
1554
+ export const unabortableMask = <T, E, D = unknown>(
1555
+ fn: (
1556
+ restore: <T2, E2>(task: Task<T2, E2, D>) => Task<T2, E2, D>,
1557
+ ) => Task<T, E, D>,
1558
+ ): Task<T, E, D> =>
1559
+ unabortable((run) =>
1560
+ fn(abortBehavior(AbortMask.orThrow(decrement(run.abortMask))))(run),
1561
+ );
1562
+
1563
+ const defaultConcurrency: Concurrency = 1;
1564
+
1565
+ const concurrencyBehaviorSymbol = Symbol("evolu.Task.concurrencyBehavior");
1566
+
1567
+ const getConcurrencyBehavior = (task: AnyTask): Concurrency | undefined =>
1568
+ (task as never)[concurrencyBehaviorSymbol];
1569
+
1570
+ /**
1571
+ * Runs tasks concurrently instead of sequentially.
1572
+ *
1573
+ * Sets the {@link Concurrency} level for a {@link Task}, which helpers like
1574
+ * {@link all}, {@link map}, etc. use to control how many tasks run at once.
1575
+ *
1576
+ * By default, tasks run sequentially (one at a time) to encourage thinking
1577
+ * about concurrency explicitly.
1578
+ *
1579
+ * For tuple-based calls like `all([taskA, taskB, taskC])` with a known small
1580
+ * number of tasks, omit the limit (runs unlimited). For arrays of unknown
1581
+ * length, always specify a limit.
1582
+ *
1583
+ * Concurrency is inherited by child tasks and can be overridden at any level.
1584
+ * Composition helpers should respect inherited concurrency — they should not
1585
+ * override it with a fixed number unless semantically required (like
1586
+ * {@link race}). Helpers with a recommended concurrency should export it for use
1587
+ * with `concurrently`.
1588
+ *
1589
+ * ### Example
1590
+ *
1591
+ * ```ts
1592
+ * // Unlimited (omit the limit)
1593
+ * run(concurrently(all([fetchA, fetchB, fetchC])));
1594
+ *
1595
+ * // Limited — at most 5 tasks run at a time
1596
+ * run(concurrently(5, all(tasks)));
1597
+ * run(concurrently(5, map(userIds, fetchUser)));
1598
+ *
1599
+ * // Inherited — inner all() uses parent's limit
1600
+ * const pipeline = concurrently(5, async (run) => {
1601
+ * const users = await run(map(userIds, fetchUser)); // uses 5
1602
+ * if (!users.ok) return users;
1603
+ * return run(map(users.value, enrichUser)); // also uses 5
1604
+ * });
1605
+ * ```
1606
+ *
1607
+ * @group Composition
1608
+ */
1609
+ export function concurrently<T, E, D = unknown>(
1610
+ concurrency: Concurrency,
1611
+ task: Task<T, E, D>,
1612
+ ): Task<T, E, D>;
1613
+ /** Unlimited. */
1614
+ export function concurrently<T, E, D = unknown>(
1615
+ task: Task<T, E, D>,
1616
+ ): Task<T, E, D>;
1617
+ export function concurrently<T, E, D = unknown>(
1618
+ concurrencyOrTask: Concurrency | Task<T, E, D>,
1619
+ taskOrFallback?: Task<T, E, D>,
1620
+ ): Task<T, E, D> {
1621
+ const isTask = isFunction(concurrencyOrTask);
1622
+ const task = isTask ? concurrencyOrTask : taskOrFallback!;
1623
+ return Object.assign((run: Run<D>) => run(task), {
1624
+ [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask,
1625
+ });
1626
+ }
1627
+
1628
+ /**
1629
+ * Yields execution to allow other work to proceed.
1630
+ *
1631
+ * Long-running JavaScript blocks the main thread. In browsers, this makes the
1632
+ * UI unresponsive (user interactions, animations). In Node.js, it prevents I/O
1633
+ * callbacks, timers, and other requests from being handled. Inserting yield
1634
+ * points lets the runtime process high-priority work between chunks of code.
1635
+ *
1636
+ * Uses `scheduler.yield()` in browsers for optimal main thread scheduling,
1637
+ * falls back to `setImmediate` in Node.js, or `setTimeout` elsewhere.
1638
+ *
1639
+ * ### Example
1640
+ *
1641
+ * ```ts
1642
+ * const processLargeArray: Task<void, never> = async (run) => {
1643
+ * const { time } = run.deps;
1644
+ * let lastYield = time.now();
1645
+ *
1646
+ * for (const item of largeArray) {
1647
+ * processItem(item);
1648
+ *
1649
+ * // Yield periodically to keep UI responsive
1650
+ * if (time.now() - lastYield > msLongTask) {
1651
+ * const r = await run(yieldNow);
1652
+ * if (!r.ok) return r;
1653
+ * lastYield = time.now();
1654
+ * }
1655
+ * }
1656
+ *
1657
+ * return ok();
1658
+ * };
1659
+ * ```
1660
+ *
1661
+ * Recursive tasks also benefit from periodic yields — without them, deep
1662
+ * recursion overflows the call stack:
1663
+ *
1664
+ * ```ts
1665
+ * const processRecursive =
1666
+ * (count: number, index: number, sum: number): Task<number> =>
1667
+ * async (run) => {
1668
+ * if (index >= count) return ok(sum);
1669
+ *
1670
+ * // Yield periodically to break synchronous call chains.
1671
+ * if (index > 0 && index % 1000 === 0) {
1672
+ * const y = await run(yieldNow);
1673
+ * if (!y.ok) return y;
1674
+ * }
1675
+ *
1676
+ * // Direct tail-call: no fiber overhead, stack-safe thanks to yieldNow.
1677
+ * return await processRecursive(count, index + 1, sum + index)(run);
1678
+ * };
1679
+ * ```
1680
+ *
1681
+ * @group Composition
1682
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
1683
+ * @see https://web.dev/articles/optimize-long-tasks
1684
+ */
1685
+ export const yieldNow: Task<void> = () =>
1686
+ tryAsync(
1687
+ () => yieldImpl(), // TODO: yieldImpl(run.signal)
1688
+ (reason): AbortError => createAbortError(reason),
1689
+ );
1690
+
1691
+ const scheduler = (
1692
+ globalThis as unknown as {
1693
+ readonly scheduler?: { readonly yield?: unknown };
1694
+ }
1695
+ ).scheduler;
1696
+
1697
+ const yieldImpl: () => Promise<void> =
1698
+ typeof scheduler?.yield === "function"
1699
+ ? () => (scheduler.yield as () => Promise<void>)()
1700
+ : typeof setImmediate !== "undefined"
1701
+ ? () => new Promise<void>((resolve) => setImmediate(resolve))
1702
+ : () => new Promise<void>((r) => setTimeout(r, 0)); // Safari
1703
+
1704
+ /**
1705
+ * Creates a {@link Task} from a callback-based API.
1706
+ *
1707
+ * Use this to wrap callback-style APIs (event listeners, Node.js callbacks,
1708
+ * etc.) into Tasks with proper abort handling.
1709
+ *
1710
+ * Optionally return a teardown function that runs on abort.
1711
+ *
1712
+ * ### Example
1713
+ *
1714
+ * ```ts
1715
+ * // The sleep helper is implemented using callback:
1716
+ * const sleep = (duration: Duration): Task<void> =>
1717
+ * callback(({ ok, deps: { time } }) => {
1718
+ * const id = time.setTimeout(ok, durationToMillis(duration));
1719
+ * return () => time.clearTimeout(id);
1720
+ * });
1721
+ *
1722
+ * // Wrap an event listener — use signal directly
1723
+ * const waitForClick = (element: HTMLElement): Task<MouseEvent> =>
1724
+ * callback(({ ok, signal }) => {
1725
+ * element.addEventListener("click", ok, { once: true, signal });
1726
+ * });
1727
+ *
1728
+ * // Wrap Node.js callback API
1729
+ * const readFile = (path: string): Task<string, NodeJS.ErrnoException> =>
1730
+ * callback(({ ok, err }) => {
1731
+ * fs.readFile(path, "utf8", (error, data) => {
1732
+ * if (error) err(error);
1733
+ * else ok(data);
1734
+ * });
1735
+ * });
1736
+ * ```
1737
+ *
1738
+ * @group Composition
1739
+ */
1740
+ export const callback =
1741
+ <T, E = never>(
1742
+ callback: CallbackWithTeardown<{
1743
+ readonly ok: Callback<T>;
1744
+ readonly err: Callback<E>;
1745
+ readonly signal: AbortSignal;
1746
+ readonly deps: RunDeps;
1747
+ }>,
1748
+ ): Task<T, E> =>
1749
+ (run) =>
1750
+ new Promise((resolve) => {
1751
+ const teardown = callback({
1752
+ ok: (value) => resolve(ok(value)),
1753
+ err: (error) => resolve(err(error)),
1754
+ signal: run.signal,
1755
+ deps: run.deps,
1756
+ });
1757
+
1758
+ run.onAbort((reason) => {
1759
+ if (teardown) teardown();
1760
+ resolve(err(createAbortError(reason)));
1761
+ });
1762
+ });
1763
+
1764
+ /**
1765
+ * Pauses execution for a specified duration.
1766
+ *
1767
+ * ### Example
1768
+ *
1769
+ * ```ts
1770
+ * const task: Task<void> = async (run) => {
1771
+ * console.log("Starting...");
1772
+ * await run(sleep("1s"));
1773
+ * console.log("Done after 1 second");
1774
+ * return ok();
1775
+ * };
1776
+ * ```
1777
+ *
1778
+ * @group Composition
1779
+ */
1780
+ export const sleep = (duration: Duration): Task<void> =>
1781
+ callback(({ ok, deps: { time } }) => {
1782
+ const id = time.setTimeout(ok, durationToMillis(duration));
1783
+ return () => time.clearTimeout(id);
1784
+ });
1785
+
1786
+ /**
1787
+ * Returns a {@link Task} that completes first.
1788
+ *
1789
+ * Like
1790
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race},
1791
+ * the first Task to complete (whether success or failure) wins. All other Tasks
1792
+ * are aborted. Use {@link any} if you need the first Task to succeed instead.
1793
+ *
1794
+ * Requires a non-empty array — racing zero Tasks has no meaningful result
1795
+ * (there's no "first to complete" without participants). This is enforced at
1796
+ * compile time for non-empty tuple types. For other arrays, guard with
1797
+ * {@link isNonEmptyArray}:
1798
+ *
1799
+ * ```ts
1800
+ * if (isNonEmptyArray(tasks)) {
1801
+ * await run(race(tasks));
1802
+ * }
1803
+ * ```
1804
+ *
1805
+ * ### Example
1806
+ *
1807
+ * ```ts
1808
+ * const fast: Task<string> = () => ok("fast");
1809
+ * const slow: Task<string> = async (run) => {
1810
+ * await run(sleep("10ms"));
1811
+ * return ok("slow");
1812
+ * };
1813
+ *
1814
+ * // First wins, others are aborted.
1815
+ * const result = await run(race([fast, slow])); // ok("fast")
1816
+ * ```
1817
+ *
1818
+ * Always runs with unlimited concurrency — a sequential race makes no sense
1819
+ * since the first Task would always "win".
1820
+ *
1821
+ * @group Composition
1822
+ */
1823
+ export const race = <T extends readonly [AnyTask, ...ReadonlyArray<AnyTask>]>(
1824
+ tasks: T,
1825
+ {
1826
+ abortReason = raceLostError,
1827
+ }: {
1828
+ /** Abort reason for losing tasks. Defaults to {@link raceLostError}. */
1829
+ abortReason?: unknown;
1830
+ } = {},
1831
+ ): Task<
1832
+ InferTaskOk<T[number]>,
1833
+ InferTaskErr<T[number]>,
1834
+ InferTaskDeps<T[number]>
1835
+ > =>
1836
+ concurrently(pool(tasks, { stopOn: "first", collect: false, abortReason }));
1837
+ /**
1838
+ * Abort reason for tasks that lose a {@link race}.
1839
+ *
1840
+ * @group Composition
1841
+ */
1842
+ export const RaceLostError = /*#__PURE__*/ typed("RaceLostError");
1843
+ export interface RaceLostError extends InferType<typeof RaceLostError> {}
1844
+
1845
+ /**
1846
+ * {@link RaceLostError} used as abort reason in {@link race}.
1847
+ *
1848
+ * @group Composition
1849
+ */
1850
+ export const raceLostError: RaceLostError = { type: "RaceLostError" };
1851
+
1852
+ /**
1853
+ * Wraps a {@link Task} with a time limit.
1854
+ *
1855
+ * Returns {@link TimeoutError} if the Task doesn't complete within the specified
1856
+ * duration. The original Task is aborted when the timeout fires.
1857
+ *
1858
+ * ### Example
1859
+ *
1860
+ * ```ts
1861
+ * const fetchWithTimeout = timeout(fetchData, "5s");
1862
+ *
1863
+ * const result = await run(fetchWithTimeout);
1864
+ * if (!result.ok && result.error.type === "TimeoutError") {
1865
+ * console.log("Request timed out");
1866
+ * }
1867
+ * ```
1868
+ *
1869
+ * @group Composition
1870
+ */
1871
+ export const timeout = <T, E, D = unknown>(
1872
+ task: Task<T, E, D>,
1873
+ duration: Duration,
1874
+ {
1875
+ abortReason = timeoutError,
1876
+ }: {
1877
+ /**
1878
+ * Abort reason for the task when timeout fires. Defaults to
1879
+ * {@link timeoutError}.
1880
+ */
1881
+ abortReason?: unknown;
1882
+ } = {},
1883
+ ): Task<T, E | TimeoutError, D> =>
1884
+ race(
1885
+ [
1886
+ task,
1887
+ async (run) => {
1888
+ await run(sleep(duration));
1889
+ return err(timeoutError);
1890
+ },
1891
+ ],
1892
+ { abortReason },
1893
+ );
1894
+
1895
+ /**
1896
+ * Typed error returned by {@link timeout} when a task exceeds its time limit.
1897
+ *
1898
+ * @group Composition
1899
+ */
1900
+ export const TimeoutError = /*#__PURE__*/ typed("TimeoutError");
1901
+ export interface TimeoutError extends InferType<typeof TimeoutError> {}
1902
+
1903
+ /**
1904
+ * {@link TimeoutError} used as abort reason in {@link timeout}.
1905
+ *
1906
+ * @group Composition
1907
+ */
1908
+ export const timeoutError: TimeoutError = { type: "TimeoutError" };
1909
+
1910
+ /**
1911
+ * Options for {@link retry}.
1912
+ *
1913
+ * @group Composition
1914
+ */
1915
+ export interface RetryOptions<E, Output> {
1916
+ /** Predicate to determine if error is retryable. Defaults to all errors. */
1917
+ readonly retryable?: Predicate<E>;
1918
+
1919
+ /**
1920
+ * Callback invoked before each retry attempt with error, retry attempt
1921
+ * number, schedule output, and delay.
1922
+ */
1923
+ readonly onRetry?: (attempt: RetryAttempt<E, Output>) => void;
1924
+ }
1925
+
1926
+ /**
1927
+ * Info passed to {@link retry} {@link RetryOptions.onRetry} callback.
1928
+ *
1929
+ * @group Composition
1930
+ */
1931
+ export interface RetryAttempt<E, Output> extends ScheduleStep<Output> {
1932
+ readonly error: E;
1933
+ }
1934
+
1935
+ /**
1936
+ * Error returned when all retry attempts are exhausted.
1937
+ *
1938
+ * @group Composition
1939
+ */
1940
+ export interface RetryError<E> extends Typed<"RetryError"> {
1941
+ /** The error from the final attempt. */
1942
+ readonly cause: E;
1943
+
1944
+ /** Total attempts made (initial + retries). */
1945
+ readonly attempts: PositiveInt;
1946
+ }
1947
+
1948
+ /**
1949
+ * Wraps a {@link Task} with retry logic.
1950
+ *
1951
+ * Retries the Task according to the {@link Schedule}'s rules. Use
1952
+ * {@link RetryOptions.retryable} to filter which errors should trigger retries.
1953
+ *
1954
+ * All non-abort errors are wrapped in {@link RetryError}:
1955
+ *
1956
+ * - Task succeeds → `ok(value)`
1957
+ * - Task returns {@link AbortError} → `err(AbortError)` — passed through, no
1958
+ * retry, no wrapping
1959
+ * - Task returns any other error → retry until schedule exhausted or `retryable`
1960
+ * returns false → `err(RetryError)` with `cause` = the last error
1961
+ *
1962
+ * The `RetryError` is informative: "I tried N times, here's why I finally gave
1963
+ * up" — and `cause` contains the actual underlying error.
1964
+ *
1965
+ * ### Example
1966
+ *
1967
+ * ```ts
1968
+ * import {
1969
+ * exponential,
1970
+ * jitter,
1971
+ * maxDelay,
1972
+ * retry,
1973
+ * take,
1974
+ * } from "@evolu/common";
1975
+ *
1976
+ * const fetchWithRetry = retry(
1977
+ * fetchData,
1978
+ * // A jittered, capped, limited exponential backoff.
1979
+ * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
1980
+ * );
1981
+ *
1982
+ * const result = await run(fetchWithRetry);
1983
+ * if (!result.ok) {
1984
+ * if (AbortError.is(result.error)) {
1985
+ * // Was aborted externally
1986
+ * } else {
1987
+ * // RetryError — failed after retrying
1988
+ * console.log(`Failed after ${result.error.attempts} attempts`);
1989
+ * console.log(`Last error:`, result.error.cause);
1990
+ * }
1991
+ * }
1992
+ * ```
1993
+ *
1994
+ * The schedule receives the error as input, enabling error-aware strategies
1995
+ * like stopping on fatal errors:
1996
+ *
1997
+ * ```ts
1998
+ * import { whileScheduleInput } from "@evolu/common";
1999
+ *
2000
+ * // Don't retry fatal errors
2001
+ * const smartRetry = retry(
2002
+ * fetchData,
2003
+ * whileScheduleInput((e: FetchError) => e.type !== "FatalError")(
2004
+ * take(5)(spaced("1s")),
2005
+ * ),
2006
+ * );
2007
+ * ```
2008
+ *
2009
+ * @group Composition
2010
+ * @see {@link RetryOptions}
2011
+ */
2012
+ export const retry =
2013
+ <T, E, D = unknown, Output = unknown>(
2014
+ task: Task<T, E, D>,
2015
+ schedule: Schedule<Output, E>,
2016
+ {
2017
+ retryable = lazyTrue as Predicate<E>,
2018
+ onRetry,
2019
+ }: RetryOptions<E, Output> = {},
2020
+ ): Task<T, RetryError<E>, D> =>
2021
+ async (run) => {
2022
+ const step = schedule(run.deps);
2023
+ let attempt = onePositiveInt;
2024
+ let error: E | undefined;
2025
+
2026
+ for (;;) {
2027
+ if (error !== undefined) {
2028
+ const scheduleResult = step(error);
2029
+ if (!scheduleResult.ok) {
2030
+ return err<RetryError<E>>({
2031
+ type: "RetryError",
2032
+ cause: error,
2033
+ attempts: PositiveInt.orThrow(decrement(attempt)),
2034
+ });
2035
+ }
2036
+
2037
+ const [output, delay] = scheduleResult.value;
2038
+ onRetry?.({
2039
+ error,
2040
+ attempt: PositiveInt.orThrow(decrement(attempt)),
2041
+ output,
2042
+ delay,
2043
+ });
2044
+ if (delay > 0) {
2045
+ const sleepResult = await run(sleep(delay));
2046
+ if (!sleepResult.ok) return sleepResult;
2047
+ }
2048
+ }
2049
+
2050
+ const result = await run(task);
2051
+ if (result.ok) return result;
2052
+
2053
+ if (AbortError.is(result.error)) return err(result.error);
2054
+
2055
+ error = result.error;
2056
+ if (!retryable(error)) {
2057
+ return err({
2058
+ type: "RetryError",
2059
+ cause: error,
2060
+ attempts: attempt,
2061
+ });
2062
+ }
2063
+
2064
+ attempt = PositiveInt.orThrow(increment(attempt));
2065
+ }
2066
+ };
2067
+
2068
+ /**
2069
+ * Options for {@link repeat}.
2070
+ *
2071
+ * @group Composition
2072
+ */
2073
+ export interface RepeatOptions<T, Output> {
2074
+ /** Predicate to determine if value is repeatable. Defaults to all values. */
2075
+ readonly repeatable?: Predicate<T>;
2076
+ /**
2077
+ * Callback invoked before each repeat with value, repeat attempt number,
2078
+ * schedule output, and delay.
2079
+ */
2080
+ readonly onRepeat?: (attempt: RepeatAttempt<T, Output>) => void;
2081
+ }
2082
+
2083
+ /**
2084
+ * Info passed to {@link repeat} {@link RepeatOptions.onRepeat} callback.
2085
+ *
2086
+ * @group Composition
2087
+ */
2088
+ export interface RepeatAttempt<T, Output> extends ScheduleStep<Output> {
2089
+ readonly value: T;
2090
+ }
2091
+
2092
+ /**
2093
+ * Repeats a {@link Task} according to a {@link Schedule}.
2094
+ *
2095
+ * Runs the Task, then checks the schedule to determine if it should repeat. The
2096
+ * schedule controls how many repetitions occur and the delay between them.
2097
+ * Continues until the schedule returns `Err(Done<void>)` or the Task fails.
2098
+ *
2099
+ * With `take(n)`, the task runs n+1 times (initial run plus n repetitions).
2100
+ *
2101
+ * Also works with {@link NextTask} — when the Task returns `Err(Done<D>)`,
2102
+ * repeat stops and propagates the done signal.
2103
+ *
2104
+ * ### Example
2105
+ *
2106
+ * ```ts
2107
+ * import { fixed, take } from "@evolu/common/schedule";
2108
+ * import { repeat } from "@evolu/common";
2109
+ *
2110
+ * // Heartbeat every 30 seconds (runs forever until aborted)
2111
+ * const heartbeat = repeat(sendHeartbeat, fixed("30s"));
2112
+ *
2113
+ * // Poll 4 times total (initial + 3 repetitions), 1 second apart
2114
+ * const poll = repeat(checkStatus, take(3)(fixed("1s")));
2115
+ *
2116
+ * // Process queue items until empty (NextTask pattern)
2117
+ * const processQueue: NextTask<Item, ProcessError, void> = async (run) => {
2118
+ * const item = queue.dequeue();
2119
+ * if (!item) return err(done()); // Queue empty, stop
2120
+ * await process(item);
2121
+ * return ok(item);
2122
+ * };
2123
+ *
2124
+ * const result = await run(repeat(processQueue, fixed("100ms")));
2125
+ * if (!result.ok && result.error.type === "Done") {
2126
+ * console.log("Queue exhausted");
2127
+ * }
2128
+ * ```
2129
+ *
2130
+ * @group Composition
2131
+ */
2132
+ export const repeat =
2133
+ <T, E, D = unknown, Output = unknown>(
2134
+ task: Task<T, E, D>,
2135
+ schedule: Schedule<Output, T>,
2136
+ {
2137
+ repeatable = lazyTrue as Predicate<T>,
2138
+ onRepeat,
2139
+ }: RepeatOptions<T, Output> = {},
2140
+ ): Task<T, E, D> =>
2141
+ async (run) => {
2142
+ const step = schedule(run.deps);
2143
+ let lastResult: Result<T, E>;
2144
+ let attempt = onePositiveInt;
2145
+
2146
+ for (;;) {
2147
+ const result = await run(task);
2148
+ if (!result.ok) return result;
2149
+ lastResult = result;
2150
+
2151
+ if (!repeatable(result.value)) return lastResult;
2152
+
2153
+ const next = step(result.value);
2154
+ if (!next.ok) break;
2155
+
2156
+ const [output, delay] = next.value;
2157
+ onRepeat?.({
2158
+ value: result.value,
2159
+ attempt,
2160
+ output,
2161
+ delay,
2162
+ });
2163
+ attempt = PositiveInt.orThrow(increment(attempt));
2164
+ if (delay > 0) {
2165
+ const sleepResult = await run(sleep(delay));
2166
+ if (!sleepResult.ok) return sleepResult;
2167
+ }
2168
+ }
2169
+
2170
+ return lastResult;
2171
+ };
2172
+
2173
+ /**
2174
+ * A value that can be resolved later.
2175
+ *
2176
+ * Similar to
2177
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers | Promise.withResolvers},
2178
+ * but integrated with {@link Task} and {@link Run} for cancellation support.
2179
+ *
2180
+ * Use for bridging callback-based APIs or coordinating between Tasks.
2181
+ *
2182
+ * Disposing aborts all waiting Tasks with an {@link AbortError} with
2183
+ * {@link deferredDisposedError} reason.
2184
+ *
2185
+ * ### Example
2186
+ *
2187
+ * ```ts
2188
+ * const deferred = createDeferred<string, MyError>();
2189
+ *
2190
+ * // Start waiting for the value
2191
+ * const fiber = run(deferred.task);
2192
+ *
2193
+ * // Resolve from elsewhere (callback, another task, etc.)
2194
+ * deferred.resolve(ok("value"));
2195
+ *
2196
+ * const result = await fiber; // ok("value")
2197
+ * ```
2198
+ *
2199
+ * @group Concurrency primitives
2200
+ * @see {@link createDeferred}
2201
+ */
2202
+ export interface Deferred<T, E = never> extends Disposable {
2203
+ /** A {@link Task} that waits until {@link Deferred.resolve} is called. */
2204
+ readonly task: Task<T, E | DeferredDisposedError>;
2205
+
2206
+ /** Resolves the value. Returns `true` once, then `false`. */
2207
+ readonly resolve: (
2208
+ result: Result<T, E | AbortError | DeferredDisposedError>,
2209
+ ) => boolean;
2210
+ }
2211
+
2212
+ /**
2213
+ * Creates a {@link Deferred}.
2214
+ *
2215
+ * @group Concurrency primitives
2216
+ */
2217
+ export const createDeferred = <T, E = never>(): Deferred<T, E> => {
2218
+ let resolved: Result<T, E | AbortError | DeferredDisposedError> | null = null;
2219
+ const resolvers = new Set<
2220
+ (result: Result<T, E | AbortError | DeferredDisposedError>) => void
2221
+ >();
2222
+
2223
+ const resolve = (
2224
+ result: Result<T, E | AbortError | DeferredDisposedError>,
2225
+ ) => {
2226
+ if (resolved !== null) return false;
2227
+ resolved = result;
2228
+ for (const resolver of resolvers) resolver(result);
2229
+ resolvers.clear();
2230
+ return true;
2231
+ };
2232
+
2233
+ return {
2234
+ task: (run) => {
2235
+ if (resolved !== null) return resolved;
2236
+
2237
+ return new Promise((resolvePromise) => {
2238
+ const resolve = (
2239
+ result: Result<T, E | AbortError | DeferredDisposedError>,
2240
+ ) => {
2241
+ resolvers.delete(resolve);
2242
+ resolvePromise(result);
2243
+ };
2244
+
2245
+ resolvers.add(resolve);
2246
+
2247
+ run.onAbort((reason) => {
2248
+ resolve(err(createAbortError(reason)));
2249
+ });
2250
+ });
2251
+ },
2252
+
2253
+ resolve,
2254
+
2255
+ [Symbol.dispose]: () => {
2256
+ resolve(err(deferredDisposedError));
2257
+ },
2258
+ };
2259
+ };
2260
+
2261
+ /**
2262
+ * Abort reason used when a {@link Deferred} is disposed.
2263
+ *
2264
+ * @group Concurrency primitives
2265
+ */
2266
+ export const DeferredDisposedError = /*#__PURE__*/ typed(
2267
+ "DeferredDisposedError",
2268
+ );
2269
+ export interface DeferredDisposedError extends InferType<
2270
+ typeof DeferredDisposedError
2271
+ > {}
2272
+
2273
+ /**
2274
+ * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}.
2275
+ *
2276
+ * @group Concurrency primitives
2277
+ */
2278
+ export const deferredDisposedError: DeferredDisposedError = {
2279
+ type: "DeferredDisposedError",
2280
+ };
2281
+
2282
+ /**
2283
+ * A blocking {@link Task} — like a gate.
2284
+ *
2285
+ * - **Closed**: Tasks wait.
2286
+ * - **Open**: Tasks proceed.
2287
+ *
2288
+ * Use it to pause execution based on a condition. Unlike a {@link Deferred}
2289
+ * (which triggers once), a {@link Gate} can be opened and closed repeatedly.
2290
+ *
2291
+ * Disposing aborts all waiting Tasks with {@link deferredDisposedError}.
2292
+ *
2293
+ * @group Concurrency primitives
2294
+ * @see {@link createGate}
2295
+ */
2296
+ export interface Gate<D = unknown> extends Disposable {
2297
+ readonly wait: Task<void, DeferredDisposedError, D>;
2298
+ readonly open: () => void;
2299
+ readonly close: () => void;
2300
+ readonly isOpen: () => boolean;
2301
+ }
2302
+
2303
+ /**
2304
+ * Creates a {@link Gate} that starts closed.
2305
+ *
2306
+ * Useful for "stop/go" logic where multiple Tasks need to wait for a state
2307
+ * change.
2308
+ *
2309
+ * ### Example
2310
+ *
2311
+ * ```ts
2312
+ * const networkGate = createGate();
2313
+ *
2314
+ * // Pause processing when offline
2315
+ * const onOffline = () => networkGate.close();
2316
+ *
2317
+ * // Resume processing when online
2318
+ * const onOnline = () => networkGate.open();
2319
+ *
2320
+ * const syncLoop = async (run) => {
2321
+ * while (true) {
2322
+ * // Blocks here whenever the gate is closed
2323
+ * await run(networkGate.wait);
2324
+ * await run(uploadNextItem);
2325
+ * }
2326
+ * };
2327
+ * ```
2328
+ *
2329
+ * @group Concurrency primitives
2330
+ */
2331
+ export const createGate = <D = unknown>(): Gate<D> => {
2332
+ let isOpen = false;
2333
+ let disposed = false;
2334
+ let deferred = createDeferred<void>();
2335
+
2336
+ return {
2337
+ wait: (run) => {
2338
+ if (disposed) return err(deferredDisposedError);
2339
+ if (isOpen) return ok();
2340
+ return run(deferred.task);
2341
+ },
2342
+
2343
+ open: () => {
2344
+ if (disposed || isOpen) return;
2345
+ isOpen = true;
2346
+ deferred.resolve(ok());
2347
+ },
2348
+
2349
+ close: () => {
2350
+ if (disposed || !isOpen) return;
2351
+ isOpen = false;
2352
+ deferred = createDeferred<void>();
2353
+ },
2354
+
2355
+ isOpen: () => isOpen,
2356
+
2357
+ [Symbol.dispose]: () => {
2358
+ if (disposed) return;
2359
+ disposed = true;
2360
+ deferred[Symbol.dispose]();
2361
+ },
2362
+ };
2363
+ };
2364
+
2365
+ /**
2366
+ * A semaphore that limits the number of concurrent {@link Task}s.
2367
+ *
2368
+ * For mutual exclusion (limiting to exactly one {@link Task}), use {@link Mutex}
2369
+ * instead.
2370
+ *
2371
+ * @group Concurrency primitives
2372
+ */
2373
+ export interface Semaphore extends Disposable {
2374
+ /**
2375
+ * Executes a {@link Task} while holding a semaphore permit.
2376
+ *
2377
+ * The Task waits until a permit is available. If the semaphore is disposed
2378
+ * while waiting or running, the Task is aborted with an {@link AbortError}
2379
+ * whose reason is {@link semaphoreDisposedError}.
2380
+ */
2381
+ readonly withPermit: <T, E, D>(task: Task<T, E, D>) => Task<T, E, D>;
2382
+
2383
+ /**
2384
+ * Executes a {@link Task} while holding a specified number of permits.
2385
+ *
2386
+ * If insufficient permits are available, waits in FIFO order until permits
2387
+ * become available. If disposed while waiting or running, the Task is aborted
2388
+ * with {@link semaphoreDisposedError}.
2389
+ *
2390
+ * Use this for weighted concurrency where a Task represents a resource
2391
+ * demand, not just "one more Task". One permit is one resource unit.
2392
+ *
2393
+ * Example: with capacity `10`, a lightweight operation can reserve `1` permit
2394
+ * while a heavy operation reserves `4` permits. This models shared budgets
2395
+ * such as DB connections, API credits, memory/CPU buckets, or batch
2396
+ * processing slots.
2397
+ *
2398
+ * {@link Semaphore.withPermit} is equivalent to `withPermits(1)`.
2399
+ */
2400
+ readonly withPermits: <T, E, D>(
2401
+ permits: Concurrency,
2402
+ ) => (task: Task<T, E, D>) => Task<T, E, D>;
2403
+
2404
+ /** Returns the current semaphore state for monitoring/debugging. */
2405
+ readonly snapshot: () => SemaphoreSnapshot;
2406
+ }
2407
+
2408
+ /** Snapshot returned by {@link Semaphore.snapshot}. */
2409
+ export interface SemaphoreSnapshot {
2410
+ /** Total permits configured at creation. */
2411
+ readonly permits: Concurrency;
2412
+
2413
+ /** Currently held permits. */
2414
+ readonly taken: NonNegativeInt;
2415
+
2416
+ /** Number of currently waiting Tasks. */
2417
+ readonly waiting: NonNegativeInt;
2418
+
2419
+ /** Currently available permits. */
2420
+ readonly available: NonNegativeInt;
2421
+
2422
+ /** Whether no permits are taken and no Tasks are waiting. */
2423
+ readonly isIdle: boolean;
2424
+
2425
+ /** Whether the semaphore has been disposed. */
2426
+ readonly disposed: boolean;
2427
+ }
2428
+
2429
+ /**
2430
+ * Creates a {@link Semaphore} that limits concurrent {@link Task}s.
2431
+ *
2432
+ * ### Example
2433
+ *
2434
+ * ```ts
2435
+ * await using run = createRun();
2436
+ *
2437
+ * const semaphore = createSemaphore(PositiveInt.orThrow(2));
2438
+ *
2439
+ * const fetchUser =
2440
+ * (id: string): Task<string> =>
2441
+ * async (run) => {
2442
+ * const { console } = run.deps;
2443
+ * console.log("[demo]", "start", id);
2444
+ * const slept = await run(sleep("10ms"));
2445
+ * if (!slept.ok) return slept;
2446
+ * console.log("[demo]", "end", id);
2447
+ * return ok(`user:${id}`);
2448
+ * };
2449
+ *
2450
+ * const fetchWithPermit = (id: string) =>
2451
+ * semaphore.withPermit(fetchUser(id));
2452
+ *
2453
+ * await Promise.all([
2454
+ * run(fetchWithPermit("1")),
2455
+ * run(fetchWithPermit("2")),
2456
+ * run(fetchWithPermit("3")),
2457
+ * ]);
2458
+ *
2459
+ * // [demo] start 1
2460
+ * // [demo] start 2
2461
+ * // [demo] end 1
2462
+ * // [demo] start 3
2463
+ * // [demo] end 2
2464
+ * // [demo] end 3
2465
+ * ```
2466
+ *
2467
+ * @group Concurrency primitives
2468
+ */
2469
+ export const createSemaphore = (permits: Concurrency): Semaphore => {
2470
+ interface Waiter {
2471
+ readonly permits: PositiveInt;
2472
+ readonly resolve: Callback<Result<void, AbortError>>;
2473
+ }
2474
+
2475
+ const fibers = new Set<Fiber>();
2476
+ const waiters: Array<Waiter> = [];
2477
+ let taken = zeroNonNegativeInt;
2478
+ let disposed = false;
2479
+
2480
+ const withPermits =
2481
+ <T, E, D>(requestedPermits: Concurrency) =>
2482
+ (task: Task<T, E, D>): Task<T, E, D> =>
2483
+ async (run) => {
2484
+ const requested = PositiveInt.orThrow(requestedPermits);
2485
+
2486
+ assert(
2487
+ requested <= permits,
2488
+ "Requested permits must not exceed semaphore capacity.",
2489
+ );
2490
+
2491
+ if (disposed) return err(semaphoreDisposedAbortError);
2492
+
2493
+ if (waiters.length > 0 || taken + requested > permits) {
2494
+ const waiter = Promise.withResolvers<Result<void, AbortError>>();
2495
+ const waiting: Waiter = {
2496
+ permits: requested,
2497
+ resolve: waiter.resolve,
2498
+ };
2499
+ waiters.push(waiting);
2500
+ run.onAbort((reason) => {
2501
+ const i = waiters.indexOf(waiting);
2502
+ if (i >= 0) waiters.splice(i, 1);
2503
+ waiter.resolve(err(createAbortError(reason)));
2504
+ });
2505
+
2506
+ const permit = await waiter.promise;
2507
+ if (!permit.ok) return permit;
2508
+ } else {
2509
+ taken = NonNegativeInt.orThrow(taken + requested);
2510
+ }
2511
+
2512
+ let fiber: Fiber<T, E, D> | null = null;
2513
+ using _ = {
2514
+ [Symbol.dispose]: () => {
2515
+ if (fiber) fibers.delete(fiber);
2516
+
2517
+ taken = NonNegativeInt.orThrow(taken - requested);
2518
+
2519
+ while (waiters.length > 0) {
2520
+ const waiter = waiters[0];
2521
+ if (taken + waiter.permits > permits) break;
2522
+ waiters.shift();
2523
+ taken = NonNegativeInt.orThrow(taken + waiter.permits);
2524
+ waiter.resolve(ok());
2525
+ }
2526
+ },
2527
+ };
2528
+
2529
+ fiber = run(task);
2530
+ fibers.add(fiber);
2531
+ return await fiber;
2532
+ };
2533
+
2534
+ return {
2535
+ withPermits,
2536
+
2537
+ withPermit: <T, E, D>(task: Task<T, E, D>): Task<T, E, D> =>
2538
+ withPermits<T, E, D>(1)(task),
2539
+
2540
+ snapshot: () => ({
2541
+ permits,
2542
+ taken,
2543
+ waiting: NonNegativeInt.orThrow(waiters.length),
2544
+ available: NonNegativeInt.orThrow(permits - taken),
2545
+ isIdle: taken === 0 && waiters.length === 0,
2546
+ disposed,
2547
+ }),
2548
+
2549
+ [Symbol.dispose]: () => {
2550
+ if (disposed) return;
2551
+ disposed = true;
2552
+
2553
+ using stack = new DisposableStack();
2554
+ for (const fiber of fibers) {
2555
+ stack.adopt(fiber, (fiber) => {
2556
+ fiber.abort(semaphoreDisposedError);
2557
+ });
2558
+ }
2559
+
2560
+ for (const waiter of waiters) {
2561
+ waiter.resolve(err(semaphoreDisposedAbortError));
2562
+ }
2563
+ waiters.length = 0;
2564
+ },
2565
+ };
2566
+ };
2567
+
2568
+ /**
2569
+ * Abort reason used when a {@link Semaphore} is disposed.
2570
+ *
2571
+ * @group Concurrency primitives
2572
+ */
2573
+ export const SemaphoreDisposedError = /*#__PURE__*/ typed(
2574
+ "SemaphoreDisposedError",
2575
+ );
2576
+ export interface SemaphoreDisposedError extends InferType<
2577
+ typeof SemaphoreDisposedError
2578
+ > {}
2579
+
2580
+ /**
2581
+ * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}.
2582
+ *
2583
+ * @group Concurrency primitives
2584
+ */
2585
+ export const semaphoreDisposedError: SemaphoreDisposedError = {
2586
+ type: "SemaphoreDisposedError",
2587
+ };
2588
+
2589
+ const semaphoreDisposedAbortError: AbortError = createAbortError(
2590
+ semaphoreDisposedError,
2591
+ );
2592
+
2593
+ /**
2594
+ * A keyed {@link Semaphore} registry.
2595
+ *
2596
+ * Provides semaphore operations per key while preserving the same API shape as
2597
+ * {@link Semaphore}.
2598
+ *
2599
+ * By default, {@link createSemaphoreByKey} uses reference identity for keys,
2600
+ * matching native `Map`. Callers may instead provide a {@link Lookup lookup} so
2601
+ * logical equality is based on a derived stable key.
2602
+ *
2603
+ * @group Concurrency primitives
2604
+ */
2605
+ export interface SemaphoreByKey<K = unknown> extends Disposable {
2606
+ /**
2607
+ * Executes a {@link Task} while holding one permit for a specific key.
2608
+ *
2609
+ * Behaves like {@link Semaphore.withPermit}, scoped to `key`.
2610
+ */
2611
+ readonly withPermit: <T, E, D>(key: K, task: Task<T, E, D>) => Task<T, E, D>;
2612
+
2613
+ /**
2614
+ * Executes a {@link Task} while holding permits for a specific key.
2615
+ *
2616
+ * Behaves like {@link Semaphore.withPermits}, scoped to `key`.
2617
+ */
2618
+ readonly withPermits: <T, E, D>(
2619
+ key: K,
2620
+ permits: Concurrency,
2621
+ ) => (task: Task<T, E, D>) => Task<T, E, D>;
2622
+
2623
+ /** Returns current semaphore state for a key, or `null` if absent. */
2624
+ readonly snapshot: (key: K) => SemaphoreSnapshot | null;
2625
+ }
2626
+
2627
+ /** Options for {@link createSemaphoreByKey}. */
2628
+ export interface CreateSemaphoreByKeyOptions<K, L = K> extends LookupOption<
2629
+ K,
2630
+ L
2631
+ > {}
2632
+
2633
+ /**
2634
+ * Creates a {@link SemaphoreByKey}.
2635
+ *
2636
+ * Each key gets its own semaphore with the same permit capacity.
2637
+ *
2638
+ * @group Concurrency primitives
2639
+ */
2640
+ export function createSemaphoreByKey<K = unknown>(
2641
+ permits: Concurrency,
2642
+ ): SemaphoreByKey<K>;
2643
+ export function createSemaphoreByKey<K, L>(
2644
+ permits: Concurrency,
2645
+ options: CreateSemaphoreByKeyOptions<K, L>,
2646
+ ): SemaphoreByKey<K>;
2647
+ export function createSemaphoreByKey<K, L = K>(
2648
+ permits: Concurrency,
2649
+ { lookup = identity as Lookup<K, L> }: CreateSemaphoreByKeyOptions<K, L> = {},
2650
+ ): SemaphoreByKey<K> {
2651
+ const semaphoresByKey = createLookupMap<K, Semaphore, L>({
2652
+ lookup,
2653
+ });
2654
+ let disposed = false;
2655
+
2656
+ const withPermits =
2657
+ <T, E, D>(key: K, requestedPermits: Concurrency) =>
2658
+ (task: Task<T, E, D>): Task<T, E, D> =>
2659
+ async (run: Run<D>) => {
2660
+ if (disposed) return err(semaphoreDisposedAbortError);
2661
+
2662
+ let semaphore = semaphoresByKey.get(key);
2663
+ if (!semaphore) {
2664
+ semaphore = createSemaphore(permits);
2665
+ semaphoresByKey.set(key, semaphore);
2666
+ }
2667
+
2668
+ using _ = {
2669
+ [Symbol.dispose]: () => {
2670
+ const snapshot = semaphore.snapshot();
2671
+ if (snapshot.isIdle) {
2672
+ semaphoresByKey.delete(key);
2673
+ semaphore[Symbol.dispose]();
2674
+ }
2675
+ },
2676
+ };
2677
+
2678
+ return await run(semaphore.withPermits<T, E, D>(requestedPermits)(task));
2679
+ };
2680
+
2681
+ return {
2682
+ withPermit: <T, E, D>(key: K, task: Task<T, E, D>): Task<T, E, D> =>
2683
+ withPermits<T, E, D>(key, 1)(task),
2684
+
2685
+ withPermits,
2686
+
2687
+ snapshot: (key) => semaphoresByKey.get(key)?.snapshot() ?? null,
2688
+
2689
+ [Symbol.dispose]: () => {
2690
+ if (disposed) return;
2691
+ disposed = true;
2692
+
2693
+ using stack = new DisposableStack();
2694
+ for (const semaphore of semaphoresByKey.values()) {
2695
+ stack.use(semaphore);
2696
+ }
2697
+ semaphoresByKey.clear();
2698
+ },
2699
+ };
2700
+ }
2701
+
2702
+ /**
2703
+ * A mutex (mutual exclusion) that ensures only one {@link Task} runs at a time.
2704
+ *
2705
+ * This is a specialized version of a {@link Semaphore} with a permit count of 1.
2706
+ *
2707
+ * ### Example
2708
+ *
2709
+ * ```ts
2710
+ * await using run = createRun();
2711
+ *
2712
+ * const mutex = createMutex();
2713
+ *
2714
+ * const task =
2715
+ * (id: string): Task<void> =>
2716
+ * async (run) => {
2717
+ * const { console } = run.deps;
2718
+ * console.log("start", id);
2719
+ * await run(sleep("10ms"));
2720
+ * console.log("end", id);
2721
+ * return ok();
2722
+ * };
2723
+ *
2724
+ * await Promise.all([
2725
+ * run(mutex.withLock(task("1"))),
2726
+ * run(mutex.withLock(task("2"))),
2727
+ * ]);
2728
+ *
2729
+ * // start 1
2730
+ * // end 1
2731
+ * // start 2
2732
+ * // end 2
2733
+ * ```
2734
+ *
2735
+ * @group Concurrency primitives
2736
+ */
2737
+ export interface Mutex extends Disposable {
2738
+ /**
2739
+ * Executes a {@link Task} while holding the mutex lock.
2740
+ *
2741
+ * Only one Task can hold the lock at a time. Other Tasks wait until the lock
2742
+ * is released.
2743
+ */
2744
+ readonly withLock: <T, E, D>(task: Task<T, E, D>) => Task<T, E, D>;
2745
+
2746
+ /** Returns the current mutex state for monitoring/debugging. */
2747
+ readonly snapshot: () => SemaphoreSnapshot;
2748
+ }
2749
+
2750
+ /**
2751
+ * Creates a {@link Mutex}.
2752
+ *
2753
+ * @group Concurrency primitives
2754
+ */
2755
+ export const createMutex = (): Mutex => {
2756
+ const semaphore = createSemaphore(onePositiveInt);
2757
+
2758
+ return {
2759
+ withLock: semaphore.withPermit,
2760
+ snapshot: semaphore.snapshot,
2761
+ [Symbol.dispose]: semaphore[Symbol.dispose],
2762
+ };
2763
+ };
2764
+
2765
+ /**
2766
+ * A keyed {@link Mutex} registry.
2767
+ *
2768
+ * Provides mutex operations per key.
2769
+ *
2770
+ * By default, {@link createMutexByKey} uses reference identity for keys,
2771
+ * matching native `Map`. Callers may instead provide a {@link Lookup lookup} so
2772
+ * logical equality is based on a derived stable key.
2773
+ *
2774
+ * @group Concurrency primitives
2775
+ */
2776
+ export interface MutexByKey<K = unknown> extends Disposable {
2777
+ /**
2778
+ * Executes a {@link Task} while holding the mutex lock for a specific key.
2779
+ *
2780
+ * Behaves like {@link Mutex.withLock}, scoped to `key`.
2781
+ */
2782
+ readonly withLock: <T, E, D>(key: K, task: Task<T, E, D>) => Task<T, E, D>;
2783
+
2784
+ /** Returns the current mutex state for `key`, or `null` if absent. */
2785
+ readonly snapshot: (key: K) => SemaphoreSnapshot | null;
2786
+ }
2787
+
2788
+ /** Options for {@link createMutexByKey}. */
2789
+ export interface CreateMutexByKeyOptions<K, L = K> extends LookupOption<K, L> {}
2790
+
2791
+ /**
2792
+ * Creates a {@link MutexByKey}.
2793
+ *
2794
+ * @group Concurrency primitives
2795
+ */
2796
+ export function createMutexByKey<K = unknown>(): MutexByKey<K>;
2797
+ export function createMutexByKey<K, L>(
2798
+ options: CreateMutexByKeyOptions<K, L>,
2799
+ ): MutexByKey<K>;
2800
+ export function createMutexByKey<K, L = K>({
2801
+ lookup = identity as Lookup<K, L>,
2802
+ }: CreateMutexByKeyOptions<K, L> = {}): MutexByKey<K> {
2803
+ const semaphoreByKey = createSemaphoreByKey<K, L>(onePositiveInt, {
2804
+ lookup,
2805
+ });
2806
+
2807
+ return {
2808
+ withLock: <T, E, D>(key: K, task: Task<T, E, D>): Task<T, E, D> =>
2809
+ semaphoreByKey.withPermit(key, task),
2810
+ snapshot: semaphoreByKey.snapshot,
2811
+ [Symbol.dispose]: semaphoreByKey[Symbol.dispose],
2812
+ };
2813
+ }
2814
+
2815
+ /**
2816
+ * {@link Ref} protected by a {@link Mutex}.
2817
+ *
2818
+ * `MutexRef` serializes all operations through an internal {@link Mutex}. Reads,
2819
+ * writes, and updates observe one consistent value transition at a time. If the
2820
+ * update fails or is aborted, the previous value is preserved.
2821
+ *
2822
+ * Typical use cases are small coordinators such as caches, session values,
2823
+ * in-memory registries, and counters whose transitions need to run {@link Task}s
2824
+ * atomically.
2825
+ *
2826
+ * @group Concurrency primitives
2827
+ */
2828
+ export interface MutexRef<T> extends Disposable {
2829
+ /** Returns the current value. */
2830
+ readonly get: Task<T>;
2831
+
2832
+ /** Sets the current value. */
2833
+ readonly set: (value: T) => Task<void>;
2834
+
2835
+ /** Sets the current value and returns the previous value. */
2836
+ readonly getAndSet: (value: T) => Task<T>;
2837
+
2838
+ /** Sets the current value and returns it. */
2839
+ readonly setAndGet: (value: T) => Task<T>;
2840
+
2841
+ /** Updates the current value. */
2842
+ readonly update: <E = never, D = unknown>(
2843
+ updater: (current: T) => Task<T, E, D>,
2844
+ ) => Task<void, E, D>;
2845
+
2846
+ /** Updates the current value and returns the previous value. */
2847
+ readonly getAndUpdate: <E = never, D = unknown>(
2848
+ updater: (current: T) => Task<T, E, D>,
2849
+ ) => Task<T, E, D>;
2850
+
2851
+ /** Updates the current value and returns it. */
2852
+ readonly updateAndGet: <E = never, D = unknown>(
2853
+ updater: (current: T) => Task<T, E, D>,
2854
+ ) => Task<T, E, D>;
2855
+
2856
+ /** Modifies the current value and returns a computed result. */
2857
+ readonly modify: <R, E = never, D = unknown>(
2858
+ modifier: (current: T) => Task<readonly [result: R, nextValue: T], E, D>,
2859
+ ) => Task<R, E, D>;
2860
+ }
2861
+
2862
+ /**
2863
+ * Creates a {@link MutexRef} with the given initial immutable value.
2864
+ *
2865
+ * @group Concurrency primitives
2866
+ */
2867
+ export const createMutexRef = <T>(initialValue: T): MutexRef<T> => {
2868
+ const ref = createRef(initialValue);
2869
+ const mutex = createMutex();
2870
+
2871
+ return {
2872
+ get: mutex.withLock(() => ok(ref.get())),
2873
+
2874
+ set: (value) =>
2875
+ mutex.withLock(() => {
2876
+ ref.set(value);
2877
+ return ok();
2878
+ }),
2879
+
2880
+ getAndSet: (value) => mutex.withLock(() => ok(ref.getAndSet(value))),
2881
+
2882
+ setAndGet: (value) => mutex.withLock(() => ok(ref.setAndGet(value))),
2883
+
2884
+ update: (updater) =>
2885
+ mutex.withLock(async (run) => {
2886
+ const nextValue = await run(updater(ref.get()));
2887
+ if (!nextValue.ok) return nextValue;
2888
+ ref.set(nextValue.value);
2889
+ return ok();
2890
+ }),
2891
+
2892
+ getAndUpdate: (updater) =>
2893
+ mutex.withLock(async (run) => {
2894
+ const previousValue = ref.get();
2895
+ const nextValue = await run(updater(previousValue));
2896
+ if (!nextValue.ok) return nextValue;
2897
+ ref.set(nextValue.value);
2898
+ return ok(previousValue);
2899
+ }),
2900
+
2901
+ updateAndGet: (updater) =>
2902
+ mutex.withLock(async (run) => {
2903
+ const currentValue = ref.get();
2904
+ const nextValue = await run(updater(currentValue));
2905
+ if (!nextValue.ok) return nextValue;
2906
+ ref.set(nextValue.value);
2907
+ return ok(nextValue.value);
2908
+ }),
2909
+
2910
+ modify: (modifier) =>
2911
+ mutex.withLock(async (run) => {
2912
+ const nextValue = await run(modifier(ref.get()));
2913
+ if (!nextValue.ok) return nextValue;
2914
+ const [result, updatedValue] = nextValue.value;
2915
+ ref.set(updatedValue);
2916
+ return ok(result);
2917
+ }),
2918
+
2919
+ [Symbol.dispose]: mutex[Symbol.dispose],
2920
+ };
2921
+ };
2922
+
2923
+ /**
2924
+ * Cross-platform leader lock abstraction.
2925
+ *
2926
+ * `lock` returns a {@link Task} that waits until leadership is acquired and
2927
+ * yields a lease.
2928
+ *
2929
+ * Returns {@link AsyncDisposable} lease. Dispose it to release leadership.
2930
+ *
2931
+ * @group Concurrency primitives
2932
+ */
2933
+ export interface LeaderLock {
2934
+ readonly lock: (name: Name) => Task<AsyncDisposable>;
2935
+ }
2936
+
2937
+ /** @group Concurrency primitives */
2938
+ export interface LeaderLockDep {
2939
+ readonly leaderLock: LeaderLock;
2940
+ }
2941
+
2942
+ /**
2943
+ * Creates an in-process {@link LeaderLock}.
2944
+ *
2945
+ * Uses one {@link Mutex} per {@link Name}. Suitable for runtimes without a
2946
+ * cross-process lock manager (for example in-memory worker tests or React
2947
+ * Native).
2948
+ *
2949
+ * @group Concurrency primitives
2950
+ */
2951
+ export const createInMemoryLeaderLock = (): LeaderLock => {
2952
+ const mutexByName = createMutexByKey<Name>();
2953
+
2954
+ return {
2955
+ lock: (name) => async (run) => {
2956
+ const leaseRun = run.create();
2957
+ const released = createDeferred<void>();
2958
+ const acquired = createDeferred<void>();
2959
+
2960
+ void leaseRun(
2961
+ mutexByName.withLock(name, async (run) => {
2962
+ acquired.resolve(ok());
2963
+ await run(released.task);
2964
+ return ok();
2965
+ }),
2966
+ );
2967
+
2968
+ const acquiredResult = await run(acquired.task);
2969
+ if (!acquiredResult.ok) {
2970
+ assert(
2971
+ AbortError.is(acquiredResult.error),
2972
+ "Leader lock acquisition deferred must not be disposed.",
2973
+ );
2974
+ void leaseRun[Symbol.asyncDispose]();
2975
+ return err(acquiredResult.error);
2976
+ }
2977
+
2978
+ return ok({
2979
+ [Symbol.asyncDispose]: leaseRun[Symbol.asyncDispose],
2980
+ });
2981
+ },
2982
+ };
2983
+ };
2984
+
2985
+ /**
2986
+ * Options for {@link all}, {@link allSettled}, {@link map}, and {@link mapSettled}.
2987
+ *
2988
+ * @group Composition
2989
+ */
2990
+ export interface CollectOptions<Collect extends boolean = true> {
2991
+ /**
2992
+ * Whether to collect results. When `false`, returns `Task<void, E, D>`.
2993
+ *
2994
+ * @default true
2995
+ */
2996
+ readonly collect?: Collect;
2997
+
2998
+ /**
2999
+ * Custom reason for aborting remaining tasks on failure.
3000
+ *
3001
+ * By default, uses the helper's default abort error.
3002
+ */
3003
+ readonly abortReason?: unknown;
3004
+ }
3005
+
3006
+ /**
3007
+ * Fails fast on first error across multiple {@link Task}s.
3008
+ *
3009
+ * Sequential by default — use {@link concurrently} to run concurrently.
3010
+ *
3011
+ * ### Example
3012
+ *
3013
+ * ```ts
3014
+ * const result = await run(all([fetchUser, fetchPosts, fetchComments]));
3015
+ * if (!result.ok) return result;
3016
+ * const [user, posts, comments] = result.value;
3017
+ * ```
3018
+ *
3019
+ * @group Composition
3020
+ * @see {@link CollectOptions}
3021
+ */
3022
+ export function all<
3023
+ const T extends readonly [AnyTask, ...ReadonlyArray<AnyTask>],
3024
+ >(
3025
+ tasks: T,
3026
+ options?: CollectOptions,
3027
+ ): Task<
3028
+ { [K in keyof T]: InferTaskOk<T[K]> },
3029
+ InferTaskErr<T[number]>,
3030
+ InferTaskDeps<T[number]>
3031
+ >;
3032
+
3033
+ /**
3034
+ * Returns object with same keys.
3035
+ *
3036
+ * ```ts
3037
+ * const result = await run(all({ user: fetchUser, posts: fetchPosts }));
3038
+ * if (!result.ok) return result;
3039
+ * const { user, posts } = result.value;
3040
+ * ```
3041
+ */
3042
+ export function all<T extends Readonly<Record<string, AnyTask>>>(
3043
+ tasks: T,
3044
+ options?: CollectOptions,
3045
+ ): Task<
3046
+ { [P in keyof T]: InferTaskOk<T[P]> },
3047
+ [keyof T] extends [never] ? never : InferTaskErr<T[keyof T]>,
3048
+ [keyof T] extends [never] ? unknown : InferTaskDeps<T[keyof T]>
3049
+ >;
3050
+
3051
+ /**
3052
+ * For dynamic or generated task lists.
3053
+ *
3054
+ * ```ts
3055
+ * const urls: ReadonlyArray<string> = getUrls();
3056
+ * const result = await run(map(urls, fetchUrl));
3057
+ * if (!result.ok) return result;
3058
+ * // result.value: ReadonlyArray<Response>
3059
+ * ```
3060
+ */
3061
+ export function all<T, E, D>(
3062
+ tasks: Iterable<Task<T, E, D>>,
3063
+ options?: CollectOptions,
3064
+ ): Task<ReadonlyArray<T>, E, D>;
3065
+
3066
+ /**
3067
+ * Guarantees non-empty result.
3068
+ *
3069
+ * ```ts
3070
+ * const tasks: NonEmptyReadonlyArray<Task<Response, FetchError>> = [
3071
+ * fetchUrl("/a"),
3072
+ * fetchUrl("/b"),
3073
+ * ];
3074
+ * const result = await run(all(tasks));
3075
+ * if (!result.ok) return result;
3076
+ * // result.value: NonEmptyReadonlyArray<Response>
3077
+ * ```
3078
+ */
3079
+ export function all<T, E, D>(
3080
+ tasks: NonEmptyReadonlyArray<Task<T, E, D>>,
3081
+ options?: CollectOptions,
3082
+ ): Task<NonEmptyReadonlyArray<T>, E, D>;
3083
+
3084
+ /**
3085
+ * Run for side effects only.
3086
+ *
3087
+ * ```ts
3088
+ * const result = await run(all(tasks, { collect: false }));
3089
+ * // result.value: void
3090
+ * ```
3091
+ */
3092
+ export function all<T, E, D>(
3093
+ tasks: Iterable<Task<T, E, D>> | Readonly<Record<string, Task<T, E, D>>>,
3094
+ options: CollectOptions<false>,
3095
+ ): Task<void, E, D>;
3096
+
3097
+ export function all(
3098
+ input: CollectInput,
3099
+ options?: CollectOptions<boolean>,
3100
+ ): Task<unknown, unknown> {
3101
+ return collect("all", input, options);
3102
+ }
3103
+
3104
+ /**
3105
+ * Abort reason used by {@link all} when aborting remaining tasks.
3106
+ *
3107
+ * Used when a Task fails and other Tasks need to be aborted.
3108
+ *
3109
+ * @group Composition
3110
+ */
3111
+ export const AllAbortError = /*#__PURE__*/ typed("AllAbortError");
3112
+ export interface AllAbortError extends InferType<typeof AllAbortError> {}
3113
+
3114
+ /**
3115
+ * {@link AllAbortError} used as abort reason in {@link all}.
3116
+ *
3117
+ * @group Composition
3118
+ */
3119
+ export const allAbortError: AllAbortError = { type: "AllAbortError" };
3120
+
3121
+ /**
3122
+ * Completes all {@link Task}s regardless of individual failures.
3123
+ *
3124
+ * Like
3125
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled | Promise.allSettled},
3126
+ * all Tasks run to completion regardless of individual failures. Returns an
3127
+ * array of {@link Result}s preserving the original order.
3128
+ *
3129
+ * Sequential by default. Use {@link concurrently} for concurrent execution.
3130
+ *
3131
+ * ### Example
3132
+ *
3133
+ * ```ts
3134
+ * const results = await run(
3135
+ * allSettled([fetchUser, fetchPosts, fetchComments]),
3136
+ * );
3137
+ * if (!results.ok) return results; // Only AbortError
3138
+ *
3139
+ * for (const result of results.value) {
3140
+ * if (result.ok) {
3141
+ * console.log("Success:", result.value);
3142
+ * } else {
3143
+ * console.log("Failed:", result.error);
3144
+ * }
3145
+ * }
3146
+ * ```
3147
+ *
3148
+ * @group Composition
3149
+ * @see {@link CollectOptions}
3150
+ */
3151
+ export function allSettled<
3152
+ const T extends readonly [AnyTask, ...ReadonlyArray<AnyTask>],
3153
+ >(
3154
+ tasks: T,
3155
+ options?: CollectOptions,
3156
+ ): Task<
3157
+ {
3158
+ [K in keyof T]: Result<InferTaskOk<T[K]>, InferTaskErr<T[K]> | AbortError>;
3159
+ },
3160
+ never,
3161
+ InferTaskDeps<T[number]>
3162
+ >;
3163
+
3164
+ /**
3165
+ * Returns object with same keys.
3166
+ *
3167
+ * ```ts
3168
+ * const results = await run(
3169
+ * allSettled({ user: fetchUser, posts: fetchPosts }),
3170
+ * );
3171
+ * if (!results.ok) return results;
3172
+ * const { user, posts } = results.value; // Each is Result<T, E>
3173
+ * ```
3174
+ */
3175
+ export function allSettled<T extends Readonly<Record<string, AnyTask>>>(
3176
+ tasks: T,
3177
+ options?: CollectOptions,
3178
+ ): Task<
3179
+ {
3180
+ [P in keyof T]: Result<InferTaskOk<T[P]>, InferTaskErr<T[P]> | AbortError>;
3181
+ },
3182
+ never,
3183
+ [keyof T] extends [never] ? unknown : InferTaskDeps<T[keyof T]>
3184
+ >;
3185
+
3186
+ /**
3187
+ * For dynamic or generated task lists.
3188
+ *
3189
+ * ```ts
3190
+ * const urls: ReadonlyArray<string> = getUrls();
3191
+ * const results = await run(allSettled(tasks));
3192
+ * if (!results.ok) return results;
3193
+ * // results.value: ReadonlyArray<Result<Response, FetchError | AbortError>>
3194
+ * ```
3195
+ */
3196
+ export function allSettled<T, E, D>(
3197
+ tasks: Iterable<Task<T, E, D>>,
3198
+ options?: CollectOptions,
3199
+ ): Task<ReadonlyArray<Result<T, E | AbortError>>, never, D>;
3200
+
3201
+ /**
3202
+ * Guarantees non-empty result.
3203
+ *
3204
+ * ```ts
3205
+ * const tasks: NonEmptyReadonlyArray<Task<Response, FetchError>> = [
3206
+ * fetchUrl("/a"),
3207
+ * fetchUrl("/b"),
3208
+ * ];
3209
+ * const results = await run(allSettled(tasks));
3210
+ * if (!results.ok) return results;
3211
+ * // results.value: NonEmptyReadonlyArray<Result<Response, FetchError | AbortError>>
3212
+ * ```
3213
+ */
3214
+ export function allSettled<T, E, D>(
3215
+ tasks: NonEmptyReadonlyArray<Task<T, E, D>>,
3216
+ options?: CollectOptions,
3217
+ ): Task<NonEmptyReadonlyArray<Result<T, E | AbortError>>, never, D>;
233
3218
 
234
- return controller.signal;
235
- };
3219
+ /**
3220
+ * Run for side effects only.
3221
+ *
3222
+ * ```ts
3223
+ * const result = await run(allSettled(tasks, { collect: false }));
3224
+ * // result.value: void
3225
+ * ```
3226
+ */
3227
+ export function allSettled<T, E, D>(
3228
+ tasks: Iterable<Task<T, E, D>> | Readonly<Record<string, Task<T, E, D>>>,
3229
+ options: CollectOptions<false>,
3230
+ ): Task<void, never, D>;
3231
+
3232
+ export function allSettled(
3233
+ input: Iterable<AnyTask> | Readonly<Record<string, AnyTask>>,
3234
+ options?: CollectOptions<boolean>,
3235
+ ): Task<unknown> {
3236
+ return collect("allSettled", input, options) as Task<unknown>;
236
3237
  }
237
3238
 
238
3239
  /**
239
- * Combines user signal from context with an internal signal.
3240
+ * Abort reason used by {@link allSettled} when aborted externally.
240
3241
  *
241
- * If the context has a signal, combines both signals using AbortSignal.any().
242
- * Otherwise, returns just the internal signal.
3242
+ * @group Composition
243
3243
  */
244
- const combineSignal = (
245
- context: TaskContext | undefined,
246
- internalSignal: AbortSignal,
247
- ): AbortSignal =>
248
- context?.signal
249
- ? AbortSignal.any([context.signal, internalSignal])
250
- : internalSignal;
3244
+ export const AllSettledAbortError = /*#__PURE__*/ typed("AllSettledAbortError");
3245
+ export interface AllSettledAbortError extends InferType<
3246
+ typeof AllSettledAbortError
3247
+ > {}
251
3248
 
252
3249
  /**
253
- * Converts async function returning {@link Result} to a {@link Task}.
3250
+ * {@link AllSettledAbortError} used as abort reason in {@link allSettled}.
254
3251
  *
255
- * ### Example
3252
+ * @group Composition
3253
+ */
3254
+ export const allSettledAbortError: AllSettledAbortError = {
3255
+ type: "AllSettledAbortError",
3256
+ };
3257
+
3258
+ /**
3259
+ * Maps values to {@link Task}s, failing fast on first error.
256
3260
  *
257
- * ```ts
258
- * interface FetchError {
259
- * readonly type: "FetchError";
260
- * readonly error: unknown;
261
- * }
3261
+ * Sequential by default — use {@link concurrently} for concurrent execution.
262
3262
  *
263
- * // Task version of fetch with proper error handling and cancellation support.
264
- * const fetch = (url: string) =>
265
- * toTask((context) =>
266
- * tryAsync(
267
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
268
- * (error): FetchError => ({ type: "FetchError", error }),
269
- * ),
270
- * );
3263
+ * ### Example
271
3264
  *
272
- * // `satisfies` shows the expected type signature.
273
- * fetch satisfies (url: string) => Task<Response, FetchError>;
3265
+ * ```ts
3266
+ * const result = await run(map(userIds, fetchUser));
3267
+ * if (!result.ok) return result;
3268
+ * // result.value: ReadonlyArray<User>
3269
+ * ```
274
3270
  *
275
- * const result1 = await fetch("https://api.example.com/data")();
276
- * result1 satisfies Result<Response, FetchError>;
3271
+ * @group Composition
3272
+ * @see {@link CollectOptions}
3273
+ */
3274
+ export function map<A, T, E, D>(
3275
+ items: Iterable<A>,
3276
+ task: (a: A) => Task<T, E, D>,
3277
+ options?: CollectOptions,
3278
+ ): Task<ReadonlyArray<T>, E, D>;
3279
+
3280
+ /**
3281
+ * Returns object with same keys.
277
3282
  *
278
- * // With AbortController
279
- * const controller = new AbortController();
280
- * const result2 = await fetch("https://api.example.com/data")(controller);
281
- * result2 satisfies Result<Response, FetchError | AbortError>;
3283
+ * ```ts
3284
+ * const result = await run(map({ a: 1, b: 2 }, (n) => double(n)));
3285
+ * if (!result.ok) return result;
3286
+ * // result.value: { a: number, b: number }
282
3287
  * ```
283
3288
  */
284
- export const toTask = <T, E>(
285
- fn: (context?: TaskContext) => Promise<Result<T, E>>,
286
- ): Task<T, E> =>
287
- // Note: Not using async to avoid Promise wrapper overhead in fast path
288
- ((context) => {
289
- const signal = context?.signal;
290
-
291
- // Fast path when no signal – return promise directly
292
- if (!signal) {
293
- // Preserve future context fields (e.g., tracing) even without a signal
294
- return fn(context);
295
- }
296
-
297
- if (signal.aborted) {
298
- return Promise.resolve(
299
- err({ type: "AbortError", reason: signal.reason as unknown }),
300
- );
301
- }
302
-
303
- // Use Promise.withResolvers for clean abort handling and cleanup
304
- const { promise: abortPromise, resolve: resolveAbort } =
305
- Promise.withResolvers<Result<never, AbortError>>();
306
-
307
- const handleAbort = () => {
308
- resolveAbort(
309
- err({ type: "AbortError", reason: signal.reason as unknown }),
310
- );
311
- };
312
-
313
- signal.addEventListener("abort", handleAbort, { once: true });
314
-
315
- // No finally: we expect no throws in normal flow; Result path removes listener.
316
- // Unexpected throws indicate a bug and are allowed to crash (no recovery here).
317
- return Promise.race([
318
- abortPromise,
319
- fn(context).then((result) => {
320
- signal.removeEventListener("abort", handleAbort);
321
- return result;
322
- }),
323
- ]);
324
- }) as Task<T, E>;
325
-
326
- // For React Native
327
- if (typeof AbortSignal.timeout !== "function") {
328
- AbortSignal.timeout = function (ms: number): AbortSignal {
329
- const controller = new AbortController();
330
- const id = setTimeout(() => {
331
- controller.abort();
332
- }, ms);
333
- // clear timeout if aborted early
334
- controller.signal.addEventListener("abort", () => {
335
- clearTimeout(id);
336
- });
337
- return controller.signal;
338
- };
339
- }
3289
+ export function map<A, T, E, D, K extends string>(
3290
+ items: Readonly<Record<K, A>>,
3291
+ task: (a: A) => Task<T, E, D>,
3292
+ options?: CollectOptions,
3293
+ ): Task<Readonly<Record<K, T>>, E, D>;
340
3294
 
341
3295
  /**
342
- * Creates a {@link Task} that waits for the specified duration.
343
- *
344
- * ### Example
3296
+ * Guarantees non-empty result.
345
3297
  *
346
3298
  * ```ts
347
- * const result1 = await wait("10ms")();
348
- * result1 satisfies Result<void, never>;
349
- *
350
- * // With AbortController
351
- * const controller = new AbortController();
352
- * const result2 = await wait("10ms")(controller);
353
- * result2 satisfies Result<void, AbortError>;
3299
+ * const ids: NonEmptyReadonlyArray<UserId> = [id1, id2];
3300
+ * const result = await run(map(ids, fetchUser));
3301
+ * if (!result.ok) return result;
3302
+ * // result.value: NonEmptyReadonlyArray<User>
354
3303
  * ```
355
3304
  */
356
- export const wait = (duration: Duration): Task<void, never> =>
357
- toTask(
358
- (context) =>
359
- new Promise<Result<void, never>>((resolve) => {
360
- const ms = durationToNonNegativeInt(duration);
361
- const timeoutSignal = AbortSignal.timeout(ms);
362
-
363
- const signal = combineSignal(context, timeoutSignal);
3305
+ export function map<A, T, E, D>(
3306
+ items: NonEmptyReadonlyArray<A>,
3307
+ task: (a: A) => Task<T, E, D>,
3308
+ options?: CollectOptions,
3309
+ ): Task<NonEmptyReadonlyArray<T>, E, D>;
364
3310
 
365
- // Listen for abort - either from timeout completion or external abort
366
- signal.addEventListener(
367
- "abort",
368
- () => {
369
- resolve(ok());
370
- },
371
- { once: true },
372
- );
373
- }),
3311
+ /**
3312
+ * Run for side effects only.
3313
+ *
3314
+ * ```ts
3315
+ * const result = await run(map(userIds, sendEmail, { collect: false }));
3316
+ * // result.value: void
3317
+ * ```
3318
+ */
3319
+ export function map<A, T, E, D>(
3320
+ items: Iterable<A> | Readonly<Record<string, A>>,
3321
+ task: (a: A) => Task<T, E, D>,
3322
+ options: CollectOptions<false>,
3323
+ ): Task<void, E, D>;
3324
+
3325
+ export function map<A, T, E, D>(
3326
+ items: MapInput<A>,
3327
+ fn: (a: A) => Task<T, E, D>,
3328
+ { abortReason = mapAbortError, ...options }: CollectOptions<boolean> = {},
3329
+ ): Task<ReadonlyArray<T> | Record<string, T> | void, E, D> {
3330
+ const mapped = mapInput(items, fn);
3331
+ return all(
3332
+ mapped as Iterable<Task<T, E, D>>,
3333
+ {
3334
+ ...options,
3335
+ abortReason,
3336
+ } as CollectOptions,
374
3337
  );
375
-
376
- /** Error returned when {@link timeout} exceeds the specified duration. */
377
- export interface TimeoutError {
378
- readonly type: "TimeoutError";
379
- readonly timeoutMs: number;
380
3338
  }
381
3339
 
382
3340
  /**
383
- * Adds timeout behavior to a {@link Task}.
3341
+ * Abort reason used by {@link map} when aborting remaining tasks.
3342
+ *
3343
+ * @group Composition
3344
+ */
3345
+ export const MapAbortError = /*#__PURE__*/ typed("MapAbortError");
3346
+ export interface MapAbortError extends InferType<typeof MapAbortError> {}
3347
+
3348
+ /**
3349
+ * {@link MapAbortError} used as abort reason in {@link map}.
3350
+ *
3351
+ * @group Composition
3352
+ */
3353
+ export const mapAbortError: MapAbortError = {
3354
+ type: "MapAbortError",
3355
+ };
3356
+
3357
+ /**
3358
+ * Maps values to {@link Task}s, completing all regardless of failures.
3359
+ *
3360
+ * Returns an array of {@link Result}s preserving the original order. Sequential
3361
+ * by default — use {@link concurrently} for concurrent execution.
384
3362
  *
385
3363
  * ### Example
386
3364
  *
387
3365
  * ```ts
388
- * interface FetchError {
389
- * readonly type: "FetchError";
390
- * readonly error: unknown;
391
- * }
3366
+ * const results = await run(mapSettled(userIds, fetchUser));
3367
+ * if (!results.ok) return results; // Only AbortError
392
3368
  *
393
- * // Task version of fetch with proper error handling and cancellation support.
394
- * const fetch = (url: string) =>
395
- * toTask((context) =>
396
- * tryAsync(
397
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
398
- * (error): FetchError => ({ type: "FetchError", error }),
399
- * ),
400
- * );
3369
+ * for (const result of results.value) {
3370
+ * if (result.ok) {
3371
+ * console.log("Success:", result.value);
3372
+ * } else {
3373
+ * console.log("Failed:", result.error);
3374
+ * }
3375
+ * }
3376
+ * ```
401
3377
  *
402
- * // `satisfies` shows the expected type signature.
403
- * fetch satisfies (url: string) => Task<Response, FetchError>;
3378
+ * @group Composition
3379
+ * @see {@link CollectOptions}
3380
+ */
3381
+ export function mapSettled<A, T, E, D>(
3382
+ items: Iterable<A>,
3383
+ task: (a: A) => Task<T, E, D>,
3384
+ options?: CollectOptions,
3385
+ ): Task<ReadonlyArray<Result<T, E | AbortError>>, never, D>;
3386
+
3387
+ /**
3388
+ * Returns object with same keys.
404
3389
  *
405
- * const fetchWithTimeout = (url: string) => timeout("2m", fetch(url));
3390
+ * ```ts
3391
+ * const results = await run(mapSettled({ a: 1, b: 2 }, (n) => double(n)));
3392
+ * if (!results.ok) return results;
3393
+ * // results.value: { a: Result<number, E>, b: Result<number, E> }
3394
+ * ```
3395
+ */
3396
+ export function mapSettled<A, T, E, D, K extends string>(
3397
+ items: Readonly<Record<K, A>>,
3398
+ task: (a: A) => Task<T, E, D>,
3399
+ options?: CollectOptions,
3400
+ ): Task<Readonly<Record<K, Result<T, E | AbortError>>>, never, D>;
3401
+
3402
+ /**
3403
+ * Guarantees non-empty result.
406
3404
  *
407
- * const result1 = await fetchWithTimeout("https://api.example.com/data")();
408
- * result1 satisfies Result<Response, FetchError | TimeoutError>;
3405
+ * ```ts
3406
+ * const ids: NonEmptyReadonlyArray<UserId> = [id1, id2];
3407
+ * const results = await run(mapSettled(ids, fetchUser));
3408
+ * if (!results.ok) return results;
3409
+ * // results.value: NonEmptyReadonlyArray<Result<User, FetchError | AbortError>>
3410
+ * ```
3411
+ */
3412
+ export function mapSettled<A, T, E, D>(
3413
+ items: NonEmptyReadonlyArray<A>,
3414
+ task: (a: A) => Task<T, E, D>,
3415
+ options?: CollectOptions,
3416
+ ): Task<NonEmptyReadonlyArray<Result<T, E | AbortError>>, never, D>;
3417
+
3418
+ /**
3419
+ * Run for side effects only.
409
3420
  *
410
- * // With AbortController
411
- * const controller = new AbortController();
412
- * const result2 = await fetchWithTimeout("https://api.example.com/data")(
413
- * controller,
3421
+ * ```ts
3422
+ * const result = await run(
3423
+ * mapSettled(userIds, sendEmail, { collect: false }),
414
3424
  * );
415
- * result2 satisfies Result<
416
- * Response,
417
- * FetchError | TimeoutError | AbortError
418
- * >;
3425
+ * // result.value: void
419
3426
  * ```
420
3427
  */
421
- export const timeout = <T, E>(
422
- duration: Duration,
423
- task: Task<T, E>,
424
- ): Task<T, E | TimeoutError> =>
425
- toTask(async (context) => {
426
- const timeoutMs = durationToNonNegativeInt(duration);
427
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
428
-
429
- const signal = combineSignal(context, timeoutSignal);
430
-
431
- const result = await task({ signal });
432
-
433
- if (timeoutSignal.aborted) {
434
- return err({ type: "TimeoutError", timeoutMs });
435
- }
436
-
437
- return result as Result<T, E | TimeoutError>;
438
- });
439
-
440
- /** Options for configuring {@link retry} behavior. */
441
- export interface RetryOptions<E> {
442
- /** Number of retry attempts after the initial failure. */
443
- readonly retries: PositiveInt;
444
-
445
- /**
446
- * Initial delay for exponential backoff (1st retry uses this, 2nd uses
447
- * this×factor, 3rd uses this×factor², etc.). Actual delays are randomized by
448
- * {@link RetryOptions.jitter}.
449
- */
450
- readonly initialDelay?: Duration;
451
-
452
- /** Maximum delay between retries. */
453
- readonly maxDelay?: Duration;
454
-
455
- /** Exponential backoff multiplier. */
456
- readonly factor?: number;
457
-
458
- /** Random jitter factor (0-1) to prevent thundering herd. */
459
- readonly jitter?: number;
460
-
461
- /**
462
- * Predicate to determine if error should trigger retry. Receives AbortError
463
- * too.
464
- */
465
- readonly retryable?: (error: E | AbortError) => boolean;
466
-
467
- /** Callback invoked before each retry attempt. */
468
- readonly onRetry?: (error: E, attempt: number, delay: number) => void;
469
- }
470
-
471
- /** Error returned when {@link retry} exhausts all retry attempts. */
472
- export interface RetryError<E> {
473
- readonly type: "RetryError";
474
- readonly cause: E;
475
- readonly attempts: number;
3428
+ export function mapSettled<A, T, E, D>(
3429
+ items: Iterable<A> | Readonly<Record<string, A>>,
3430
+ task: (a: A) => Task<T, E, D>,
3431
+ options: CollectOptions<false>,
3432
+ ): Task<void, never, D>;
3433
+
3434
+ export function mapSettled<A, T, E, D>(
3435
+ items: MapInput<A>,
3436
+ task: (a: A) => Task<T, E, D>,
3437
+ options?: CollectOptions<boolean>,
3438
+ ): Task<
3439
+ | ReadonlyArray<Result<T, E | AbortError>>
3440
+ | Record<string, Result<T, E | AbortError>>
3441
+ | void,
3442
+ never,
3443
+ D
3444
+ > {
3445
+ const mapped = mapInput(items, task);
3446
+ return allSettled(
3447
+ mapped as Iterable<Task<T, E, D>>,
3448
+ options as CollectOptions,
3449
+ );
476
3450
  }
477
3451
 
478
3452
  /**
479
- * Adds retry logic with exponential backoff and jitter to a {@link Task}.
3453
+ * Returns the first {@link Task} that succeeds.
3454
+ *
3455
+ * Like
3456
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any | Promise.any},
3457
+ * the first Task to succeed wins. All other Tasks are aborted. If all Tasks
3458
+ * fail, returns the last error (by input order).
3459
+ *
3460
+ * Sequential by default. Use {@link concurrently} for concurrent execution.
3461
+ *
3462
+ * Think of it like `Array.prototype.some()` — it stops on the first success.
3463
+ * This is in contrast to {@link race}, which returns the first task to complete
3464
+ * (whether success or failure).
480
3465
  *
481
3466
  * ### Example
482
3467
  *
483
3468
  * ```ts
484
- * interface FetchError {
485
- * readonly type: "FetchError";
486
- * readonly error: unknown;
487
- * }
3469
+ * // Try multiple endpoints concurrently, first success wins
3470
+ * const result = await run(
3471
+ * concurrently(
3472
+ * any([fetchFromPrimary, fetchFromSecondary, fetchFromTertiary]),
3473
+ * ),
3474
+ * );
3475
+ * ```
488
3476
  *
489
- * // Task version of fetch with proper error handling and cancellation support.
490
- * const fetch = (url: string) =>
491
- * toTask((context) =>
492
- * tryAsync(
493
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
494
- * (error): FetchError => ({ type: "FetchError", error }),
495
- * ),
496
- * );
3477
+ * @group Composition
3478
+ */
3479
+ export function any<T, E, D>(
3480
+ tasks: NonEmptyReadonlyArray<Task<T, E, D>>,
3481
+ options?: {
3482
+ /** How to choose an error if all tasks fail. */
3483
+ allFailed?: AnyAllFailed;
3484
+ },
3485
+ ): Task<T, E, D>;
3486
+
3487
+ export function any<T, E, D>(
3488
+ tasks: NonEmptyReadonlyArray<Task<T, E, D>>,
3489
+ options?: {
3490
+ allFailed?: AnyAllFailed;
3491
+ },
3492
+ ): Task<T, E, D> {
3493
+ const { allFailed = "input" } = options ?? {};
3494
+ return pool(tasks, {
3495
+ stopOn: "success",
3496
+ collect: false,
3497
+ abortReason: anyAbortError,
3498
+ allFailed,
3499
+ });
3500
+ }
3501
+
3502
+ /**
3503
+ * Tie-breaker for {@link any} when all tasks fail.
497
3504
  *
498
- * // `satisfies` shows the expected type signature.
499
- * fetch satisfies (url: string) => Task<Response, FetchError>;
3505
+ * Used only when no task succeeds.
500
3506
  *
501
- * const fetchWithRetry = (url: string) =>
502
- * retry({ retries: PositiveInt.orThrow(3) }, fetch(url));
3507
+ * - `"input"` returns the error from the last task in the input array. This is
3508
+ * stable under concurrency and generally produces deterministic tests.
3509
+ * - `"completion"` returns the error from the Task that finished last. This
3510
+ * reflects timing but can vary across runs when task timing varies.
503
3511
  *
504
- * const result1 = await fetchWithRetry("https://api.example.com/data")();
505
- * result1 satisfies Result<Response, FetchError | RetryError<FetchError>>;
3512
+ * ### Example
506
3513
  *
507
- * // With AbortController
508
- * const controller = new AbortController();
509
- * const result2 = await fetchWithRetry("https://api.example.com/data")(
510
- * controller,
3514
+ * ```ts
3515
+ * await using run = createRun();
3516
+ * const result = await run(
3517
+ * concurrently(any([a, b, c], { allFailed: "completion" })),
511
3518
  * );
512
- * result2 satisfies Result<
513
- * Response,
514
- * FetchError | RetryError<FetchError> | AbortError
515
- * >;
516
3519
  * ```
517
3520
  */
518
- export const retry = <T, E>(
519
- {
520
- retries,
521
- initialDelay = "1s",
522
- maxDelay = "30s",
523
- factor = 2,
524
- jitter = 0.5,
525
- retryable = (error: E | AbortError) => !isAbortError(error),
526
- onRetry,
527
- }: RetryOptions<E>,
528
- task: Task<T, E>,
529
- ): Task<T, E | RetryError<E>> =>
530
- toTask(async (context): Promise<Result<T, E | RetryError<E>>> => {
531
- const initialDelayMs = durationToNonNegativeInt(initialDelay);
532
- const maxDelayMs = durationToNonNegativeInt(maxDelay);
533
- const maxRetries = PositiveInt.orThrow(retries);
534
-
535
- let attempt = 0;
536
-
537
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
538
- while (true) {
539
- const result = await task(context);
540
-
541
- if (result.ok) {
542
- return result;
543
- }
544
-
545
- // Never retry on AbortError; propagate it directly
546
- if (isAbortError(result.error)) {
547
- return err(result.error) as Result<T, E | RetryError<E>>;
548
- }
549
-
550
- attempt += 1;
3521
+ export type AnyAllFailed = "input" | "completion";
551
3522
 
552
- if (attempt > maxRetries || !retryable(result.error)) {
553
- return err({
554
- type: "RetryError",
555
- cause: result.error,
556
- attempts: attempt,
557
- });
558
- }
559
-
560
- // Calculate delay with exponential backoff
561
- const exponentialDelay = initialDelayMs * Math.pow(factor, attempt - 1);
562
- const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
3523
+ /**
3524
+ * Abort reason used by {@link any} when aborting remaining tasks.
3525
+ *
3526
+ * @group Composition
3527
+ */
3528
+ export const AnyAbortError = /*#__PURE__*/ typed("AnyAbortError");
3529
+ export interface AnyAbortError extends InferType<typeof AnyAbortError> {}
563
3530
 
564
- // Apply jitter to prevent thundering herd problem
565
- const randomFactor = 1 - jitter + Math.random() * jitter * 2;
566
- const delay = Math.floor(cappedDelay * randomFactor);
3531
+ /**
3532
+ * {@link AnyAbortError} used as abort reason in {@link any}.
3533
+ *
3534
+ * @group Composition
3535
+ */
3536
+ export const anyAbortError: AnyAbortError = { type: "AnyAbortError" };
567
3537
 
568
- if (onRetry) {
569
- onRetry(result.error, attempt, delay);
570
- }
3538
+ type CollectInput =
3539
+ | Iterable<Task<unknown, unknown>>
3540
+ | Readonly<Record<string, AnyTask>>;
571
3541
 
572
- // Wait before retry
573
- {
574
- const result = await wait(NonNegativeInt.orThrow(delay))(context);
575
- if (!result.ok) {
576
- // If delay was aborted, return AbortError (will be handled by toTask)
577
- return result;
578
- }
579
- }
3542
+ /** Shared implementation for {@link all} and {@link allSettled}. */
3543
+ const collect = (
3544
+ type: "all" | "allSettled",
3545
+ input: CollectInput,
3546
+ {
3547
+ collect = true,
3548
+ abortReason = type === "all" ? allAbortError : allSettledAbortError,
3549
+ }: CollectOptions<boolean> = {},
3550
+ ): Task<unknown, unknown> => {
3551
+ const stopOn = type === "all" ? ("error" as const) : null;
3552
+
3553
+ if (isIterable(input)) {
3554
+ const array = arrayFrom(input as Iterable<unknown>);
3555
+ if (!isNonEmptyArray(array))
3556
+ return () => ok(collect ? emptyArray : undefined);
3557
+
3558
+ return pool(array as ReadonlyArray<Task<unknown, unknown>>, {
3559
+ stopOn,
3560
+ collect,
3561
+ abortReason,
3562
+ });
3563
+ }
3564
+
3565
+ const keys: Array<string> = [];
3566
+ const taskArray: Array<AnyTask> = [];
3567
+ for (const key in input) {
3568
+ keys.push(key);
3569
+ taskArray.push((input as Record<string, AnyTask>)[key]);
3570
+ }
3571
+ if (keys.length === 0) return () => ok(collect ? emptyRecord : undefined);
3572
+
3573
+ return async (run) => {
3574
+ const result = await run(pool(taskArray, { stopOn, collect, abortReason }));
3575
+ if (!result.ok) return result;
3576
+ if (!collect) return ok();
3577
+ const record = createRecord();
3578
+ for (let i = 0; i < keys.length; i++) {
3579
+ record[keys[i]] = (result.value as Array<unknown>)[i];
580
3580
  }
581
- });
3581
+ return ok(record);
3582
+ };
3583
+ };
582
3584
 
583
3585
  /**
584
- * A semaphore that limits the number of concurrent async Tasks.
3586
+ * When to stop processing Tasks in {@link pool}.
585
3587
  *
586
- * For mutual exclusion (limiting to exactly one Task), consider using
587
- * {@link Mutex} instead.
588
- *
589
- * @see {@link createSemaphore} to create a semaphore instance.
3588
+ * - `"first"` stop on first result (success or error), used by {@link race}
3589
+ * - `"error"` — stop on first error, used by {@link all} and {@link map}
3590
+ * - `"success"` — stop on first success, used by {@link any}
3591
+ * - `null` — never stop early, used by {@link allSettled} and {@link mapSettled}
590
3592
  */
591
- export interface Semaphore extends Disposable {
592
- /**
593
- * Executes a Task while holding a semaphore permit.
594
- *
595
- * The Task will wait until a permit is available before executing. Supports
596
- * cancellation via AbortSignal - if the signal is aborted while waiting for a
597
- * permit or during execution, the Task is cancelled and permits are properly
598
- * released.
599
- */
600
- readonly withPermit: <T, E>(task: Task<T, E>) => Task<T, E | AbortError>;
601
- }
3593
+ type StopOn = "first" | "error" | "success";
3594
+
3595
+ type MapInput<A> = Iterable<A> | Readonly<Record<string, A>>;
3596
+
3597
+ const mapInput = <A, T, E, D>(
3598
+ input: MapInput<A>,
3599
+ fn: (a: A) => Task<T, E, D>,
3600
+ ): ReadonlyArray<Task<T, E, D>> | Readonly<Record<string, Task<T, E, D>>> =>
3601
+ isIterable(input) ? mapArray(arrayFrom(input), fn) : mapObject(input, fn);
602
3602
 
603
3603
  /**
604
- * Creates a semaphore that limits concurrent async Tasks to the specified
605
- * count.
606
- *
607
- * A semaphore controls access to a resource by maintaining a count of available
608
- * permits. Tasks acquire a permit before executing and release it when
609
- * complete.
610
- *
611
- * For mutual exclusion (exactly one Task at a time), consider using
612
- * {@link createMutex} instead.
613
- *
614
- * ### Example
3604
+ * Worker pool respecting {@link Run.concurrency}.
615
3605
  *
616
- * ```ts
617
- * // Allow maximum 3 concurrent Tasks
618
- * const semaphore = createSemaphore(PositiveInt.orThrow(3));
619
- *
620
- * let currentConcurrent = 0;
621
- * const events: Array<string> = [];
622
- *
623
- * const fetchData = (id: number) =>
624
- * toTask<number, never>(async (context) => {
625
- * currentConcurrent++;
626
- * events.push(`start ${id} (concurrent: ${currentConcurrent})`);
3606
+ * Spawns only as many workers as allowed, avoiding idle Fibers waiting for
3607
+ * permits.
627
3608
  *
628
- * await wait("10ms")(context);
3609
+ * Workers run as daemons so callers don't block on unabortable Tasks. When
3610
+ * abort is requested, pool returns immediately. Structured concurrency is
3611
+ * preserved because the root {@link Run} still waits for all daemons.
629
3612
  *
630
- * currentConcurrent--;
631
- * events.push(`end ${id} (concurrent: ${currentConcurrent})`);
632
- * return ok(id * 10);
633
- * });
634
- *
635
- * // These will execute with at most 3 running concurrently
636
- * const results = await Promise.all([
637
- * semaphore.withPermit(fetchData(1))(),
638
- * semaphore.withPermit(fetchData(2))(),
639
- * semaphore.withPermit(fetchData(3))(),
640
- * semaphore.withPermit(fetchData(4))(), // waits for one above to complete
641
- * semaphore.withPermit(fetchData(5))(), // waits for permit
642
- * ]);
3613
+ * The `stopOn` option determines when to stop:
643
3614
  *
644
- * expect(results.map(getOrThrow)).toEqual([10, 20, 30, 40, 50]);
645
- * expect(events).toMatchInlineSnapshot(`
646
- * [
647
- * "start 1 (concurrent: 1)",
648
- * "start 2 (concurrent: 2)",
649
- * "start 3 (concurrent: 3)",
650
- * "end 1 (concurrent: 2)",
651
- * "start 4 (concurrent: 3)",
652
- * "end 2 (concurrent: 2)",
653
- * "start 5 (concurrent: 3)",
654
- * "end 3 (concurrent: 2)",
655
- * "end 4 (concurrent: 1)",
656
- * "end 5 (concurrent: 0)",
657
- * ]
658
- * `);
659
- * ```
660
- */
661
- export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => {
662
- let isDisposed = false;
663
- let availablePermits = maxConcurrent;
664
- const waitingQueue: Array<() => void> = [];
665
- const semaphoreController = new AbortController();
666
-
667
- const acquire = (): Promise<void> => {
668
- if (availablePermits > 0) {
669
- availablePermits--;
670
- return Promise.resolve();
671
- }
672
-
673
- return new Promise<void>((resolve) => {
674
- waitingQueue.push(resolve);
675
- });
676
- };
3615
+ * - `"first"` stop on any result
3616
+ * - `"error"` — stop on first error
3617
+ * - `"success"` — stop on first success
3618
+ * - `null` — never stop early
3619
+ */
3620
+ function pool<T, E, D>(
3621
+ tasks: Iterable<Task<T, E, D>>,
3622
+ options: {
3623
+ stopOn: StopOn;
3624
+ collect: true;
3625
+ abortReason: unknown;
3626
+ },
3627
+ ): Task<ReadonlyArray<T>, E, D>;
3628
+
3629
+ function pool<T, E, D>(
3630
+ tasks: Iterable<Task<T, E, D>>,
3631
+ options: {
3632
+ stopOn: StopOn;
3633
+ collect: false;
3634
+ abortReason: unknown;
3635
+ allFailed?: AnyAllFailed;
3636
+ },
3637
+ ): Task<T, E, D>;
3638
+
3639
+ function pool<T, E, D>(
3640
+ tasks: Iterable<Task<T, E, D>>,
3641
+ options: {
3642
+ stopOn: null;
3643
+ collect: true;
3644
+ abortReason: unknown;
3645
+ },
3646
+ ): Task<ReadonlyArray<Result<T, E>>, never, D>;
3647
+
3648
+ function pool<D>(
3649
+ tasks: Iterable<Task<unknown, unknown, D>>,
3650
+ options: {
3651
+ stopOn: null;
3652
+ collect: false;
3653
+ abortReason: unknown;
3654
+ },
3655
+ ): Task<void, never, D>;
3656
+
3657
+ /** Internal overload for {@link collect} with dynamic stopOn/collect. */
3658
+ function pool(
3659
+ tasks: Iterable<Task<unknown, unknown>>,
3660
+ options: {
3661
+ stopOn: StopOn | null;
3662
+ collect: boolean;
3663
+ abortReason: unknown;
3664
+ },
3665
+ ): Task<unknown, unknown>;
3666
+
3667
+ function pool<T, E>(
3668
+ tasksIterable: Iterable<AnyTask>,
3669
+ {
3670
+ stopOn = null,
3671
+ collect,
3672
+ abortReason,
3673
+ allFailed,
3674
+ }: {
3675
+ stopOn?: StopOn | null;
3676
+ collect: boolean;
3677
+ abortReason: unknown;
3678
+ allFailed?: AnyAllFailed;
3679
+ },
3680
+ ): Task<ReadonlyArray<unknown> | T | void, E> {
3681
+ const tasks = arrayFrom(tasksIterable);
3682
+ const { length } = tasks;
3683
+ if (length === 0) return () => ok(emptyArray);
3684
+
3685
+ return async (run) => {
3686
+ const results = collect ? new Array<unknown>(length) : null;
3687
+ const aborted = Promise.withResolvers<void>();
3688
+ const stopSignal = stopOn ? Promise.withResolvers<void>() : null;
3689
+
3690
+ let index = 0;
3691
+ let stopped = null as Result<T, E> | null;
3692
+ let lastResult = null as Result<T, E> | null;
3693
+ let lastIndexResult = null as Result<T, E> | null;
3694
+
3695
+ const worker: Task<void> = async (run) => {
3696
+ while (index < length && (stopOn ? !stopped : true)) {
3697
+ const i = index++;
3698
+
3699
+ const result = (await run(tasks[i])) as Result<T, E>;
3700
+ lastResult = result;
3701
+ if (i === length - 1) lastIndexResult = result;
3702
+
3703
+ if (!stopOn) {
3704
+ if (results) results[i] = result;
3705
+ continue;
3706
+ }
677
3707
 
678
- const release = (): void => {
679
- if (isNonEmptyArray(waitingQueue)) {
680
- shiftArray(waitingQueue)();
681
- } else {
682
- availablePermits++;
683
- }
684
- };
3708
+ const stop =
3709
+ stopOn === "first" ||
3710
+ (stopOn === "error" && !result.ok) ||
3711
+ (stopOn === "success" && result.ok);
685
3712
 
686
- return {
687
- withPermit: <T, E>(task: Task<T, E>): Task<T, E | AbortError> =>
688
- toTask(async (context): Promise<Result<T, E | AbortError>> => {
689
- await acquire();
690
-
691
- // Check if semaphore was disposed while waiting
692
- if (isDisposed) {
693
- return err({
694
- type: "AbortError",
695
- reason: "Semaphore disposed",
696
- });
3713
+ if (!stop) {
3714
+ if (results) results[i] = (result as Ok<T>).value;
3715
+ continue;
697
3716
  }
698
3717
 
699
- const signal = combineSignal(context, semaphoreController.signal);
3718
+ if (!stopped) {
3719
+ stopped = result;
3720
+ abortWorkers(
3721
+ !result.ok && AbortError.is(result.error)
3722
+ ? result.error.reason
3723
+ : abortReason,
3724
+ );
3725
+ stopSignal?.resolve();
3726
+ }
3727
+ break;
3728
+ }
3729
+ return ok();
3730
+ };
700
3731
 
701
- const result = await task({ signal });
3732
+ let workersAborted = false;
702
3733
 
703
- release();
3734
+ const abortWorkers = (reason: unknown) => {
3735
+ if (workersAborted) return;
3736
+ workersAborted = true;
3737
+ for (const worker of workers) worker.abort(reason);
3738
+ };
704
3739
 
705
- return result;
706
- }),
3740
+ const workerCount = Math.min(run.concurrency, length);
3741
+ const workers = arrayFrom(workerCount, () => run.daemon(worker));
707
3742
 
708
- [Symbol.dispose]: () => {
709
- if (isDisposed) return;
710
- isDisposed = true;
3743
+ using _ = new DisposableStack();
3744
+ _.defer(() => {
3745
+ abortWorkers(abortReason);
3746
+ });
711
3747
 
712
- // Cancel all running and waiting tasks
713
- semaphoreController.abort("Semaphore disposed");
3748
+ run.onAbort((reason) => {
3749
+ abortWorkers(reason);
3750
+ aborted.resolve();
3751
+ });
714
3752
 
715
- // Release all waiting tasks so they can continue and check isDisposed
716
- while (isNonEmptyArray(waitingQueue)) {
717
- shiftArray(waitingQueue)();
718
- }
719
- },
720
- };
721
- };
3753
+ const waitFor = [Promise.all(workers), aborted.promise];
3754
+ if (stopSignal) waitFor.push(stopSignal.promise);
3755
+ await Promise.race(waitFor);
722
3756
 
723
- /**
724
- * A mutex (mutual exclusion) that ensures only one Task runs at a time.
725
- *
726
- * This is a specialized version of a {@link Semaphore} with a permit count of 1.
727
- *
728
- * @see {@link createMutex} to create a mutex instance.
729
- */
730
- export interface Mutex extends Disposable {
731
- /**
732
- * Executes a Task while holding the mutex lock.
733
- *
734
- * Only one Task can hold the lock at a time. Other Tasks will wait until the
735
- * lock is released. Supports cancellation via AbortSignal.
736
- */
737
- readonly withLock: <T, E>(task: Task<T, E>) => Task<T, E | AbortError>;
738
- }
3757
+ if (run.signal.aborted) {
3758
+ return err(run.signal.reason as AbortError);
3759
+ }
739
3760
 
740
- /**
741
- * Creates a new mutex for ensuring mutual exclusion.
742
- *
743
- * A mutex is a {@link createSemaphore} with exactly one permit, ensuring that
744
- * only one Task can execute at a time.
745
- *
746
- * ### Example
747
- *
748
- * ```ts
749
- * const mutex = createMutex();
750
- *
751
- * const updateTask = (id: number) =>
752
- * toTask((context) =>
753
- * tryAsync(
754
- * () => updateSharedResource(id, context),
755
- * (error): UpdateError => ({ type: "UpdateError", error }),
756
- * ),
757
- * );
758
- *
759
- * // These Tasks will execute one at a time
760
- * const results = await Promise.all([
761
- * mutex.withLock(updateTask(1))(),
762
- * mutex.withLock(updateTask(2))(),
763
- * mutex.withLock(updateTask(3))(),
764
- * ]);
765
- * ```
766
- */
767
- export const createMutex = (): Mutex => {
768
- const mutex = createSemaphore(PositiveInt.orThrow(1));
3761
+ if (!stopOn) return results ? ok(results) : ok();
3762
+ if (stopped) return stopped;
3763
+ if (results) return ok(results);
3764
+ // For all/allSettled/map/mapSettled with collect: false (no allFailed handler)
3765
+ if (!allFailed) return ok();
769
3766
 
770
- return {
771
- withLock: mutex.withPermit,
772
- [Symbol.dispose]: mutex[Symbol.dispose],
3767
+ return allFailed === "completion" ? lastResult! : lastIndexResult!;
773
3768
  };
774
- };
3769
+ }
775
3770
 
776
3771
  /**
777
- * Schedule a task to run after all interactions (animations, gestures,
778
- * navigation) have completed.
779
- *
780
- * This uses `requestIdleCallback` when available, otherwise falls back to
781
- * `setTimeout(0)` for cross-platform compatibility.
782
- *
783
- * ### Example
784
- *
785
- * ```ts
786
- * const processDataTask: Task<void, ProcessError> = toTask(async () => {
787
- * // Heavy processing work
788
- * return ok();
789
- * });
3772
+ * Error returned when a {@link fetch} {@link Task} fails.
790
3773
  *
791
- * // Schedule the task to run when idle
792
- * void requestIdleTask(processDataTask)();
793
- * ```
3774
+ * @group Composition
794
3775
  */
795
- export const requestIdleTask = <T, E>(task: Task<T, E>): Task<T, E> =>
796
- toTask(
797
- async (context?: TaskContext) =>
798
- new Promise<Result<T, E>>((resolve) => {
799
- idleCallback(() => {
800
- void task(context).then(resolve);
801
- });
802
- }),
803
- );
804
-
805
- const idleCallback: (callback: () => void) => void =
806
- typeof globalThis.requestIdleCallback === "function"
807
- ? globalThis.requestIdleCallback
808
- : (callback) => setTimeout(callback, 0);
3776
+ export const FetchError = /*#__PURE__*/ typed("FetchError", { error: Unknown });
3777
+ export interface FetchError extends InferType<typeof FetchError> {}
809
3778
 
810
3779
  /**
811
- * Represents a value that can be either synchronous or asynchronous.
3780
+ * Creates a {@link Task} that wraps the native
3781
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}.
812
3782
  *
813
- * This type is useful for functions that may complete synchronously or
814
- * asynchronously depending on runtime conditions (e.g., cache hit vs network
815
- * fetch).
816
- *
817
- * ### Why MaybeAsync?
818
- *
819
- * When a function can be sync or async, the typical approaches are:
820
- *
821
- * 1. **Always return Promise** - Simple but forces microtask overhead even for
822
- * sync values (see "await always adds microtask" test in Task.test.ts)
823
- * 2. **Use callbacks** - Can avoid microtask, but calling code must still `await`
824
- * for sane composition, which adds microtask anyway
825
- * 3. **Return `T | PromiseLike<T>`** - Calling code can check the value and only
826
- * `await` when needed, avoiding microtask overhead for sync cases
827
- *
828
- * The third approach (MaybeAsync) provides:
829
- *
830
- * - **Performance**: No microtask overhead for synchronous operations
831
- * - **Reliability**: No interleaving via microtask queue when operations are
832
- * _synchronous_, reducing need for mutexes to protect shared state
3783
+ * Handles cross-browser abort behavior WebKit throws a `DOMException` with
3784
+ * message "Fetch is aborted" instead of propagating `signal.reason`. This
3785
+ * helper normalizes the behavior to always return {@link AbortError}.
833
3786
  *
834
3787
  * ### Example
835
3788
  *
836
3789
  * ```ts
837
- * // Function that may be sync or async
838
- * const getData = (id: string): MaybeAsync<Data> => {
839
- * const cached = cache.get(id);
840
- * if (cached) return cached; // Sync path
841
- * return fetchData(id); // Async path
842
- * };
843
- *
844
- * // Caller can optimize based on actual behavior
845
- * const result = getData(id);
846
- * const data = isAsync(result) ? await result : result;
847
- * ```
3790
+ * await using run = createRun();
848
3791
  *
849
- * ### Alternative Approaches
3792
+ * const result = await run(fetch("https://api.example.com/users"));
850
3793
  *
851
- * It's possible to eliminate the sync/async distinction using complex
852
- * frameworks with custom schedulers. However, such frameworks require depending
853
- * on other people's code that controls how your code executes, resulting in
854
- * more complex stack traces and debugging experiences. With MaybeAsync, we
855
- * don't need that machinery - it works directly with JavaScript's native
856
- * primitives and TypeScript's type system.
857
- *
858
- * ### TODO: Consider
859
- *
860
- * Use MaybeAsync in Task and Task helpers to preserve synchronous execution
861
- * when possible (e.g., mutex with available permit, retry on first success).
862
- */
863
- export type MaybeAsync<T> = T | PromiseLike<T>;
864
-
865
- /**
866
- * Type guard to check if a {@link MaybeAsync} value is async (a promise).
867
- *
868
- * This function narrows the type of a {@link MaybeAsync} value, allowing you to
869
- * conditionally `await` only when necessary.
870
- *
871
- * ### Example
872
- *
873
- * ```ts
874
- * const getData = (id: string): MaybeAsync<Data> => {
875
- * const cached = cache.get(id);
876
- * if (cached) return cached; // Sync path
877
- * return fetchData(id); // Async path
878
- * };
3794
+ * if (!result.ok) {
3795
+ * // Handle FetchError or AbortError
3796
+ * }
879
3797
  *
880
- * const result = getData(id);
881
- * const data = isAsync(result) ? await result : result;
882
- * // No microtask overhead when cached!
3798
+ * // Compose with timeout and retry
3799
+ * const fetchWithRetry = (url: string) =>
3800
+ * retry(timeout(fetch(url), "10s"), retryStrategyAws);
883
3801
  * ```
3802
+ *
3803
+ * @group Composition
884
3804
  */
885
- export const isAsync = <T>(
886
- value: MaybeAsync<T>,
887
- ): value is T extends PromiseLike<unknown> ? never : PromiseLike<T> =>
888
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
889
- typeof (value as any)?.then === "function";
890
-
891
- // TODO: Add tracing support
892
- // - Extend TaskContext with optional tracing field
893
- // - Add traced(name, task) helper that wraps Task execution
894
- // - Collect span data (name, timing, parent-child relationships, status)
895
- // - Support OpenTelemetry export format with proper traceId/spanId generation
896
- // - Automatic parent-child span relationships through context propagation
3805
+ export const fetch =
3806
+ (input: RequestInfo | URL, init?: RequestInit): Task<Response, FetchError> =>
3807
+ ({ signal }) =>
3808
+ tryAsync(
3809
+ () => globalThis.fetch(input, { ...init, signal }),
3810
+ (error): FetchError | AbortError => {
3811
+ if (AbortError.is(error)) return error;
3812
+ // WebKit throws DOMException with message "Fetch is aborted" instead of
3813
+ // propagating signal.reason. Detect this and create AbortError.
3814
+ if (
3815
+ signal.aborted &&
3816
+ error instanceof Error &&
3817
+ error.message === "Fetch is aborted"
3818
+ ) {
3819
+ return createAbortError(signal.reason);
3820
+ }
3821
+ return { type: "FetchError", error };
3822
+ },
3823
+ );
3824
+
3825
+ // TODO: Prioritized Task Scheduling API integration
3826
+ // https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
3827
+ //
3828
+ // - `run(task, { priority })` - Fiber-level priority via TaskController
3829
+ // (extends AbortController), so the fiber's signal gets priority and all
3830
+ // nested work inherits it.
3831
+ // - `fiber.setPriority(priority)` - Dynamic priority changes mid-flight via
3832
+ // TaskController.setPriority().
3833
+ //
3834
+ // Note: scheduler.yield() inherits priority from enclosing postTask, so
3835
+ // yieldNow doesn't need a priority argument — just run the fiber at priority.
3836
+ //
3837
+ // Safari doesn't support it yet, Node.js probably never will (use setImmediate).
3838
+ // For Safari, scheduler-polyfill can be used.
3839
+ // https://www.npmjs.com/package/scheduler-polyfill
3840
+
3841
+ // TODO: Do we really need specialized aborts?