@evolu/common 8.0.0-next.5 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/dist/src/Array.d.ts +279 -173
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +105 -101
  4. package/dist/src/Assert.d.ts +39 -44
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +38 -31
  7. package/dist/src/BigInt.d.ts +6 -3
  8. package/dist/src/BigInt.d.ts.map +1 -1
  9. package/dist/src/BigInt.js +5 -2
  10. package/dist/src/Brand.d.ts +27 -40
  11. package/dist/src/Brand.d.ts.map +1 -1
  12. package/dist/src/Buffer.d.ts +27 -11
  13. package/dist/src/Buffer.d.ts.map +1 -1
  14. package/dist/src/Cache.d.ts +9 -4
  15. package/dist/src/Cache.d.ts.map +1 -1
  16. package/dist/src/Cache.js +8 -3
  17. package/dist/src/Callbacks.d.ts +30 -19
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Console.d.ts +145 -66
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +91 -43
  22. package/dist/src/Crypto.d.ts +85 -42
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +44 -20
  25. package/dist/src/Eq.d.ts +87 -21
  26. package/dist/src/Eq.d.ts.map +1 -1
  27. package/dist/src/Eq.js +67 -19
  28. package/dist/src/Error.d.ts +3 -3
  29. package/dist/src/Error.d.ts.map +1 -1
  30. package/dist/src/Function.d.ts +119 -58
  31. package/dist/src/Function.d.ts.map +1 -1
  32. package/dist/src/Function.js +86 -43
  33. package/dist/src/Http.d.ts +394 -0
  34. package/dist/src/Http.d.ts.map +1 -0
  35. package/dist/src/Http.js +102 -0
  36. package/dist/src/Identicon.d.ts +16 -7
  37. package/dist/src/Identicon.d.ts.map +1 -1
  38. package/dist/src/Identicon.js +14 -5
  39. package/dist/src/LeakDetector.d.ts +88 -0
  40. package/dist/src/LeakDetector.d.ts.map +1 -0
  41. package/dist/src/LeakDetector.js +86 -0
  42. package/dist/src/LockManager.d.ts +18 -9
  43. package/dist/src/LockManager.d.ts.map +1 -1
  44. package/dist/src/LockManager.js +7 -8
  45. package/dist/src/Lookup.d.ts +43 -13
  46. package/dist/src/Lookup.d.ts.map +1 -1
  47. package/dist/src/Lookup.js +28 -8
  48. package/dist/src/Microtask.d.ts +1 -1
  49. package/dist/src/Microtask.d.ts.map +1 -1
  50. package/dist/src/Microtask.js +3 -7
  51. package/dist/src/Number.d.ts +49 -13
  52. package/dist/src/Number.d.ts.map +1 -1
  53. package/dist/src/Number.js +34 -5
  54. package/dist/src/Object.d.ts +160 -40
  55. package/dist/src/Object.d.ts.map +1 -1
  56. package/dist/src/Object.js +92 -46
  57. package/dist/src/Option.d.ts +10 -7
  58. package/dist/src/Option.d.ts.map +1 -1
  59. package/dist/src/Option.js +9 -6
  60. package/dist/src/Order.d.ts +21 -21
  61. package/dist/src/Order.d.ts.map +1 -1
  62. package/dist/src/Order.js +18 -21
  63. package/dist/src/Platform.d.ts +31 -0
  64. package/dist/src/Platform.d.ts.map +1 -1
  65. package/dist/src/Platform.js +98 -0
  66. package/dist/src/Random.d.ts +32 -17
  67. package/dist/src/Random.d.ts.map +1 -1
  68. package/dist/src/Random.js +12 -8
  69. package/dist/src/Redacted.d.ts +43 -29
  70. package/dist/src/Redacted.d.ts.map +1 -1
  71. package/dist/src/Redacted.js +14 -3
  72. package/dist/src/Ref.d.ts +11 -3
  73. package/dist/src/Ref.d.ts.map +1 -1
  74. package/dist/src/Ref.js +2 -2
  75. package/dist/src/RefCount.d.ts +2 -6
  76. package/dist/src/RefCount.d.ts.map +1 -1
  77. package/dist/src/RefCount.js +33 -90
  78. package/dist/src/Relation.d.ts +70 -5
  79. package/dist/src/Relation.d.ts.map +1 -1
  80. package/dist/src/Relation.js +77 -2
  81. package/dist/src/Resource.d.ts +590 -155
  82. package/dist/src/Resource.d.ts.map +1 -1
  83. package/dist/src/Resource.js +592 -343
  84. package/dist/src/Result.d.ts +838 -191
  85. package/dist/src/Result.d.ts.map +1 -1
  86. package/dist/src/Result.js +163 -102
  87. package/dist/src/Schedule.d.ts +684 -231
  88. package/dist/src/Schedule.d.ts.map +1 -1
  89. package/dist/src/Schedule.js +766 -295
  90. package/dist/src/Set.d.ts +89 -40
  91. package/dist/src/Set.d.ts.map +1 -1
  92. package/dist/src/Set.js +50 -29
  93. package/dist/src/Sqlite.d.ts +29 -72
  94. package/dist/src/Sqlite.d.ts.map +1 -1
  95. package/dist/src/Sqlite.js +39 -45
  96. package/dist/src/StackTrace.d.ts +40 -0
  97. package/dist/src/StackTrace.d.ts.map +1 -0
  98. package/dist/src/StackTrace.js +140 -0
  99. package/dist/src/Store.d.ts +5 -4
  100. package/dist/src/Store.d.ts.map +1 -1
  101. package/dist/src/Store.js +5 -4
  102. package/dist/src/String.d.ts +7 -1
  103. package/dist/src/String.d.ts.map +1 -1
  104. package/dist/src/String.js +20 -7
  105. package/dist/src/Task.d.ts +3428 -1629
  106. package/dist/src/Task.d.ts.map +1 -1
  107. package/dist/src/Task.js +1876 -1279
  108. package/dist/src/Test.d.ts +17 -124
  109. package/dist/src/Test.d.ts.map +1 -1
  110. package/dist/src/Test.js +17 -68
  111. package/dist/src/Time.d.ts +108 -49
  112. package/dist/src/Time.d.ts.map +1 -1
  113. package/dist/src/Time.js +172 -73
  114. package/dist/src/Type.d.ts +3542 -1926
  115. package/dist/src/Type.d.ts.map +1 -1
  116. package/dist/src/Type.js +3070 -1784
  117. package/dist/src/Types.d.ts +200 -100
  118. package/dist/src/Types.d.ts.map +1 -1
  119. package/dist/src/Types.js +39 -8
  120. package/dist/src/WebSocket.d.ts +48 -23
  121. package/dist/src/WebSocket.d.ts.map +1 -1
  122. package/dist/src/WebSocket.js +15 -6
  123. package/dist/src/Worker.d.ts +94 -36
  124. package/dist/src/Worker.d.ts.map +1 -1
  125. package/dist/src/Worker.js +58 -24
  126. package/dist/src/index.d.ts +55 -52
  127. package/dist/src/index.d.ts.map +1 -1
  128. package/dist/src/index.js +3 -1
  129. package/dist/src/intl/cs.d.ts +94 -0
  130. package/dist/src/intl/cs.d.ts.map +1 -0
  131. package/dist/src/intl/cs.js +220 -0
  132. package/dist/src/intl/index.d.ts +7 -0
  133. package/dist/src/intl/index.d.ts.map +1 -0
  134. package/dist/src/intl/index.js +6 -0
  135. package/dist/src/local-first/Db.d.ts +12 -8
  136. package/dist/src/local-first/Db.d.ts.map +1 -1
  137. package/dist/src/local-first/Db.js +107 -130
  138. package/dist/src/local-first/Error.d.ts +4 -4
  139. package/dist/src/local-first/Evolu.d.ts +338 -92
  140. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  141. package/dist/src/local-first/Evolu.js +47 -55
  142. package/dist/src/local-first/LocalAuth.d.ts +4 -4
  143. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  144. package/dist/src/local-first/Owner.d.ts +61 -24
  145. package/dist/src/local-first/Owner.d.ts.map +1 -1
  146. package/dist/src/local-first/Owner.js +45 -8
  147. package/dist/src/local-first/Protocol.d.ts +44 -31
  148. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  149. package/dist/src/local-first/Protocol.js +36 -13
  150. package/dist/src/local-first/Query.d.ts +147 -62
  151. package/dist/src/local-first/Query.d.ts.map +1 -1
  152. package/dist/src/local-first/Query.js +112 -58
  153. package/dist/src/local-first/Relay.d.ts +44 -14
  154. package/dist/src/local-first/Relay.d.ts.map +1 -1
  155. package/dist/src/local-first/Schema.d.ts +92 -41
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +25 -8
  158. package/dist/src/local-first/Shared.d.ts +23 -21
  159. package/dist/src/local-first/Shared.d.ts.map +1 -1
  160. package/dist/src/local-first/Shared.js +85 -42
  161. package/dist/src/local-first/Storage.d.ts +53 -32
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +194 -234
  164. package/dist/src/local-first/Timestamp.d.ts +21 -21
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +2 -2
  167. package/dist/src/local-first/index.d.ts +11 -11
  168. package/package.json +14 -13
  169. package/src/Array.ts +306 -205
  170. package/src/Assert.ts +48 -68
  171. package/src/BigInt.ts +6 -3
  172. package/src/Brand.ts +27 -40
  173. package/src/Buffer.ts +28 -12
  174. package/src/Cache.ts +9 -4
  175. package/src/Callbacks.ts +30 -19
  176. package/src/Console.ts +156 -84
  177. package/src/Crypto.ts +83 -41
  178. package/src/Eq.ts +95 -22
  179. package/src/Error.ts +5 -2
  180. package/src/Function.ts +167 -59
  181. package/src/Http.ts +549 -0
  182. package/src/Identicon.ts +16 -7
  183. package/src/LeakDetector.ts +182 -0
  184. package/src/LockManager.ts +43 -39
  185. package/src/Lookup.ts +62 -28
  186. package/src/Microtask.ts +19 -23
  187. package/src/Number.ts +64 -12
  188. package/src/Object.ts +173 -49
  189. package/src/Option.ts +10 -7
  190. package/src/Order.ts +23 -21
  191. package/src/Platform.ts +136 -0
  192. package/src/Polyfills.ts +6 -12
  193. package/src/Random.ts +32 -19
  194. package/src/Redacted.ts +44 -30
  195. package/src/Ref.ts +14 -6
  196. package/src/RefCount.ts +38 -80
  197. package/src/Relation.ts +202 -10
  198. package/src/Resource.ts +1199 -591
  199. package/src/Result.ts +930 -271
  200. package/src/Schedule.ts +859 -343
  201. package/src/Set.ts +89 -40
  202. package/src/Sqlite.ts +142 -137
  203. package/src/StackTrace.ts +230 -0
  204. package/src/Store.ts +8 -7
  205. package/src/String.ts +22 -5
  206. package/src/Task.ts +5109 -2845
  207. package/src/Test.ts +20 -167
  208. package/src/Time.ts +293 -90
  209. package/src/Type.ts +10034 -4117
  210. package/src/Types.ts +227 -101
  211. package/src/WebSocket.ts +64 -36
  212. package/src/Worker.ts +180 -72
  213. package/src/index.ts +55 -52
  214. package/src/intl/cs.ts +388 -0
  215. package/src/intl/index.ts +7 -0
  216. package/src/local-first/Db.ts +161 -190
  217. package/src/local-first/Error.ts +4 -4
  218. package/src/local-first/Evolu.ts +495 -250
  219. package/src/local-first/LocalAuth.ts +6 -12
  220. package/src/local-first/Owner.ts +58 -21
  221. package/src/local-first/Protocol.ts +49 -31
  222. package/src/local-first/Query.ts +146 -61
  223. package/src/local-first/Relay.ts +51 -21
  224. package/src/local-first/Schema.ts +109 -51
  225. package/src/local-first/Shared.ts +253 -214
  226. package/src/local-first/Storage.ts +266 -264
  227. package/src/local-first/Timestamp.ts +25 -18
  228. package/src/local-first/index.ts +11 -11
  229. package/dist/src/Skiplist.d.ts +0 -28
  230. package/dist/src/Skiplist.d.ts.map +0 -1
  231. package/dist/src/Skiplist.js +0 -64
  232. package/dist/src/Tracer.d.ts +0 -48
  233. package/dist/src/Tracer.d.ts.map +0 -1
  234. package/dist/src/Tracer.js +0 -6
  235. package/src/Skiplist.ts +0 -108
  236. package/src/Tracer.ts +0 -54
package/dist/src/Task.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * JavaScript-native structured concurrency.
3
- *
4
- * @module
5
- */
6
1
  var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
7
2
  if (value !== null && value !== void 0) {
8
3
  if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
@@ -55,1268 +50,2118 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
55
50
  var e = new Error(message);
56
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
57
52
  });
58
- import { arrayFrom, emptyArray, isNonEmptyArray, mapArray, } from "./Array.js";
59
- import { assert, assertNotDisposed } from "./Assert.js";
60
- import { createConsole } from "./Console.js";
61
- import { createRandomBytes } from "./Crypto.js";
53
+ /**
54
+ * JavaScript-native structured concurrency.
55
+ *
56
+ * @module
57
+ */
58
+ import { emptyArray, isNonEmptyArray, mapArray, } from "./Array.js";
59
+ import { assert, assertNonEmptyReadonlyArray, assertNonNullable, assertNotDisposed, } from "./Assert.js";
60
+ import { createConsole, testCreateConsole, } from "./Console.js";
61
+ import { createRandomBytes, testCreateRandomBytes, } from "./Crypto.js";
62
62
  import { eqArrayStrict } from "./Eq.js";
63
- import { identity, lazyTrue, lazyVoid } from "./Function.js";
63
+ import { constTrue, constVoid, identity } from "./Function.js";
64
+ import { createLeakDetector, noopLeakDetector, testCreateLeakDetector, } from "./LeakDetector.js";
64
65
  import { createLookupMap } from "./Lookup.js";
65
- import { decrement, increment } from "./Number.js";
66
- import { createRecord, emptyRecord, isFunction, isIterable, mapObject, } from "./Object.js";
67
- import { createRandom } from "./Random.js";
66
+ import { decrement, increment, incrementPositiveInt } from "./Number.js";
67
+ import { emptyRecord, mapObject, objectFromEntries, objectToEntries, } from "./Object.js";
68
+ import { none, some } from "./Option.js";
69
+ import { isDev } from "./Platform.js";
70
+ import { createRandom, testCreateRandom, testCreateRandomLib, } from "./Random.js";
68
71
  import { createRef } from "./Ref.js";
69
- import { err, getOrThrow, ok, tryAsync } from "./Result.js";
70
- import { addToSet, deleteFromSet, emptySet } from "./Set.js";
71
- import { createTime, durationToMillis, Millis } from "./Time.js";
72
- import { brand, createId, Id, maxPositiveInt, NonNegativeInt, object, onePositiveInt, PositiveInt, typed, union, Unknown, UnknownResult, zeroNonNegativeInt, } from "./Type.js";
73
- import {} from "./Types.js";
72
+ import { err, getOk, getOrThrow, ok, } from "./Result.js";
73
+ import { emptySet } from "./Set.js";
74
+ import { createTime, PositiveMillis, testCreateTime, } from "./Time.js";
75
+ import { assertType, createId, maxPositiveInt, NonNegativeInt, object, onePositiveInt, PositiveInt, record, String, typed, Unknown, UnknownResult, } from "./Type.js";
74
76
  /**
75
- * Error returned when a {@link Task} is aborted via
76
- * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal}.
77
- *
78
- * This is structured-concurrency control flow, not a domain error. It plays a
79
- * role similar to an interrupt: in most code, propagate it unchanged or ignore
80
- * it when cleanup is already handled by the runtime.
81
- *
82
- * The `reason` field is `unknown` by design — use typed errors for business
83
- * logic. If you need to inspect the reason, use type guards like
84
- * `RaceLostError.is(reason)`.
77
+ * Typed object explaining why a {@link Run} was aborted.
85
78
  *
86
- * When a piece of logic must continue once started, wrap it with
87
- * {@link unabortable}. That prevents ordinary mid-flight interruption, but it
88
- * does not erase {@link AbortError} from the type because the Task can still be
89
- * rejected before it starts, for example on an already-stopped {@link Run}. In
90
- * those cases, abort usually indicates a lifecycle bug, so use
91
- * `assertNotAborted` if you want to fail fast.
79
+ * A reason has a `type` discriminant and optional structured data, so abort
80
+ * causes can carry typed domain data. Well-known reasons are
81
+ * {@link explicitAbortReason}, {@link runDisposedAbortReason}, and
82
+ * {@link PanicAbortReason}.
92
83
  *
93
- * @group Core Types
84
+ * @group Core
94
85
  */
95
- export const AbortError = /*#__PURE__*/ typed("AbortError", {
96
- reason: Unknown,
97
- });
86
+ export const AbortReason = /*#__PURE__*/ object({ type: String },
87
+ /*#__PURE__*/ record(String, Unknown));
98
88
  /**
99
- * Abort mask depth for a {@link Run} or {@link Fiber}.
89
+ * Typed object representing structured-concurrency abort control flow.
100
90
  *
101
- * - `0` abortable (default)
102
- * - `>= 1` inside {@link unabortable}, abort requests are ignored
91
+ * AbortError is thrown to stop Task execution when a Run observes an abort
92
+ * request. AbortableFiber catches AbortError and returns it as a {@link Result}
93
+ * error, so abort can be handled as an ordinary Task outcome.
103
94
  *
104
- * The mask tracks nested unabortable regions. When abort is requested, the
105
- * signal only propagates if `mask === 0`.
95
+ * The reason explains why the Run was aborted. It can be an explicit abort
96
+ * reason, {@link runDisposedAbortReason} for normal Run cleanup, or
97
+ * {@link PanicAbortReason} when a defect panicked the Run tree. The original
98
+ * defect is available as `panicAbortReason.defect` for diagnostics.
106
99
  *
107
- * - {@link unabortable} increments the mask Task becomes protected
108
- * - {@link unabortableMask} provides `restore` to restore the previous mask
109
- * - Tasks inherit their parent's mask by default
100
+ * AbortError is reserved for Task abort control flow. Do not throw or reject
101
+ * with AbortError for domain errors; return a Result error instead.
110
102
  *
111
- * This enables nested resource lifecycle patterns where each level can have its
112
- * own abortable section while outer acquisitions remain protected.
103
+ * Helpers that abort their own child Tasks should catch or normalize AbortError
104
+ * before it escapes the helper boundary. The reason carries typed domain data.
113
105
  *
114
- * UI/debugging tools can use this to visually distinguish protected Tasks
115
- * (e.g., different icon or color) and explain why abort requests are ignored.
106
+ * WebKit fetch rejects with its own abort error instead of `signal.reason`.
107
+ * Native wrappers should treat `signal.reason` as the source of truth and
108
+ * normalize aborts to AbortError.
116
109
  *
117
- * @group Abort masking
110
+ * @group Core
118
111
  */
119
- export const AbortMask = /*#__PURE__*/ brand("AbortMask", NonNegativeInt);
112
+ export const AbortError = /*#__PURE__*/ typed("AbortError", {
113
+ reason: AbortReason,
114
+ });
120
115
  /**
121
- * {@link RunSnapshot} state Type.
116
+ * Creates an {@link AbortError} from an {@link AbortReason}.
122
117
  *
123
- * @group Monitoring
118
+ * @group Core
124
119
  */
125
- export const RunSnapshotState = /*#__PURE__*/ union(
126
- /*#__PURE__*/ typed("Running"),
127
- /*#__PURE__*/ typed("Disposing"),
128
- /*#__PURE__*/ typed("Settled", {
129
- result: UnknownResult,
130
- outcome: UnknownResult,
131
- }));
120
+ export const createAbortError = (reason) => ({
121
+ type: "AbortError",
122
+ reason,
123
+ });
132
124
  /**
133
- * The event-specific payload of a {@link RunEvent}.
125
+ * Shared abort reason used for ordinary {@link Run} cleanup.
126
+ *
127
+ * Disposal requests abort so child Tasks stop while the Run waits for them to
128
+ * settle. This reason distinguishes that cleanup path from explicit abort and
129
+ * {@link PanicAbortReason}.
134
130
  *
135
- * @group Monitoring
131
+ * @group Run
136
132
  */
137
- export const RunEventData = /*#__PURE__*/ union(
138
- /*#__PURE__*/ typed("ChildAdded", { childId: Id }),
139
- /*#__PURE__*/ typed("ChildRemoved", { childId: Id }),
140
- /*#__PURE__*/ typed("StateChanged", { state: RunSnapshotState }));
133
+ export const runDisposedAbortReason = {
134
+ type: "RunDisposedAbortReason",
135
+ };
136
+ const runDisposedAbortError = createAbortError(runDisposedAbortReason);
141
137
  /**
142
- * Events emitted by a {@link Run} for monitoring and debugging.
143
- *
144
- * Events bubble up through parent runs, enabling centralized monitoring at the
145
- * root. Use with {@link Run.onEvent} to track Run lifecycle.
138
+ * Shared abort reason used when callers explicitly request abort without a more
139
+ * specific reason.
146
140
  *
147
- * @group Monitoring
141
+ * @group Run
148
142
  */
149
- export const RunEvent = /*#__PURE__*/ object({
150
- id: Id,
151
- timestamp: Millis,
152
- data: RunEventData,
153
- });
154
- // TODO:
155
- // Partial<TracerConfigDep> & // TODO:
156
- // Partial<TracerDep>; // TODO:
157
- const runDefaultDeps = {
158
- console: createConsole(),
159
- randomBytes: createRandomBytes(),
160
- random: createRandom(),
161
- time: createTime(),
143
+ export const explicitAbortReason = {
144
+ type: "ExplicitAbortReason",
162
145
  };
163
146
  /**
164
- * Creates root {@link Run}.
165
- *
166
- * The root Run is also the daemon Run: it stays running until disposed. Child
167
- * Runs created by `run(task)` are disposed by their parent once they settle.
168
- *
169
- * Call once per entry point (main thread, worker, etc.) and dispose on
170
- * shutdown. All Tasks run as descendants of this root Run.
171
- *
172
- * This common {@link createRun} is platform-agnostic. At application entry
173
- * points, prefer the platform adapter when one exists. `@evolu/web` adds
174
- * browser `error` and `unhandledrejection` handlers, `@evolu/nodejs` adds
175
- * Node.js `uncaughtException`, `unhandledRejection`, and graceful shutdown
176
- * handling, and `@evolu/react-native` adds React Native global error handling.
177
- *
178
- * {@link RunDefaultDeps} provides default dependencies:
179
- *
180
- * - {@link Time}
181
- * - {@link Console}
182
- * - {@link Random}
183
- * - {@link RandomBytes}
184
- *
185
- * ### Example
186
- *
187
- * ```ts
188
- * // App entry point
189
- * await using run = createRun();
190
- *
191
- * const result = await run(fetchData);
192
- * ```
193
- *
194
- * ### Example with custom dependencies
147
+ * Shared abort reason for tests that need a non-production abort reason.
195
148
  *
196
- * ```ts
197
- * // Define dependency interfaces
198
- * interface Config {
199
- * readonly apiUrl: string;
200
- * }
149
+ * @group Run
150
+ */
151
+ export const testAbortReason = {
152
+ type: "TestAbortReason",
153
+ };
154
+ /**
155
+ * Shared {@link AbortError} for tests, created from {@link testAbortReason}.
201
156
  *
202
- * interface ConfigDep {
203
- * readonly config: Config;
204
- * }
157
+ * @group Run
158
+ */
159
+ export const testAbortError = /*#__PURE__*/ createAbortError(testAbortReason);
160
+ /**
161
+ * Creates a {@link PanicAbortReason} from a defect.
205
162
  *
206
- * // Task declares its dependencies via the D type parameter
207
- * const fetchUser =
208
- * (id: string): Task<User, FetchError, ConfigDep> =>
209
- * async (run) => {
210
- * const { config } = run.deps;
211
- * const response = await fetch(`${config.apiUrl}/users/${id}`);
212
- * // ...
213
- * };
163
+ * @group Core
164
+ */
165
+ export const createPanicAbortReason = (defect) => ({
166
+ type: "PanicAbortReason",
167
+ defect,
168
+ });
169
+ /**
170
+ * Default {@link ReportDefect} for platform-independent {@link createRun}.
214
171
  *
215
- * // Composition root: create a Run with custom deps
216
- * type AppDeps = RunDefaultDeps & ConfigDep;
172
+ * Throws the reported defect from a queued microtask. This is a fallback for
173
+ * platforms without native global error reporting. Browser adapters use the
174
+ * native
175
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/reportError | reportError}
176
+ * API; other platform adapters can use platform-specific reporting and preserve
177
+ * nested panic defects as cause or detail data.
217
178
  *
218
- * const appDeps: AppDeps = {
219
- * ...testCreateDeps(), // or spread individual deps
220
- * config: { apiUrl: "https://api.example.com" },
221
- * };
179
+ * @group Run
180
+ */
181
+ export const reportDefectAfterMicrotask = (defect) => {
182
+ queueMicrotask(() => {
183
+ throw defect;
184
+ });
185
+ };
186
+ /**
187
+ * Creates {@link RunDefaultDeps}.
222
188
  *
223
- * await using run = createRun(appDeps);
189
+ * @group Run
190
+ */
191
+ export const createRunDefaultDeps = () => {
192
+ const console = createConsole();
193
+ return {
194
+ console,
195
+ leakDetector: isDev ? createLeakDetector({ console }) : noopLeakDetector,
196
+ nativeFetch: globalThis.fetch.bind(globalThis),
197
+ randomBytes: createRandomBytes(),
198
+ random: createRandom(),
199
+ reportDefect: reportDefectAfterMicrotask,
200
+ time: createTime(),
201
+ };
202
+ };
203
+ /**
204
+ * Creates a root {@link DisposableRun}.
224
205
  *
225
- * // Run type is inferred from the deps argument
226
- * const result = await run(fetchUser("123"));
206
+ * @group Run
207
+ */
208
+ export const createRun = (deps) => createRunInternal({
209
+ ...createRunDefaultDeps(),
210
+ ...deps,
211
+ });
212
+ /**
213
+ * Creates {@link TestReportDefect}.
227
214
  *
228
- * // TypeScript catches missing deps at compile time:
229
- * // await using run2 = createRun(); // Run<RunDefaultDeps>
230
- * // run2(fetchUser("123")); // Error: Property 'config' is missing
231
- * ```
215
+ * @group Testing
216
+ */
217
+ export const testCreateReportDefect = () => {
218
+ const defects = [];
219
+ const nextWaiters = [];
220
+ let nextIndex = 0;
221
+ const next = () => {
222
+ if (nextIndex < defects.length) {
223
+ const defect = defects[nextIndex];
224
+ nextIndex += 1;
225
+ return Promise.resolve(defect);
226
+ }
227
+ const nextDefect = Promise.withResolvers();
228
+ nextWaiters.push(nextDefect.resolve);
229
+ return nextDefect.promise;
230
+ };
231
+ const getDefects = () => [...defects];
232
+ const getDefectsSnapshot = () => {
233
+ assert(nextWaiters.length === 0, "getDefectsSnapshot must not be called while reportDefect.next() is pending");
234
+ const snapshot = [...defects];
235
+ defects.length = 0;
236
+ nextIndex = 0;
237
+ return snapshot;
238
+ };
239
+ const clearDefects = () => {
240
+ assert(nextWaiters.length === 0, "clearDefects must not be called while reportDefect.next() is pending");
241
+ defects.length = 0;
242
+ nextIndex = 0;
243
+ };
244
+ return Object.assign((defect) => {
245
+ defects.push(defect);
246
+ const resolveNext = nextWaiters.shift();
247
+ if (!resolveNext)
248
+ return;
249
+ nextIndex += 1;
250
+ resolveNext(defect);
251
+ }, { getDefects, next, getDefectsSnapshot, clearDefects });
252
+ };
253
+ /**
254
+ * Creates {@link TestRunDefaultDeps}.
232
255
  *
233
- * @group Creating Run
256
+ * @group Testing
234
257
  */
235
- export const createRun = (deps) => createRunInternal({ ...runDefaultDeps, ...deps });
236
- const createRunInternal = (deps, parent, daemon, abortBehavior, concurrencyBehavior) => {
237
- const parentMask = parent?.abortMask ?? isAbortable;
238
- let abortMask;
239
- switch (abortBehavior) {
240
- case undefined:
241
- abortMask = parentMask;
242
- break;
243
- case "unabortable":
244
- abortMask = increment(parentMask);
245
- break;
246
- default:
247
- assert(abortBehavior <= parentMask, "restore used outside its unabortableMask");
248
- abortMask = abortBehavior;
258
+ export const testCreateDeps = (options) => {
259
+ const seed = options?.seed ?? "evolu";
260
+ const console = testCreateConsole();
261
+ const leakDetector = testCreateLeakDetector({ console });
262
+ const random = testCreateRandom(seed);
263
+ const randomLib = testCreateRandomLib(seed);
264
+ const randomBytes = testCreateRandomBytes({ randomLib });
265
+ const nativeFetch = () => {
266
+ throw new Error("Provide a nativeFetch test double");
267
+ };
268
+ const reportDefect = testCreateReportDefect();
269
+ const time = testCreateTime();
270
+ return {
271
+ console,
272
+ leakDetector,
273
+ nativeFetch,
274
+ randomBytes,
275
+ random,
276
+ randomLib,
277
+ reportDefect,
278
+ time,
279
+ };
280
+ };
281
+ export function testCreateRun(deps) {
282
+ return createRunInternal({
283
+ ...testCreateDeps(),
284
+ ...deps,
285
+ });
286
+ }
287
+ const globalWithScheduler = globalThis;
288
+ const globalWithSetImmediate = globalThis;
289
+ const taskMetaSymbol = Symbol("evolu.Task.meta");
290
+ const abortableMask = 0;
291
+ const runningRunState = { type: "Running" };
292
+ const createRunInternal = (deps, parent, rootRun, taskMeta) => {
293
+ const abortBehavior = taskMeta?.abortBehavior;
294
+ if (abortBehavior !== undefined && abortBehavior !== "unabortable") {
295
+ assert(parent?.restoreTokens.has(abortBehavior.restoreToken) === true, "restore is only valid inside the unabortableMask that created it");
249
296
  }
297
+ // Plain Tasks inherit the parent's mask. `unabortable` increments at start
298
+ // time because its concrete mask is relative to that parent. `restore` is
299
+ // validated by its token above, then uses its captured concrete mask.
300
+ const parentAbortMask = parent?.abortMask ?? abortableMask;
301
+ const abortMask = abortBehavior === undefined
302
+ ? parentAbortMask
303
+ : abortBehavior === "unabortable"
304
+ ? increment(parentAbortMask)
305
+ : abortBehavior.abortMask;
306
+ let state = runningRunState;
307
+ let exit;
308
+ let snapshot;
309
+ // Keyed by the child Run for snapshot() and removal; the value is the done
310
+ // promise disposal waits on.
311
+ const children = new Map();
312
+ // Invariant: requestController records every abort request immediately;
313
+ // signalController only exposes an abort when this Run's mask allows it.
314
+ // Code that must reject new work after shutdown reads requestAbortSignal.
250
315
  const requestController = new AbortController();
251
316
  const signalController = new AbortController();
252
- let state = running;
253
- let result;
254
- let outcome;
255
- let children = emptySet;
256
- const requestAbort = (abortError) => {
257
- if (abortMask === isAbortable)
258
- signalController.abort(abortError);
259
- requestController.abort(abortError);
317
+ let disposePromise;
318
+ let finalizers;
319
+ let finalizerAbortError;
320
+ const reportDefect = (reported) => {
321
+ try {
322
+ deps.reportDefect(reported);
323
+ }
324
+ catch (reporterDefect) {
325
+ reportDefectAfterMicrotask(new AggregateError([reported, reporterDefect], "ReportDefect failed while reporting a defect"));
326
+ }
260
327
  };
261
- if (parent) {
262
- subscribeToAbort(parent.requestSignal, () => requestAbort(parent.requestSignal.reason), { signal: requestController.signal });
263
- }
264
328
  const emitEvent = (data) => {
265
- if (!deps.runConfig?.eventsEnabled.get())
329
+ // Run events are observability-only. Handler and event-construction defects
330
+ // are reported, but must not panic the Run or interrupt Task settlement.
331
+ try {
332
+ if (!deps.runConfig?.eventsEnabled.get())
333
+ return;
334
+ const event = {
335
+ id: run.id,
336
+ timestamp: root.deps.time.now(),
337
+ data,
338
+ };
339
+ for (let node = run; node; node = node.parent) {
340
+ try {
341
+ node.onEvent?.(event);
342
+ }
343
+ catch (error) {
344
+ reportDefect(error);
345
+ }
346
+ }
347
+ }
348
+ catch (error) {
349
+ reportDefect(error);
350
+ }
351
+ };
352
+ // Reads abort reasons from the Run-owned controllers, which are only
353
+ // aborted with AbortError.
354
+ const currentAbort = () => ({
355
+ request: requestController.signal.reason.reason,
356
+ observed: signalController.signal.aborted
357
+ ? signalController.signal.reason.reason
358
+ : null,
359
+ });
360
+ const commitState = (nextState) => {
361
+ state = nextState;
362
+ emitEvent({ type: "StateChanged", state });
363
+ };
364
+ const requestAbort = (reason = explicitAbortReason) => {
365
+ if (requestController.signal.aborted)
266
366
  return;
267
- const e = { id: self.id, timestamp: deps.time.now(), data };
268
- for (let node = self; node; node = node.parent) {
269
- node.onEvent?.(e);
367
+ const abortError = createAbortError(reason);
368
+ requestController.abort(abortError);
369
+ if (abortMask === abortableMask)
370
+ signalController.abort(abortError);
371
+ commitState({ type: "Aborted", abort: currentAbort() });
372
+ };
373
+ // The first provided exit claims the Run exit; later exits are ignored. A
374
+ // Run disposed without an exit stays claimable (e.g. by a panic during
375
+ // disposal) until children settle, then the exit defaults to ok(ok()) —
376
+ // root and Run.create Runs have no parent Task to supply one, and a settled
377
+ // Run must record an exit. A panic that claims the exit during disposal is
378
+ // not reflected in the already-aborted controllers, so RunStateSettled.abort
379
+ // can keep runDisposedAbortReason while exit records the panic.
380
+ const dispose = (nextExit) => {
381
+ exit ??= nextExit;
382
+ if (disposePromise)
383
+ return disposePromise;
384
+ const settle = () => {
385
+ exit ??= ok(ok());
386
+ commitState({ type: "Settled", abort: currentAbort(), exit });
387
+ return exit;
388
+ };
389
+ const finalizersToDispose = finalizers;
390
+ if (finalizersToDispose) {
391
+ disposePromise = Promise.all(children.values()).then(async () => {
392
+ try {
393
+ await finalizersToDispose.disposeAsync();
394
+ }
395
+ catch (error) {
396
+ finalizerAbortError = root.panic(error);
397
+ exit ??= err(finalizerAbortError);
398
+ }
399
+ return settle();
400
+ });
401
+ }
402
+ else {
403
+ disposePromise = Promise.all(children.values()).then(settle);
270
404
  }
405
+ const abortError = exit?.ok === false ? exit.error : runDisposedAbortError;
406
+ const { aborted } = signalController.signal;
407
+ requestController.abort(abortError);
408
+ signalController.abort(abortError);
409
+ if (!aborted)
410
+ commitState({ type: "Aborted", abort: currentAbort() });
411
+ return disposePromise;
271
412
  };
272
- const run = (task, taskDeps) => {
273
- const childRun = createRunInternal(taskDeps === undefined
274
- ? deps
275
- : {
413
+ // Custom deps replace parent custom deps, so defaults must be picked from
414
+ // the merged deps by key. `satisfies RunDefaultDeps` fails to compile when
415
+ // a newly added required default dep is missing here; optional ones like
416
+ // runConfig must be added manually.
417
+ const createChildDeps = (taskDeps) => taskDeps === undefined
418
+ ? deps
419
+ : {
420
+ ...{
276
421
  console: deps.console,
422
+ leakDetector: deps.leakDetector,
423
+ nativeFetch: deps.nativeFetch,
277
424
  randomBytes: deps.randomBytes,
278
425
  random: deps.random,
426
+ reportDefect: deps.reportDefect,
279
427
  time: deps.time,
280
- ...(deps.runConfig && {
281
- runConfig: deps.runConfig,
282
- }),
283
- ...taskDeps,
284
- }, self, daemon ?? self, getAbortBehavior(task), getConcurrencyBehavior(task));
285
- if (state !== running) {
286
- childRun.requestAbort(runStoppedAbortError);
287
- task = () => err(runStoppedAbortError);
288
- }
289
- else if (signalController.signal.aborted &&
290
- childRun.abortMask === isAbortable) {
291
- const abortError = signalController.signal.reason;
292
- childRun.requestAbort(abortError);
293
- task = () => err(abortError);
294
- }
295
- const childFiber = Object.assign(Promise.try(task, childRun)
296
- .then(childRun.handleTaskFulfilled)
297
- .finally(childRun.handleTaskSettled)
298
- .finally(childRun[Symbol.asyncDispose])
299
- .finally(() => {
300
- children = deleteFromSet(children, childFiber);
301
- emitEvent({ type: "ChildRemoved", childId: childRun.id });
302
- }), {
303
- run: childRun,
304
- abort: (reason) => {
305
- childRun.requestAbort(createAbortError(reason));
306
- },
307
- getState: () => childRun.getState(),
308
- [Symbol.dispose]: () => {
309
- childFiber.abort();
428
+ ...(deps.runConfig && { runConfig: deps.runConfig }),
310
429
  },
311
- });
312
- children = addToSet(children, childFiber);
313
- emitEvent({ type: "ChildAdded", childId: childRun.id });
314
- return childFiber;
315
- };
316
- let snapshot = null;
317
- let disposingPromise = null;
318
- const self = run;
319
- {
320
- const run = self;
321
- const id = createId(deps);
322
- async function orThrow(task, taskDeps) {
323
- const result = taskDeps === undefined ? await self(task) : await self(task, taskDeps);
324
- return getOrThrow(result);
325
- }
326
- run.orThrow = orThrow;
327
- run.id = id;
328
- run.parent = parent ?? null;
329
- run.signal = signalController.signal;
330
- run.abortMask = abortMask;
331
- run.onAbort = (callback) => {
332
- if (abortMask !== isAbortable)
333
- return;
334
- subscribeToAbort(signalController.signal, () => callback(signalController.signal.reason.reason), { once: true, signal: requestController.signal });
335
- };
336
- run.getState = () => state;
337
- run.getChildren = () => children;
338
- run.snapshot = () => {
339
- const childSnapshots = Array.from(children).map((fiber) => fiber.run.snapshot());
340
- if (snapshot?.state !== state ||
341
- !eqArrayStrict(snapshot.children, childSnapshots)) {
342
- snapshot = {
343
- id,
344
- state,
345
- children: childSnapshots,
346
- abortMask,
347
- };
348
- }
349
- return snapshot;
430
+ ...taskDeps,
350
431
  };
351
- run.onEvent = undefined;
352
- run.daemon = daemon ?? self;
353
- run.create = (runDeps = deps) => {
354
- const task = createDeferred().task;
355
- return run.daemon(task, runDeps).run;
432
+ const run = ((task, taskDeps, { abortable = false } = {}) => {
433
+ assertNotDisposed({ disposed: !!disposePromise });
434
+ const taskMeta = task[taskMetaSymbol];
435
+ const taskRun = createRunInternal(createChildDeps(taskDeps), run, root, taskMeta);
436
+ // Disposal uses a separate promise because the Fiber starts immediately and
437
+ // gets its rejection handler only after the Task stack is captured.
438
+ const done = Promise.withResolvers();
439
+ children.set(taskRun, done.promise);
440
+ emitEvent({ type: "ChildAdded", childId: taskRun.id });
441
+ // Wired before the Task starts so an already-aborted parent prevents the
442
+ // start via startSignal.throwIfAborted below.
443
+ const abortFromParent = () => {
444
+ taskRun.requestAbort(run.requestAbortSignal.reason.reason);
356
445
  };
357
- run.deps = deps;
358
- run.concurrency =
359
- concurrencyBehavior ?? parent?.concurrency ?? defaultConcurrency;
360
- run[Symbol.asyncDispose] = () => {
361
- if (disposingPromise)
362
- return disposingPromise;
363
- state = { type: "Disposing" };
364
- emitEvent({ type: "StateChanged", state });
365
- requestAbort(runStoppedAbortError);
366
- disposingPromise = Promise.allSettled((self.ownTaskSettled
367
- ? [self.ownTaskSettled.promise, ...children]
368
- : children))
369
- .then(lazyVoid)
370
- .finally(() => {
371
- /**
372
- * Root and daemon Runs have no own Task, so `run.handleTaskFulfilled`
373
- * never populates their terminal values. In that case disposal
374
- * publishes `ok()` for both `result` and `outcome`. Task-backed Runs
375
- * normally reach this point with both values already set.
376
- */
377
- [result, outcome] = [result ?? ok(), outcome ?? ok()];
378
- state = { type: "Settled", result, outcome };
379
- emitEvent({ type: "StateChanged", state });
446
+ if (run.requestAbortSignal.aborted)
447
+ abortFromParent();
448
+ else {
449
+ run.requestAbortSignal.addEventListener("abort", abortFromParent, {
450
+ once: true,
451
+ signal: taskRun.requestAbortSignal,
380
452
  });
381
- return disposingPromise;
453
+ }
454
+ // The Fiber is runTask's native async-function promise. Keep runTask named:
455
+ // async stack traces use it as the visible Run boundary frame.
456
+ const runTask = async () => {
457
+ let exit;
458
+ try {
459
+ const startSignal = taskMeta?.abortBehavior === "unabortable"
460
+ ? taskRun.requestAbortSignal
461
+ : taskRun.signal;
462
+ const scheduler = globalWithScheduler.scheduler;
463
+ let result;
464
+ if (taskMeta?.priority && scheduler?.postTask) {
465
+ result = await scheduler.postTask(() => {
466
+ startSignal.throwIfAborted();
467
+ // eslint-disable-next-line evolu/no-direct-task-call -- The executor invokes the Task with its child Run.
468
+ return task(taskRun);
469
+ }, {
470
+ priority: taskMeta.priority,
471
+ signal: startSignal,
472
+ });
473
+ }
474
+ else {
475
+ startSignal.throwIfAborted();
476
+ // eslint-disable-next-line evolu/no-direct-task-call -- The executor invokes the Task with its child Run.
477
+ result = await task(taskRun);
478
+ }
479
+ // Full Result validation is dev-only; production checks only the
480
+ // boolean `ok` discriminant. This catches common malformed returns
481
+ // without full validation cost.
482
+ assert(isDev
483
+ ? UnknownResult.is(result)
484
+ : typeof result
485
+ ?.ok === "boolean", "Task must return Result.");
486
+ exit = ok(result);
487
+ }
488
+ catch (error) {
489
+ exit = err(AbortError.is(error) ? error : root.panic(error));
490
+ }
491
+ // Internal Child Run disposal cannot reject; finalizer defects become Err
492
+ // exits. Plain Task Runs settle from the Task exit; only a DisposableRun
493
+ // from Run.create can record an earlier exit.
494
+ const taskExit = await taskRun.dispose(exit);
495
+ // The child is removed once its Run has disposed. Parent bookkeeping
496
+ // tracks internal lifetime, not Fiber observers.
497
+ children.delete(taskRun);
498
+ emitEvent({ type: "ChildRemoved", childId: taskRun.id });
499
+ done.resolve();
500
+ if (taskExit.ok)
501
+ return taskExit.value;
502
+ if (abortable)
503
+ return err(taskExit.error);
504
+ // Invariant: the Run tree is every Fiber's final handler: defects are
505
+ // reported, aborts are control flow, and forgotten Fibers are a lint
506
+ // concern. Attach this late so V8 captures the defect stack before any
507
+ // catch observes the Fiber.
508
+ void fiber.catch(constVoid);
509
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- AbortError is Task abort control flow; aborts intentionally carry no stack.
510
+ throw taskExit.error;
382
511
  };
383
- // Internal
384
- run.ownTaskSettled = parent ? Promise.withResolvers() : null;
385
- run.requestAbort = requestAbort;
386
- run.requestSignal = requestController.signal;
387
- run.handleTaskFulfilled = (taskOutcome) => {
388
- const taskResult = self.signal.aborted
389
- ? err(self.signal.reason)
390
- : taskOutcome;
391
- result = taskResult;
392
- outcome = taskOutcome;
393
- return taskResult;
512
+ const fiber = runTask();
513
+ fiber.run = taskRun;
514
+ if (abortable) {
515
+ const abortableFiber = fiber;
516
+ abortableFiber.abort = taskRun.requestAbort;
517
+ abortableFiber[Symbol.asyncDispose] = async () => {
518
+ abortableFiber.abort();
519
+ await abortableFiber;
520
+ };
521
+ }
522
+ return fiber;
523
+ });
524
+ const root = rootRun ?? run;
525
+ /* eslint-disable @typescript-eslint/no-unsafe-return -- Internal overload assignments implement public generics via TaskInternal. */
526
+ run.orThrow = (async (task, taskDeps) => getOrThrow(await run(task, taskDeps)));
527
+ run.ok = (async (task, taskDeps) => getOk((await run(task, taskDeps))));
528
+ /* eslint-enable @typescript-eslint/no-unsafe-return */
529
+ run.abortable = ((task, deps) => run(task, deps, { abortable: true }));
530
+ run.daemon = ((task, taskDeps) => {
531
+ assertNotDisposed({ disposed: !!disposePromise });
532
+ // Invariant: daemons detach to the root lifetime, so they must never start
533
+ // after this Run records abort, even if a mask hides it from run.signal.
534
+ run.requestAbortSignal.throwIfAborted();
535
+ return root.abortable(task, createChildDeps(taskDeps));
536
+ });
537
+ run.create = ((runDeps) => run.daemon(async (run) => {
538
+ await run.abortable(waitForAbort);
539
+ return ok();
540
+ }, runDeps).run);
541
+ run.id = createId(deps);
542
+ run.parent = parent ?? null;
543
+ run.deps = deps;
544
+ run.signal = signalController.signal;
545
+ run.requestAbortSignal = requestController.signal;
546
+ run.onAbort = (callback) => {
547
+ const onAbort = () => {
548
+ try {
549
+ callback(run.signal.reason);
550
+ }
551
+ catch (error) {
552
+ // Abort callbacks run outside a Fiber result channel.
553
+ root.panic(error);
554
+ }
394
555
  };
395
- run.handleTaskSettled = () => {
396
- self.ownTaskSettled?.resolve();
556
+ if (run.signal.aborted) {
557
+ onAbort();
558
+ return null;
559
+ }
560
+ // A user listener on run.signal calling stopImmediatePropagation could
561
+ // suppress this listener (see the run.signal JSDoc). Switch to
562
+ // addAbortCallback if/when https://github.com/whatwg/dom/pull/1425 lands
563
+ // (free structural fix); use a private signal exposed via AbortSignal.any
564
+ // only if a real-world report ever surfaces (per-Run cost).
565
+ run.signal.addEventListener("abort", onAbort, { once: true });
566
+ return {
567
+ [Symbol.dispose]: () => {
568
+ run.signal.removeEventListener("abort", onAbort);
569
+ },
397
570
  };
571
+ };
572
+ run.getState = () => state;
573
+ run.snapshot = () => {
574
+ const childSnapshots = Array.from(children.keys(), (childRun) => childRun.snapshot());
575
+ if (snapshot?.state !== state ||
576
+ !eqArrayStrict(snapshot.children, childSnapshots)) {
577
+ snapshot = {
578
+ id: run.id,
579
+ state,
580
+ children: childSnapshots,
581
+ abortMask,
582
+ };
583
+ }
584
+ return snapshot;
585
+ };
586
+ run.onEvent = undefined;
587
+ run.defer = (finalizer) => {
588
+ assertNotDisposed({ disposed: !!disposePromise });
589
+ (finalizers ??= new AsyncDisposableStack()).defer(finalizer);
590
+ };
591
+ run.abort = (reason = explicitAbortReason) => {
592
+ // Unlike panic, abort cannot claim the exit after disposal starts.
593
+ if (disposePromise)
594
+ return;
595
+ void dispose(err(createAbortError(reason)));
596
+ };
597
+ run.panic = (defect) => {
598
+ const abortError = createAbortError(createPanicAbortReason(defect));
599
+ reportDefect(abortError);
600
+ // Internal disposal waits for Run children and does not reject. The first
601
+ // disposal wins, so a later panic does not replace the root Run exit.
602
+ void root.dispose(err(abortError));
603
+ return abortError;
604
+ };
605
+ run[Symbol.dispose] = () => {
606
+ void dispose();
607
+ };
608
+ run[Symbol.asyncDispose] = async () => {
609
+ await dispose();
610
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- AbortError is Task abort control flow; rethrowing it avoids reporting the finalizer defect twice in Task code.
611
+ if (finalizerAbortError)
612
+ throw finalizerAbortError;
613
+ };
614
+ // Internal
615
+ run.abortMask = abortMask;
616
+ run.restoreTokens = parent?.restoreTokens ?? emptySet;
617
+ run.requestAbort = requestAbort;
618
+ run.dispose = dispose;
619
+ return run;
620
+ };
621
+ const withTaskMeta = (meta) => (task) => {
622
+ const taskInternal = task;
623
+ assert(meta.abortBehavior === undefined ||
624
+ taskInternal[taskMetaSymbol]?.abortBehavior === undefined, "abort behavior helpers cannot wrap the same Task");
625
+ // eslint-disable-next-line evolu/no-direct-task-call -- Preserve the wrapped Task's child Run.
626
+ const wrapped = (run) => task(run);
627
+ const taskMeta = taskInternal[taskMetaSymbol];
628
+ wrapped[taskMetaSymbol] = taskMeta ? { ...taskMeta, ...meta } : meta;
629
+ return wrapped;
630
+ };
631
+ export function all(input, fnOrOptions, options) {
632
+ const fn = typeof fnOrOptions === "function" ? fnOrOptions : undefined;
633
+ const collectionOptions = typeof fnOrOptions === "function" ? options : fnOrOptions;
634
+ const collectValues = collectionOptions === undefined ||
635
+ !("collect" in collectionOptions) ||
636
+ collectionOptions.collect !== false;
637
+ return collect("all", fn ? mapInput(input, fn) : input, collectionOptions, collectValues);
638
+ }
639
+ export function allSettled(input, fnOrOptions, options) {
640
+ const fn = typeof fnOrOptions === "function" ? fnOrOptions : undefined;
641
+ return collect("allSettled", fn ? mapInput(input, fn) : input, typeof fnOrOptions === "function" ? options : fnOrOptions);
642
+ }
643
+ const collect = (type, input, options, collectValues = true) => async (run) => {
644
+ let tasks;
645
+ let getValue;
646
+ if (Array.isArray(input)) {
647
+ if (!isNonEmptyArray(input)) {
648
+ return collectValues ? ok(emptyArray) : ok();
649
+ }
650
+ tasks = input;
651
+ getValue = identity;
652
+ }
653
+ else {
654
+ const entries = objectToEntries(input);
655
+ if (!isNonEmptyArray(entries)) {
656
+ return collectValues ? ok(emptyRecord) : ok();
657
+ }
658
+ tasks = mapArray(entries, ([, task]) => task);
659
+ getValue = (values) => objectFromEntries(entries.map(([key], index) => [key, values[index]]));
398
660
  }
399
- return self;
661
+ const values = collectValues ? [] : undefined;
662
+ let firstErr;
663
+ await run(each(tasks, (result, index) => {
664
+ if (type === "allSettled") {
665
+ values[index] = result;
666
+ return "continue";
667
+ }
668
+ if (!result.ok) {
669
+ firstErr = result;
670
+ return "stop";
671
+ }
672
+ if (values)
673
+ values[index] = result.value;
674
+ return "continue";
675
+ }, options));
676
+ return firstErr ?? (values ? ok(getValue(values)) : ok());
400
677
  };
401
- const running = { type: "Running" };
678
+ const mapInput = (input, fn) => Array.isArray(input)
679
+ ? mapArray(input, (value, index) => fn(value, index))
680
+ : mapObject(input, fn);
402
681
  /**
403
- * Abort reason indicating a {@link Run} can no longer start new Tasks.
682
+ * Creates a {@link Task} from a callback-based API.
683
+ *
684
+ * Use this to wrap callback-style APIs such as event listeners and Node.js
685
+ * callbacks. Resolve with `ok(value)` or `err(error)`, or reject with a defect,
686
+ * to complete the Task. Settlement is one-shot: the first `resolve` or `reject`
687
+ * wins, and later settlement calls are ignored, matching Promise semantics.
688
+ * When `reject` wins settlement, the defect panics the Run tree and is observed
689
+ * at Fiber boundaries as {@link AbortError} with {@link PanicAbortReason}.
690
+ * Rejecting AbortError is reserved for Task abort control flow: direct
691
+ * `run(task)` rejects with it, and `run.abortable(task)` returns it as an Err.
692
+ *
693
+ * This helper is a callback bridge. If `reject` forwards an Error created in a
694
+ * separate async chain, V8 cannot reconstruct the caller's zero-cost async
695
+ * stack through this bridge. Prefer native promise APIs and `await` when the
696
+ * wrapped operation already has a promise-shaped API.
697
+ *
698
+ * One-shot settlement applies only to `resolve` and `reject`. A synchronous
699
+ * throw from the setup function is a defect that panics the Run tree even after
700
+ * `resolve` was called — setup throws are bugs and must not be masked by an
701
+ * earlier resolution.
702
+ *
703
+ * Optionally return a synchronous cleanup callback. It runs when the Task Run
704
+ * signal aborts, including normal completion and explicit abort. The callback
705
+ * must not throw. Cleanup defects panic the root Run; if the callback Task
706
+ * already settled, its Fiber keeps the original Result while the root still
707
+ * reports the panic. Cleanup must be synchronous; returned promises are not
708
+ * awaited. For async cleanup, prefer {@link acquireUseRelease}, `await using`
709
+ * with {@link AsyncDisposableStack}, or a Task that owns the resource
710
+ * explicitly.
711
+ *
712
+ * If setup can throw after acquiring any resource, use a local
713
+ * {@link DisposableStack} before returning cleanup. Register each cleanup as
714
+ * soon as the resource is acquired, then move the disposer and return a cleanup
715
+ * callback. If setup throws before cleanup is returned, the local `using`
716
+ * disposal releases already-acquired resources.
717
+ *
718
+ * ### Example
719
+ *
720
+ * ```ts
721
+ * import { callback, createRun, ok, type Task } from "@evolu/common";
722
+ *
723
+ * const listeners = new Set<(message: string) => void>();
724
+ * const subscribe = (
725
+ * listener: (message: string) => void,
726
+ * ): (() => void) => {
727
+ * listeners.add(listener);
728
+ * return () => listeners.delete(listener);
729
+ * };
730
+ * const nextMessage: Task<string> = callback(({ resolve }) =>
731
+ * subscribe((message) => resolve(ok(message))),
732
+ * );
404
733
  *
405
- * Covers both disposing and settled Runs.
734
+ * await using run = createRun();
735
+ * const fiber = run(nextMessage);
736
+ * expect(listeners.size).toBe(1);
737
+ * for (const listener of listeners) listener("ready");
738
+ * expectOk(await fiber, "ready");
739
+ * // The callback cleanup unsubscribes after settlement.
740
+ * expect(listeners.size).toBe(0);
741
+ * ```
406
742
  *
407
- * @group Creating Run
743
+ * @group Interop
408
744
  */
409
- export const RunStoppedError = /*#__PURE__*/ typed("RunStoppedError");
745
+ export const callback = (fn) => (run) => {
746
+ const { promise, resolve, reject } = Promise.withResolvers();
747
+ const cleanup = fn({ run, resolve, reject });
748
+ // The Task Run aborts during disposal, so this also runs callback cleanup
749
+ // after normal settlement. The abort listener is once-only.
750
+ run.onAbort((abortError) => {
751
+ reject(abortError);
752
+ cleanup?.();
753
+ });
754
+ return promise;
755
+ };
410
756
  /**
411
- * Shared {@link RunStoppedError} instance used as the default
412
- * {@link AbortError.reason} when a Task is started on a non-running {@link Run}.
757
+ * Pauses execution for a specified {@link PositiveDuration}.
758
+ *
759
+ * Aborting the Task clears the scheduled timeout.
413
760
  *
414
- * @group Creating Run
761
+ * @group Timing
415
762
  */
416
- export const runStoppedError = {
417
- type: "RunStoppedError",
418
- };
419
- const createAbortError = (reason) => ({
420
- type: "AbortError",
421
- reason,
422
- });
423
- const subscribeToAbort = (signal, handler, options) => {
424
- if (signal.aborted)
425
- handler();
426
- else
427
- signal.addEventListener("abort", handler, options);
428
- };
429
- const runStoppedAbortError = createAbortError(runStoppedError);
430
- const isAbortable = AbortMask.orThrow(0);
431
- const abortBehaviorSymbol = Symbol("evolu.Task.abortBehavior");
432
- const getAbortBehavior = (task) => task[abortBehaviorSymbol];
433
- const abortBehavior = (behavior) => (task) => Object.assign((run) => run(task), {
434
- [abortBehaviorSymbol]: behavior,
763
+ export const sleep = (duration) => callback(({ run: { deps }, resolve }) => {
764
+ const id = deps.time.setTimeout(() => resolve(ok()), duration);
765
+ return () => deps.time.clearTimeout(id);
435
766
  });
436
767
  /**
437
- * Makes a {@link Task} unabortable.
768
+ * Typed error returned by {@link timeout} when a Task exceeds its duration.
438
769
  *
439
- * Once started, an unabortable Task always completes. Abort requests are masked
440
- * while it runs, and `signal.aborted` remains `false` inside the Task.
770
+ * @group Timing
771
+ */
772
+ export const TimeoutError =
773
+ /*#__PURE__*/ typed("TimeoutError");
774
+ /**
775
+ * The {@link TimeoutError} instance returned by {@link timeout}.
441
776
  *
442
- * `unabortable` controls abort signal propagation; it does not force work to
443
- * start. If the parent {@link Run} is already disposing or settled, `run(task)`
444
- * short-circuits before task execution and returns `err(AbortError)` with
445
- * {@link runStoppedError} as reason. So `unabortable` means “do not interrupt
446
- * this Task once it has started”, not “remove AbortError from its type”.
777
+ * @group Timing
778
+ */
779
+ export const timeoutError = { type: "TimeoutError" };
780
+ /**
781
+ * Limits how long a {@link Task} may run.
447
782
  *
448
- * Most callers should still propagate or ignore {@link AbortError} according to
449
- * ordinary structured-concurrency ownership. When abort would violate a
450
- * lifecycle invariant, await the unabortable Task and use
451
- * {@link assertNotAborted} to fail fast if it could not even start.
783
+ * Returns the Task {@link Result} when it settles within the duration.
784
+ * Otherwise, the Task is aborted and {@link TimeoutError} is returned. A Task
785
+ * that doesn't observe abort delays the TimeoutError until it settles; when
786
+ * that wait is unacceptable, wrap the Task with {@link daemon}:
787
+ * `timeout(daemon(task), duration)`.
452
788
  *
453
789
  * ### Example
454
790
  *
455
791
  * ```ts
792
+ * import {
793
+ * createRun,
794
+ * timeout,
795
+ * timeoutError,
796
+ * waitForAbort,
797
+ * type Result,
798
+ * type TimeoutError,
799
+ * } from "@evolu/common";
800
+ *
456
801
  * await using run = createRun();
457
802
  *
458
- * const events: Array<string> = [];
459
- * const canComplete = Promise.withResolvers<void>();
460
- * let signalAbortedInAnalytics = true;
461
- *
462
- * // Simulate async analytics API (abortable by default)
463
- * const sendToAnalytics =
464
- * (event: number): Task<void, never> =>
465
- * async ({ signal }) => {
466
- * await canComplete.promise;
467
- * signalAbortedInAnalytics = signal.aborted;
468
- * events.push(`sent ${event}`);
469
- * return ok();
470
- * };
803
+ * const result = await run(timeout(waitForAbort, "1ms"));
804
+ * expectTypeOf(result).toEqualTypeOf<Result<never, TimeoutError>>();
805
+ * expectErr(result, timeoutError);
806
+ * ```
471
807
  *
472
- * // Important events must be sent even if the user navigates away
473
- * const trackImportantEvent = (event: number) =>
474
- * unabortable(sendToAnalytics(event));
808
+ * @group Timing
809
+ */
810
+ export const timeout = (task, duration) => race([
811
+ task,
812
+ async (run) => {
813
+ await run.ok(sleep(duration));
814
+ return err(timeoutError);
815
+ },
816
+ ]);
817
+ /**
818
+ * Retries a Task according to a {@link Schedule}.
475
819
  *
476
- * // User clicks, we start tracking (Task runs until first await)
477
- * const fiber = run(trackImportantEvent(123));
820
+ * Use `retry` for failure recovery: it repeats after {@link Err} and wraps the
821
+ * final domain error. Use {@link repeat} for success-driven loops: it repeats
822
+ * after {@link Ok} and returns the Task's natural Result.
478
823
  *
479
- * // User navigates away (abort requested while task is running)
480
- * fiber.abort();
481
- * canComplete.resolve();
824
+ * {@link AbortError} passes through unchanged when returned as a Result error,
825
+ * such as from {@link daemon}. Abort from `run(task)` remains Fiber control
826
+ * flow. Other errors are domain errors: retrying continues while the schedule
827
+ * yields another delay and {@link RetryOptions.shouldRetry} returns `true`. When
828
+ * retrying stops, `retry` returns {@link RetryError} with the final domain error
829
+ * as `lastError`.
482
830
  *
483
- * const result = await fiber;
831
+ * AbortError recognition is structural. Domain errors, especially values parsed
832
+ * from untrusted input, must not use the reserved AbortError shape.
484
833
  *
485
- * expect(signalAbortedInAnalytics).toBe(false);
486
- * // Analytics was sent despite abort
487
- * expect(events).toEqual(["sent 123"]);
488
- * expect(result).toEqual(ok());
489
- * ```
834
+ * ### Example
490
835
  *
491
- * @group Abort masking
492
- */
493
- // TODO: Clear AbortError from unabortable task results.
494
- export const unabortable = /*#__PURE__*/ abortBehavior("unabortable");
495
- /**
496
- * Like {@link unabortable}, but provides `restore` to restore abortability for
497
- * specific tasks.
836
+ * ```ts
837
+ * import {
838
+ * createRun,
839
+ * err,
840
+ * recurs,
841
+ * retry,
842
+ * type Result,
843
+ * type RetryTaskError,
844
+ * type Task,
845
+ * } from "@evolu/common";
498
846
  *
499
- * Tasks inherit abort masking from their parent. This means:
847
+ * await using run = createRun();
500
848
  *
501
- * - Tasks run inside `unabortableMask` are unabortable by default
502
- * - Tasks wrapped with `restore()` restore the previous abortability
849
+ * interface FetchDataError {
850
+ * readonly type: "FetchDataError";
851
+ * }
503
852
  *
504
- * @group Abort masking
505
- */
506
- export const unabortableMask = (fn) => unabortable((run) => fn(abortBehavior(AbortMask.orThrow(decrement(run.abortMask))))(run));
507
- const defaultConcurrency = 1;
508
- const concurrencyBehaviorSymbol = Symbol("evolu.Task.concurrencyBehavior");
509
- const getConcurrencyBehavior = (task) => task[concurrencyBehaviorSymbol];
510
- export function concurrently(concurrencyOrTask, taskOrFallback) {
511
- const isTask = isFunction(concurrencyOrTask);
512
- const task = isTask ? concurrencyOrTask : taskOrFallback;
513
- return Object.assign((run) => run(task), {
514
- [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask,
515
- });
516
- }
517
- /**
518
- * Yields execution to allow other work to proceed.
853
+ * const fetchData: Task<string, FetchDataError> = () =>
854
+ * err({ type: "FetchDataError" });
519
855
  *
520
- * Long-running JavaScript blocks the main thread. In browsers, this makes the
521
- * UI unresponsive (user interactions, animations). In Node.js, it prevents I/O
522
- * callbacks, timers, and other requests from being handled. Inserting yield
523
- * points lets the runtime process high-priority work between chunks of code.
856
+ * const fetchWithRetry = retry(fetchData, recurs(2));
524
857
  *
525
- * Uses `scheduler.yield()` in browsers for optimal main thread scheduling,
526
- * falls back to `setImmediate` in Node.js, or `setTimeout` elsewhere.
858
+ * const result = await run(fetchWithRetry);
859
+ * expectTypeOf(result).toEqualTypeOf<
860
+ * Result<string, RetryTaskError<FetchDataError>>
861
+ * >();
862
+ * expectErr(result, {
863
+ * type: "RetryError",
864
+ * attempts: 3,
865
+ * lastError: { type: "FetchDataError" },
866
+ * });
867
+ * ```
527
868
  *
528
869
  * ### Example
529
870
  *
530
871
  * ```ts
531
- * const processLargeArray: Task<void, never> = async (run) => {
532
- * const { time } = run.deps;
533
- * let lastYield = time.now();
534
- *
535
- * for (const item of largeArray) {
536
- * processItem(item);
537
- *
538
- * // Yield periodically to keep UI responsive
539
- * if (time.now() - lastYield > msLongTask) {
540
- * const r = await run(yieldNow);
541
- * if (!r.ok) return r;
542
- * lastYield = time.now();
543
- * }
544
- * }
545
- *
546
- * return ok();
547
- * };
548
- * ```
872
+ * import { createRun, err, recurs, retry, type Task } from "@evolu/common";
549
873
  *
550
- * Recursive tasks also benefit from periodic yields — without them, deep
551
- * recursion overflows the call stack:
874
+ * interface FetchDataError {
875
+ * readonly type: "RecoverableError" | "FatalError";
876
+ * }
552
877
  *
553
- * ```ts
554
- * const processRecursive =
555
- * (count: number, index: number, sum: number): Task<number> =>
556
- * async (run) => {
557
- * if (index >= count) return ok(sum);
878
+ * const fetchData: Task<string, FetchDataError> = () =>
879
+ * err({ type: "FatalError" });
558
880
  *
559
- * // Yield periodically to break synchronous call chains.
560
- * if (index > 0 && index % 1000 === 0) {
561
- * const y = await run(yieldNow);
562
- * if (!y.ok) return y;
563
- * }
881
+ * const fetchWithRetry = retry(fetchData, recurs(5), {
882
+ * shouldRetry: (error) => error.type !== "FatalError",
883
+ * });
564
884
  *
565
- * // Direct tail-call: no fiber overhead, stack-safe thanks to yieldNow.
566
- * return await processRecursive(count, index + 1, sum + index)(run);
567
- * };
885
+ * await using run = createRun();
886
+ * expectErr(await run(fetchWithRetry), {
887
+ * type: "RetryError",
888
+ * attempts: 1,
889
+ * lastError: { type: "FatalError" },
890
+ * });
568
891
  * ```
569
892
  *
570
- * @group Composition
571
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
572
- * @see https://web.dev/articles/optimize-long-tasks
893
+ * @group Resilience
573
894
  */
574
- export const yieldNow = () => tryAsync(() => yieldImpl(), // TODO: yieldImpl(run.signal)
575
- (reason) => createAbortError(reason));
576
- const scheduler = globalThis.scheduler;
577
- const yieldImpl = typeof scheduler?.yield === "function"
578
- ? () => scheduler.yield()
579
- : typeof setImmediate !== "undefined"
580
- ? () => new Promise((resolve) => setImmediate(resolve))
581
- : () => new Promise((r) => setTimeout(r, 0)); // Safari
895
+ export const retry = (task, schedule, { shouldRetry = constTrue, onRetry, } = {}) => async (run) => {
896
+ const step = schedule(run.deps);
897
+ let attempt = onePositiveInt;
898
+ const createRetryError = (lastError) => ({
899
+ type: "RetryError",
900
+ lastError,
901
+ attempts: attempt,
902
+ });
903
+ for (;;) {
904
+ const result = await run(task);
905
+ if (result.ok)
906
+ return result;
907
+ if (AbortError.is(result.error))
908
+ return result;
909
+ const error = result.error;
910
+ if (!shouldRetry(error))
911
+ return err(createRetryError(error));
912
+ const next = step(error);
913
+ if (!next.ok)
914
+ return err(createRetryError(error));
915
+ const [output, delay] = next.value;
916
+ onRetry?.({ error, attempt, output, delay });
917
+ attempt = incrementPositiveInt(attempt);
918
+ if (delay > 0)
919
+ await run.ok(sleep(PositiveMillis.orThrow(delay)));
920
+ }
921
+ };
582
922
  /**
583
- * Creates a {@link Task} from a callback-based API.
923
+ * Repeats a Task according to a {@link Schedule}.
584
924
  *
585
- * Use this to wrap callback-style APIs (event listeners, Node.js callbacks,
586
- * etc.) into Tasks with proper abort handling.
925
+ * Runs the Task once, then repeats while the Task returns {@link Ok}, the
926
+ * schedule yields another delay, and {@link RepeatOptions.shouldRepeat} returns
927
+ * `true`. When repeating stops, `repeat` returns the last successful Result. If
928
+ * the Task returns {@link Err}, including {@link Done} from a {@link NextTask},
929
+ * `repeat` returns that error without scheduling another attempt.
587
930
  *
588
- * Optionally return a teardown function that runs on abort.
931
+ * Use `repeat` for success-driven loops such as polling or consuming a
932
+ * NextTask: it repeats after Ok and returns the Task's natural Result. Use
933
+ * {@link retry} for failure recovery: it repeats after Err and wraps the final
934
+ * domain error in {@link RetryError}.
589
935
  *
590
- * This helper can also wrap promise APIs that do not observe `AbortSignal`.
591
- * Start the promise work inside the callback, and aborting the Task stops
592
- * waiting for it immediately.
936
+ * With `take(n)`, the Task runs n+1 times: the initial attempt plus n repeats.
593
937
  *
594
938
  * ### Example
595
939
  *
596
940
  * ```ts
597
- * // The sleep helper is implemented using callback:
598
- * const sleep = (duration: Duration): Task<void> =>
599
- * callback(({ ok, deps: { time } }) => {
600
- * const id = time.setTimeout(ok, durationToMillis(duration));
601
- * return () => time.clearTimeout(id);
602
- * });
941
+ * import { createRun, ok, recurs, repeat, type Task } from "@evolu/common";
603
942
  *
604
- * // Wrap an event listener — use signal directly
605
- * const waitForClick = (element: HTMLElement): Task<MouseEvent> =>
606
- * callback(({ ok, signal }) => {
607
- * element.addEventListener("click", ok, { once: true, signal });
608
- * });
943
+ * let attempts = 0;
944
+ * const checkStatus: Task<string> = () => {
945
+ * attempts += 1;
946
+ * return ok("pending");
947
+ * };
609
948
  *
610
- * // Wrap Node.js callback API
611
- * const readFile = (path: string): Task<string, NodeJS.ErrnoException> =>
612
- * callback(({ ok, err }) => {
613
- * fs.readFile(path, "utf8", (error, data) => {
614
- * if (error) err(error);
615
- * else ok(data);
616
- * });
617
- * });
949
+ * const poll = repeat(checkStatus, recurs(3));
618
950
  *
619
- * // Wrap a non-cooperating promise API. Aborting the Task stops waiting even
620
- * // if promiseApi itself ignores AbortSignal.
621
- * const fromPromiseApi = (): Task<string> =>
622
- * callback(({ ok, err }) => {
623
- * void promiseApi().then(ok, err);
624
- * });
951
+ * await using run = createRun();
952
+ * expectOk(await run(poll), "pending");
953
+ * expect(attempts).toBe(4);
625
954
  * ```
626
955
  *
627
- * @group Composition
628
- */
629
- export const callback = (callback) => (run) => new Promise((resolve) => {
630
- const teardown = callback({
631
- ok: (value) => resolve(ok(value)),
632
- err: (error) => resolve(err(error)),
633
- signal: run.signal,
634
- deps: run.deps,
635
- });
636
- run.onAbort((reason) => {
637
- if (teardown)
638
- teardown();
639
- resolve(err(createAbortError(reason)));
640
- });
641
- });
642
- /**
643
- * Pauses execution for a specified duration.
644
- *
645
956
  * ### Example
646
957
  *
647
958
  * ```ts
648
- * const task: Task<void> = async (run) => {
649
- * console.log("Starting...");
650
- * await run(sleep("1s"));
651
- * console.log("Done after 1 second");
652
- * return ok();
653
- * };
654
- * ```
959
+ * import {
960
+ * createRun,
961
+ * done,
962
+ * err,
963
+ * ok,
964
+ * repeat,
965
+ * spaced,
966
+ * type NextTask,
967
+ * } from "@evolu/common";
655
968
  *
656
- * @group Composition
657
- */
658
- export const sleep = (duration) => callback(({ ok, deps: { time } }) => {
659
- const id = time.setTimeout(ok, durationToMillis(duration));
660
- return () => time.clearTimeout(id);
661
- });
662
- /**
663
- * Returns a {@link Task} that completes first.
969
+ * await using run = createRun();
664
970
  *
665
- * Like
666
- * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race},
667
- * the first Task to complete (whether success or failure) wins. All other Tasks
668
- * are aborted. Use {@link any} if you need the first Task to succeed instead.
971
+ * interface Item {
972
+ * readonly id: string;
973
+ * }
669
974
  *
670
- * Requires a non-empty array racing zero Tasks has no meaningful result
671
- * (there's no "first to complete" without participants). This is enforced at
672
- * compile time for non-empty tuple types. For other arrays, guard with
673
- * {@link isNonEmptyArray}:
975
+ * const queue: Array<Item> = [{ id: "item-1" }];
674
976
  *
675
- * ```ts
676
- * if (isNonEmptyArray(tasks)) {
677
- * await run(race(tasks));
678
- * }
977
+ * const processQueue: NextTask<Item> = () => {
978
+ * const item = queue.shift();
979
+ * return item ? ok(item) : err(done());
980
+ * };
981
+ *
982
+ * const result = await run(repeat(processQueue, spaced("1ms")));
983
+ * expectErr(result, done());
984
+ * expect(queue).toEqual([]);
679
985
  * ```
680
986
  *
987
+ * @group Repetition
988
+ */
989
+ export const repeat = (task, schedule, { shouldRepeat = constTrue, onRepeat } = {}) => async (run) => {
990
+ const step = schedule(run.deps);
991
+ let attempt = onePositiveInt;
992
+ for (;;) {
993
+ const result = await run(task);
994
+ if (!result.ok)
995
+ return result;
996
+ if (!shouldRepeat(result.value))
997
+ return result;
998
+ const next = step(result.value);
999
+ if (!next.ok)
1000
+ return result;
1001
+ const [output, delay] = next.value;
1002
+ onRepeat?.({ value: result.value, attempt, output, delay });
1003
+ attempt = incrementPositiveInt(attempt);
1004
+ if (delay > 0)
1005
+ await run.ok(sleep(PositiveMillis.orThrow(delay)));
1006
+ }
1007
+ };
1008
+ /**
1009
+ * Runs Tasks until one returns {@link Ok} or all return {@link Err}.
1010
+ *
1011
+ * Use {@link race} to return the first settled Result instead, whether Ok or
1012
+ * {@link Err}.
1013
+ *
1014
+ * Returns the first {@link Ok} Result. Losing Tasks are aborted. If no Task
1015
+ * returns Ok, returns the last Err by input order, regardless of completion
1016
+ * order. Other Err results are discarded; use {@link allSettled} when you need
1017
+ * every error.
1018
+ *
1019
+ * Sequential by default; pass a `concurrency` option to run more than one Task
1020
+ * at a time.
1021
+ *
1022
+ * Similar to
1023
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any | Promise.any},
1024
+ * but races Tasks, returns Result values, and aborts losers.
1025
+ *
681
1026
  * ### Example
682
1027
  *
683
1028
  * ```ts
684
- * const fast: Task<string> = () => ok("fast");
685
- * const slow: Task<string> = async (run) => {
686
- * await run(sleep("10ms"));
687
- * return ok("slow");
1029
+ * import {
1030
+ * any,
1031
+ * createRun,
1032
+ * err,
1033
+ * ok,
1034
+ * type Result,
1035
+ * type Task,
1036
+ * } from "@evolu/common";
1037
+ *
1038
+ * const unavailable: Task<string, "Unavailable"> = () =>
1039
+ * err("Unavailable");
1040
+ * let fallbackStarted = false;
1041
+ * const fallback: Task<string> = () => {
1042
+ * fallbackStarted = true;
1043
+ * return ok("fallback");
688
1044
  * };
689
1045
  *
690
- * // First wins, others are aborted.
691
- * const result = await run(race([fast, slow])); // ok("fast")
692
- * ```
1046
+ * await using run = createRun();
1047
+ * const result = await run(any([unavailable, fallback]));
693
1048
  *
694
- * Always runs with unlimited concurrency — a sequential race makes no sense
695
- * since the first Task would always "win".
1049
+ * expectTypeOf(result).toEqualTypeOf<Result<string, "Unavailable">>();
1050
+ * expectOk(result, "fallback");
1051
+ * expect(fallbackStarted).toBe(true);
1052
+ * ```
696
1053
  *
697
- * @group Composition
1054
+ * @group Racing
698
1055
  */
699
- export const race = (tasks, { abortReason = raceLostError, } = {}) => concurrently(pool(tasks, { stopOn: "first", collect: false, abortReason }));
1056
+ export const any = (tasks, options) => async (run) => {
1057
+ let firstOk;
1058
+ let lastErr;
1059
+ let lastErrIndex = -1;
1060
+ await run(each(tasks, (result, index) => {
1061
+ if (result.ok) {
1062
+ firstOk = result;
1063
+ return "stop";
1064
+ }
1065
+ if (index > lastErrIndex) {
1066
+ lastErrIndex = index;
1067
+ lastErr = result;
1068
+ }
1069
+ return "continue";
1070
+ }, options));
1071
+ if (firstOk)
1072
+ return firstOk;
1073
+ assertNonNullable(lastErr);
1074
+ return lastErr;
1075
+ };
700
1076
  /**
701
- * Abort reason for tasks that lose a {@link race}.
1077
+ * Runs Tasks until the first Task settles.
702
1078
  *
703
- * @group Composition
704
- */
705
- export const RaceLostError = /*#__PURE__*/ typed("RaceLostError");
706
- /**
707
- * {@link RaceLostError} used as abort reason in {@link race}.
1079
+ * Returns the first Task {@link Result} to settle, whether {@link Ok} or
1080
+ * {@link Err}.
708
1081
  *
709
- * @group Composition
710
- */
711
- export const raceLostError = { type: "RaceLostError" };
712
- /**
713
- * Wraps a {@link Task} with a time limit.
1082
+ * Use {@link any} to wait for the first Ok instead.
1083
+ *
1084
+ * Losing Tasks are aborted.
714
1085
  *
715
- * Returns {@link TimeoutError} if the Task doesn't complete within the specified
716
- * duration. The original Task is aborted when the timeout fires.
1086
+ * Tasks always run concurrently because racing sequentially would be
1087
+ * meaningless.
1088
+ *
1089
+ * Similar to
1090
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race},
1091
+ * but races Tasks, returns Result values, and aborts losers.
1092
+ *
1093
+ * Requires a non-empty array: zero Tasks have no meaningful first settled
1094
+ * Result. This is enforced at compile time for non-empty tuple types. For
1095
+ * arrays whose emptiness is only known at runtime, guard with
1096
+ * {@link isNonEmptyArray}:
717
1097
  *
718
1098
  * ### Example
719
1099
  *
720
1100
  * ```ts
721
- * const fetchWithTimeout = timeout(fetchData, "5s");
1101
+ * import {
1102
+ * createRun,
1103
+ * isNonEmptyArray,
1104
+ * ok,
1105
+ * race,
1106
+ * type Task,
1107
+ * } from "@evolu/common";
722
1108
  *
723
- * const result = await run(fetchWithTimeout);
724
- * if (!result.ok && result.error.type === "TimeoutError") {
725
- * console.log("Request timed out");
1109
+ * const tasks: ReadonlyArray<Task<string>> = [() => ok("first")];
1110
+ * await using run = createRun();
1111
+ * if (isNonEmptyArray(tasks)) {
1112
+ * const result = await run(race(tasks));
1113
+ * expectOk(result, "first");
726
1114
  * }
727
1115
  * ```
728
1116
  *
729
- * @group Composition
730
- */
731
- export const timeout = (task, duration, { abortReason = timeoutError, } = {}) => race([
732
- task,
733
- async (run) => {
734
- await run(sleep(duration));
735
- return err(timeoutError);
736
- },
737
- ], { abortReason });
738
- /**
739
- * Typed error returned by {@link timeout} when a task exceeds its time limit.
1117
+ * ### Example
740
1118
  *
741
- * @group Composition
742
- */
743
- export const TimeoutError = /*#__PURE__*/ typed("TimeoutError");
744
- /**
745
- * {@link TimeoutError} used as abort reason in {@link timeout}.
1119
+ * ```ts
1120
+ * import {
1121
+ * createRun,
1122
+ * ok,
1123
+ * race,
1124
+ * sleep,
1125
+ * type Result,
1126
+ * type Task,
1127
+ * } from "@evolu/common";
1128
+ *
1129
+ * await using run = createRun();
1130
+ *
1131
+ * const fast: Task<string> = () => ok("fast");
1132
+ * let slowCompleted = false;
1133
+ * const slow: Task<string> = async (run) => {
1134
+ * await run.ok(sleep("10ms"));
1135
+ * slowCompleted = true;
1136
+ * return ok("slow");
1137
+ * };
746
1138
  *
747
- * @group Composition
1139
+ * // Input order does not matter: the first settled Result wins, and the
1140
+ * // still-running loser is aborted.
1141
+ * const result = await run(race([slow, fast]));
1142
+ * expectTypeOf(result).toEqualTypeOf<Result<string>>();
1143
+ * expectOk(result, "fast");
1144
+ * expect(slowCompleted).toBe(false);
1145
+ * ```
1146
+ *
1147
+ * @group Racing
748
1148
  */
749
- export const timeoutError = { type: "TimeoutError" };
1149
+ export const race = (tasks) => async (run) => {
1150
+ assertNonEmptyReadonlyArray(tasks);
1151
+ let firstResult;
1152
+ await run(each(tasks, (result) => {
1153
+ firstResult = result;
1154
+ return "stop";
1155
+ }, { concurrency: tasks.length }));
1156
+ assertNonNullable(firstResult);
1157
+ return firstResult;
1158
+ };
750
1159
  /**
751
- * Wraps a {@link Task} with retry logic.
1160
+ * Runs Tasks until `count` Tasks return {@link Ok} or all Tasks settle.
752
1161
  *
753
- * Retries the Task according to the {@link Schedule}'s rules. Use
754
- * {@link RetryOptions.retryable} to filter which errors should trigger retries.
1162
+ * Returns {@link Ok} with Ok values in settlement order, not input order.
1163
+ * {@link Err} Results are ignored. When `count` Ok values have settled,
1164
+ * remaining Tasks are aborted. If fewer than `count` Tasks return Ok, returns
1165
+ * the Ok values that did settle.
755
1166
  *
756
- * All non-abort errors are wrapped in {@link RetryError}:
1167
+ * Sequential by default; pass a `concurrency` option to run more than one Task
1168
+ * at a time. The count uses {@link Int1To100OrPositiveInt}: pass `1` to `100` as
1169
+ * a literal, or a validated {@link PositiveInt} for larger values.
757
1170
  *
758
- * - Task succeeds → `ok(value)`
759
- * - Task returns {@link AbortError} → `err(AbortError)` — passed through, no
760
- * retry, no wrapping
761
- * - Task returns any other error → retry until schedule exhausted or `retryable`
762
- * returns false → `err(RetryError)` with `cause` = the last error
1171
+ * ### Example
1172
+ *
1173
+ * ```ts
1174
+ * import {
1175
+ * createRun,
1176
+ * err,
1177
+ * firstN,
1178
+ * ok,
1179
+ * sleep,
1180
+ * type Task,
1181
+ * } from "@evolu/common";
763
1182
  *
764
- * The `RetryError` is informative: "I tried N times, here's why I finally gave
765
- * up" and `cause` contains the actual underlying error.
1183
+ * let slowCompleted = false;
1184
+ * const slow: Task<string, "Failed"> = async (run) => {
1185
+ * await run.ok(sleep("10ms"));
1186
+ * slowCompleted = true;
1187
+ * return ok("slow");
1188
+ * };
1189
+ * const tasks = [
1190
+ * slow,
1191
+ * () => err("Failed" as const),
1192
+ * () => ok("fast-1"),
1193
+ * () => ok("fast-2"),
1194
+ * ] as const;
1195
+ * await using run = createRun();
1196
+ *
1197
+ * // Errs do not count. After two Ok values settle, the slow Task is aborted.
1198
+ * const result = await run(firstN(tasks, 2, { concurrency: 4 }));
1199
+ * expectOk(result, ["fast-1", "fast-2"]);
1200
+ * expect(slowCompleted).toBe(false);
1201
+ * ```
1202
+ *
1203
+ * @group Racing
1204
+ */
1205
+ export const firstN = (tasks, count, options) => async (run) => {
1206
+ assertType(PositiveInt, count);
1207
+ const values = [];
1208
+ await run(each(tasks, (result) => {
1209
+ if (result.ok)
1210
+ values.push(result.value);
1211
+ return values.length < count ? "continue" : "stop";
1212
+ }, options));
1213
+ return ok(values);
1214
+ };
1215
+ /**
1216
+ * Runs Tasks until `count` Tasks settle or all Tasks settle.
1217
+ *
1218
+ * Returns {@link Ok} with Task {@link Result}s in settlement order, not input
1219
+ * order. When `count` Results have settled, remaining Tasks are aborted. If
1220
+ * fewer than `count` Tasks settle, returns the Results that did settle.
1221
+ *
1222
+ * Sequential by default; pass a `concurrency` option to run more than one Task
1223
+ * at a time. The count uses {@link Int1To100OrPositiveInt}: pass `1` to `100` as
1224
+ * a literal, or a validated {@link PositiveInt} for larger values.
766
1225
  *
767
1226
  * ### Example
768
1227
  *
769
1228
  * ```ts
770
1229
  * import {
771
- * exponential,
772
- * jitter,
773
- * maxDelay,
774
- * retry,
775
- * take,
1230
+ * createRun,
1231
+ * err,
1232
+ * firstNSettled,
1233
+ * ok,
1234
+ * sleep,
1235
+ * type Task,
776
1236
  * } from "@evolu/common";
777
1237
  *
778
- * const fetchWithRetry = retry(
779
- * fetchData,
780
- * // A jittered, capped, limited exponential backoff.
781
- * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
782
- * );
1238
+ * let slowCompleted = false;
1239
+ * const slow: Task<string, "Failed"> = async (run) => {
1240
+ * await run.ok(sleep("10ms"));
1241
+ * slowCompleted = true;
1242
+ * return ok("slow");
1243
+ * };
1244
+ * const tasks = [
1245
+ * slow,
1246
+ * () => err("Failed" as const),
1247
+ * () => ok("fast"),
1248
+ * ] as const;
1249
+ * await using run = createRun();
783
1250
  *
784
- * const result = await run(fetchWithRetry);
785
- * if (!result.ok) {
786
- * if (AbortError.is(result.error)) {
787
- * // Was aborted externally
788
- * } else {
789
- * // RetryError — failed after retrying
790
- * console.log(`Failed after ${result.error.attempts} attempts`);
791
- * console.log(`Last error:`, result.error.cause);
792
- * }
793
- * }
1251
+ * // Err and Ok both count, and Results use settlement order.
1252
+ * const result = await run(firstNSettled(tasks, 2, { concurrency: 3 }));
1253
+ * expectOk(result, [
1254
+ * { ok: false, error: "Failed" },
1255
+ * { ok: true, value: "fast" },
1256
+ * ]);
1257
+ * expect(slowCompleted).toBe(false);
794
1258
  * ```
795
1259
  *
796
- * The schedule receives the error as input, enabling error-aware strategies
797
- * like stopping on fatal errors:
1260
+ * @group Racing
1261
+ */
1262
+ export const firstNSettled = (tasks, count, options) => async (run) => {
1263
+ assertType(PositiveInt, count);
1264
+ const results = [];
1265
+ await run(each(tasks, (result) => {
1266
+ results.push(result);
1267
+ return results.length < count ? "continue" : "stop";
1268
+ }, options));
1269
+ return ok(results);
1270
+ };
1271
+ /**
1272
+ * Runs Tasks under a concurrency limit and calls `onResult` for each Task
1273
+ * {@link Result} as it settles.
1274
+ *
1275
+ * `onResult` receives the Result and the original input index; call order is
1276
+ * settlement order, not input order. Returning `continue` lets queued Tasks
1277
+ * start when capacity is available. Returning `stop` prevents queued Tasks from
1278
+ * starting and aborts already-running Tasks through structured Run disposal —
1279
+ * `each` still waits for them to settle before returning.
1280
+ *
1281
+ * `each` is the scheduling primitive under the collection helpers. Each one is
1282
+ * a small `onResult` policy:
1283
+ *
1284
+ * | Helper | Policy |
1285
+ * | --------------------- | ------------------------------------- |
1286
+ * | {@link all} | Collect values, stop on the first Err |
1287
+ * | {@link allSettled} | Collect every Result, never stop |
1288
+ * | {@link any} | Stop on the first Ok |
1289
+ * | {@link race} | Stop on the first settled Result |
1290
+ * | {@link firstN} | Stop after n Ok values |
1291
+ * | {@link firstNSettled} | Stop after n Results |
1292
+ *
1293
+ * Use `each` directly to build a collection policy the helpers don't cover. For
1294
+ * example, keep the first successful value together with its original input
1295
+ * index:
1296
+ *
1297
+ * ### Example
798
1298
  *
799
1299
  * ```ts
800
- * import { whileScheduleInput } from "@evolu/common";
1300
+ * import {
1301
+ * createRun,
1302
+ * each,
1303
+ * err,
1304
+ * ok,
1305
+ * sleep,
1306
+ * type Task,
1307
+ * } from "@evolu/common";
801
1308
  *
802
- * // Don't retry fatal errors
803
- * const smartRetry = retry(
804
- * fetchData,
805
- * whileScheduleInput((e: FetchError) => e.type !== "FatalError")(
806
- * take(5)(spaced("1s")),
1309
+ * let slowCompleted = false;
1310
+ * const slow: Task<string> = async (run) => {
1311
+ * await run.ok(sleep("10ms"));
1312
+ * slowCompleted = true;
1313
+ * return ok("slow");
1314
+ * };
1315
+ * const tasks = [
1316
+ * slow,
1317
+ * () => err("Unavailable" as const),
1318
+ * () => ok("fast"),
1319
+ * ] as const;
1320
+ * let first: readonly [string, number] | undefined;
1321
+ * await using run = createRun();
1322
+ * const result = await run(
1323
+ * each(
1324
+ * tasks,
1325
+ * (result, index) => {
1326
+ * if (!result.ok) return "continue";
1327
+ * first = [result.value, index];
1328
+ * return "stop";
1329
+ * },
1330
+ * { concurrency: 2 },
807
1331
  * ),
808
1332
  * );
1333
+ *
1334
+ * expectOk(result, undefined);
1335
+ * expect(first).toEqual(["fast", 2]);
1336
+ * expect(slowCompleted).toBe(false);
809
1337
  * ```
810
1338
  *
811
- * @group Composition
812
- * @see {@link RetryOptions}
1339
+ * `onResult` is a synchronous scheduling decision, not a place to do work. It
1340
+ * runs in the scheduler's own continuation, bracketed by abort checks, and its
1341
+ * return value gates whether queued Tasks may start. For async work per result,
1342
+ * put it inside the Task itself — the Task is the async slot — or start a
1343
+ * supervised side effect with `void run(task)` from inside the callback and
1344
+ * keep the decision synchronous. Like {@link RetryOptions.shouldRetry} and
1345
+ * {@link RetryOptions.onRetry}, `onResult` must not throw: a thrown exception is
1346
+ * a defect that panics the Run tree.
1347
+ *
1348
+ * Sequential by default; pass a `concurrency` option to run more than one Task
1349
+ * at a time. Defects from child Tasks keep caller-linked async stack traces;
1350
+ * building on `each` preserves diagnostics that a hand-rolled scheduling loop
1351
+ * typically loses.
1352
+ *
1353
+ * @group Concurrency
813
1354
  */
814
- export const retry = (task, schedule, { retryable = lazyTrue, onRetry } = {}) => async (run) => {
815
- const step = schedule(run.deps);
816
- let attempt = onePositiveInt;
817
- let error;
818
- for (;;) {
819
- if (error !== undefined) {
820
- const scheduleResult = step(error);
821
- if (!scheduleResult.ok) {
822
- return err({
823
- type: "RetryError",
824
- cause: error,
825
- attempts: PositiveInt.orThrow(decrement(attempt)),
826
- });
1355
+ export const each = (tasks, onResult, options = {}) => async (run) => {
1356
+ const env_1 = { stack: [], error: void 0, hasError: false };
1357
+ try {
1358
+ // Guard against hanging on an empty array.
1359
+ assertNonEmptyReadonlyArray(tasks);
1360
+ const parked = new Promise(constVoid);
1361
+ const wake = Promise.withResolvers();
1362
+ const _ = __addDisposableResource(env_1, run.onAbort(wake.reject), false);
1363
+ let stopped = false;
1364
+ let nextIndex = 0;
1365
+ const workerCount = Math.min(options.concurrency ?? onePositiveInt, tasks.length);
1366
+ let active = workerCount;
1367
+ // This topology is measured by StackTrace.test.ts ("pool parked" and
1368
+ // "pool parked wake reject"). Defects and stacks must travel await/
1369
+ // combinator edges, not resolver/then bridges; V8 drops parent frames when
1370
+ // a started worker is re-raced, JSC drops them through nested combinators.
1371
+ // Workers are direct race arms and never resolve — non-panic child
1372
+ // AbortErrors reject a worker arm; panics and caller abort reject the wake
1373
+ // via run.onAbort; drain and stop resolve it. `parked` must stay per-call:
1374
+ // a shared never-settling promise would retain worker reactions forever.
1375
+ const eachWorker = async () => {
1376
+ try {
1377
+ while (!stopped && nextIndex < tasks.length) {
1378
+ const index = nextIndex;
1379
+ nextIndex += 1;
1380
+ const result = await run(tasks[index]);
1381
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- stopped can flip across the await via sibling workers
1382
+ if (stopped)
1383
+ break;
1384
+ run.signal.throwIfAborted();
1385
+ const decision = onResult(result, index);
1386
+ run.signal.throwIfAborted();
1387
+ if (decision === "stop") {
1388
+ stopped = true;
1389
+ wake.resolve();
1390
+ }
1391
+ }
827
1392
  }
828
- const [output, delay] = scheduleResult.value;
829
- onRetry?.({
830
- error,
831
- attempt: PositiveInt.orThrow(decrement(attempt)),
832
- output,
833
- delay,
834
- });
835
- if (delay > 0) {
836
- const sleepResult = await run(sleep(delay));
837
- if (!sleepResult.ok)
838
- return sleepResult;
1393
+ catch (error) {
1394
+ stopped = true;
1395
+ throw error;
839
1396
  }
840
- }
841
- const result = await run(task);
842
- if (result.ok)
843
- return result;
844
- if (AbortError.is(result.error))
845
- return err(result.error);
846
- error = result.error;
847
- if (!retryable(error)) {
848
- return err({
849
- type: "RetryError",
850
- cause: error,
851
- attempts: attempt,
852
- });
853
- }
854
- attempt = PositiveInt.orThrow(increment(attempt));
1397
+ active -= 1;
1398
+ if (active === 0)
1399
+ wake.resolve();
1400
+ return await parked;
1401
+ };
1402
+ await Promise.race([
1403
+ ...Array.from({ length: workerCount }, eachWorker),
1404
+ wake.promise,
1405
+ ]);
1406
+ return ok();
1407
+ }
1408
+ catch (e_1) {
1409
+ env_1.error = e_1;
1410
+ env_1.hasError = true;
1411
+ }
1412
+ finally {
1413
+ __disposeResources(env_1);
855
1414
  }
856
1415
  };
857
1416
  /**
858
- * Repeats a {@link Task} according to a {@link Schedule}.
1417
+ * Assigns static scheduler priority to a {@link Task}.
1418
+ *
1419
+ * {@link Run} uses the priority with native `scheduler.postTask` when available.
1420
+ * Platforms without the Scheduler API run the Task normally. Use a Scheduler
1421
+ * API polyfill such as
1422
+ * {@link https://github.com/GoogleChromeLabs/scheduler-polyfill | scheduler-polyfill}
1423
+ * for hosts like Safari and React Native when priority-aware scheduling is
1424
+ * needed.
859
1425
  *
860
- * Runs the Task, then checks the schedule to determine if it should repeat. The
861
- * schedule controls how many repetitions occur and the delay between them.
862
- * Continues until the schedule returns `Err(Done<void>)` or the Task fails.
1426
+ * `scheduler.postTask` must reject queued aborts with `AbortSignal.reason`.
1427
+ * Other host-specific abort objects are treated as defects.
863
1428
  *
864
- * With `take(n)`, the task runs n+1 times (initial run plus n repetitions).
1429
+ * ### Example
1430
+ *
1431
+ * ```ts
1432
+ * import { createRun, ok, prioritized, type Task } from "@evolu/common";
1433
+ *
1434
+ * const rebuildSearchIndex: Task<string> = () => ok("indexed");
1435
+ * const backgroundIndexing = prioritized("background", rebuildSearchIndex);
865
1436
  *
866
- * Also works with {@link NextTask} — when the Task returns `Err(Done<D>)`,
867
- * repeat stops and propagates the done signal.
1437
+ * await using run = createRun();
1438
+ * expectOk(await run(backgroundIndexing), "indexed");
1439
+ * ```
1440
+ *
1441
+ * @group Scheduling
1442
+ */
1443
+ export const prioritized = (priority, task) => withTaskMeta({ priority })(task);
1444
+ /**
1445
+ * Yields execution to the host scheduler.
1446
+ *
1447
+ * Uses native `scheduler.yield()` when available, `setImmediate` when
1448
+ * available, and `setTimeout` elsewhere. Because this is a Task, `await
1449
+ * run(yieldNow)` is an explicit abortable checkpoint and is visible in Run
1450
+ * monitoring.
1451
+ *
1452
+ * For example, call it periodically in a long-running synchronous loop to let
1453
+ * the host process rendering, input, and other scheduled work.
868
1454
  *
869
1455
  * ### Example
870
1456
  *
871
1457
  * ```ts
872
- * import { fixed, take } from "@evolu/common/schedule";
873
- * import { repeat } from "@evolu/common";
1458
+ * import { createRun, ok, yieldNow, type Task } from "@evolu/common";
874
1459
  *
875
- * // Heartbeat every 30 seconds (runs forever until aborted)
876
- * const heartbeat = repeat(sendHeartbeat, fixed("30s"));
1460
+ * const sumTo =
1461
+ * (count: number): Task<number> =>
1462
+ * async (run) => {
1463
+ * let sum = 0;
877
1464
  *
878
- * // Poll 4 times total (initial + 3 repetitions), 1 second apart
879
- * const poll = repeat(checkStatus, take(3)(fixed("1s")));
1465
+ * for (let index = 0; index < count; index++) {
1466
+ * if (index > 0 && index % 1000 === 0) await run.ok(yieldNow);
1467
+ * sum += index;
1468
+ * }
880
1469
  *
881
- * // Process queue items until empty (NextTask pattern)
882
- * const processQueue: NextTask<Item, ProcessError, void> = async (run) => {
883
- * const item = queue.dequeue();
884
- * if (!item) return err(done()); // Queue empty, stop
885
- * await process(item);
886
- * return ok(item);
887
- * };
1470
+ * return ok(sum);
1471
+ * };
888
1472
  *
889
- * const result = await run(repeat(processQueue, fixed("100ms")));
890
- * if (!result.ok && result.error.type === "Done") {
891
- * console.log("Queue exhausted");
892
- * }
1473
+ * await using run = createRun();
1474
+ * expectOk(await run(sumTo(1001)), 500500);
893
1475
  * ```
894
1476
  *
895
- * @group Composition
1477
+ * @group Scheduling
896
1478
  */
897
- export const repeat = (task, schedule, { repeatable = lazyTrue, onRepeat } = {}) => async (run) => {
898
- const step = schedule(run.deps);
899
- let lastResult;
900
- let attempt = onePositiveInt;
901
- for (;;) {
902
- const result = await run(task);
903
- if (!result.ok)
904
- return result;
905
- lastResult = result;
906
- if (!repeatable(result.value))
907
- return lastResult;
908
- const next = step(result.value);
909
- if (!next.ok)
910
- break;
911
- const [output, delay] = next.value;
912
- onRepeat?.({
913
- value: result.value,
914
- attempt,
915
- output,
916
- delay,
1479
+ export const yieldNow = async (run) => {
1480
+ const scheduler = globalWithScheduler.scheduler;
1481
+ if (scheduler?.yield) {
1482
+ await scheduler.yield();
1483
+ }
1484
+ else {
1485
+ // Intentionally bypasses deps.time: yielding is a host-scheduler concern,
1486
+ // not logical time, and fake time would block awaiting code in tests. The
1487
+ // timer is not cleared on abort; throwIfAborted below observes abort after
1488
+ // the tick, delaying settlement by at most one timer tick.
1489
+ await new Promise((resolve) => {
1490
+ const immediate = globalWithSetImmediate.setImmediate;
1491
+ if (immediate)
1492
+ immediate(resolve);
1493
+ else
1494
+ setTimeout(resolve, 0);
917
1495
  });
918
- attempt = PositiveInt.orThrow(increment(attempt));
919
- if (delay > 0) {
920
- const sleepResult = await run(sleep(delay));
921
- if (!sleepResult.ok)
922
- return sleepResult;
923
- }
924
1496
  }
925
- return lastResult;
1497
+ run.signal.throwIfAborted();
1498
+ return ok();
926
1499
  };
1500
+ // Abortability
927
1501
  /**
928
- * Creates a {@link Deferred}.
1502
+ * Waits until the current {@link Run} aborts, then rejects with its
1503
+ * {@link AbortError}.
929
1504
  *
930
- * @group Concurrency primitives
931
- */
932
- export const createDeferred = () => {
933
- let resolved = null;
934
- const resolvers = new Set();
935
- const resolve = (result) => {
936
- if (resolved !== null)
937
- return false;
938
- resolved = result;
939
- for (const resolver of resolvers)
940
- resolver(result);
941
- resolvers.clear();
942
- return true;
943
- };
944
- return {
945
- task: (run) => {
946
- if (resolved !== null)
947
- return resolved;
948
- return new Promise((resolvePromise) => {
949
- const resolve = (result) => {
950
- resolvers.delete(resolve);
951
- resolvePromise(result);
952
- };
953
- resolvers.add(resolve);
954
- run.onAbort((reason) => {
955
- resolve(err(createAbortError(reason)));
956
- });
957
- });
958
- },
959
- resolve,
960
- [Symbol.dispose]: () => {
961
- resolve(err(deferredDisposedError));
962
- },
963
- };
964
- };
965
- /**
966
- * Abort reason used when a {@link Deferred} is disposed.
1505
+ * Use it to keep long-running services and Run-owned resources alive until
1506
+ * their owning Run shuts down.
967
1507
  *
968
- * @group Concurrency primitives
969
- */
970
- export const DeferredDisposedError = /*#__PURE__*/ typed("DeferredDisposedError");
971
- /**
972
- * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}.
1508
+ * ### Example
973
1509
  *
974
- * @group Concurrency primitives
1510
+ * ```ts
1511
+ * import {
1512
+ * AbortError,
1513
+ * createRun,
1514
+ * ok,
1515
+ * waitForAbort,
1516
+ * type Task,
1517
+ * } from "@evolu/common";
1518
+ *
1519
+ * interface ServerDep {
1520
+ * readonly port: number;
1521
+ * }
1522
+ * interface Server extends AsyncDisposable {}
1523
+ *
1524
+ * const serverStarted = Promise.withResolvers<void>();
1525
+ * let serverStopped = false;
1526
+ * const startServer: Task<Server, never, ServerDep> = ({ deps }) => {
1527
+ * expect(deps.port).toBe(3000);
1528
+ * serverStarted.resolve();
1529
+ * return ok({
1530
+ * [Symbol.asyncDispose]: async () => {
1531
+ * serverStopped = true;
1532
+ * },
1533
+ * });
1534
+ * };
1535
+ *
1536
+ * const serve = (): Task<never, never, ServerDep> => async (run) => {
1537
+ * await using server = await run.ok(startServer);
1538
+ * return await run(waitForAbort);
1539
+ * };
1540
+ *
1541
+ * expectTypeOf(serve).returns.toEqualTypeOf<
1542
+ * Task<never, never, ServerDep>
1543
+ * >();
1544
+ *
1545
+ * await using run = createRun();
1546
+ * const fiber = run.abortable(serve(), { port: 3000 });
1547
+ * await serverStarted.promise;
1548
+ * fiber.abort();
1549
+ *
1550
+ * const result = await fiber;
1551
+ * assert(!result.ok);
1552
+ * expect(AbortError.is(result.error)).toBe(true);
1553
+ * expect(serverStopped).toBe(true);
1554
+ * ```
1555
+ *
1556
+ * @group Abortability
975
1557
  */
976
- export const deferredDisposedError = {
977
- type: "DeferredDisposedError",
1558
+ export const waitForAbort = async (run) => {
1559
+ const env_2 = { stack: [], error: void 0, hasError: false };
1560
+ try {
1561
+ const aborted = Promise.withResolvers();
1562
+ const _ = __addDisposableResource(env_2, run.onAbort(aborted.reject), false);
1563
+ return await aborted.promise;
1564
+ }
1565
+ catch (e_2) {
1566
+ env_2.error = e_2;
1567
+ env_2.hasError = true;
1568
+ }
1569
+ finally {
1570
+ __disposeResources(env_2);
1571
+ }
978
1572
  };
979
1573
  /**
980
- * Creates a {@link Gate} that starts closed.
981
- *
982
- * Useful for "stop/go" logic where multiple Tasks need to wait for a state
983
- * change.
1574
+ * Starts a {@link Task} with {@link Run.daemon} and waits until it settles or the
1575
+ * current Run aborts.
1576
+ *
1577
+ * When the current Run aborts, this helper requests abort for the daemon Task
1578
+ * and returns {@link AbortError} without waiting for that daemon Task to observe
1579
+ * abort, clean up, or settle. This makes the wait abortable, not the underlying
1580
+ * execution. The daemon Task continues under root Run ownership until it
1581
+ * settles, observes abort, or the root Run is disposed.
1582
+ *
1583
+ * This is not a replacement for direct {@link AbortSignal} support in operations
1584
+ * that can observe abort, such as {@link fetch}, timers that accept a signal, or
1585
+ * callback APIs that accept a signal. Use it as an escape hatch for Tasks that
1586
+ * ignore abort when an abort request must stop waiting immediately.
1587
+ *
1588
+ * Do not wrap a Task that keeps using a resource the caller may release after
1589
+ * this wrapper returns, unless the Task reliably observes abort before using
1590
+ * that resource. The daemon Task can continue after the caller stops waiting.
1591
+ * Later domain `Err` results from the daemon Task are discarded after the
1592
+ * caller stops waiting. Defects from the daemon Task remain visible to the root
1593
+ * Run: if it later throws or rejects, the root Run still panics and reports the
1594
+ * defect.
1595
+ *
1596
+ * Compose with {@link race} or {@link timeout} when the losing Task must not
1597
+ * delay the winner. Those helpers normally abort losing Tasks and wait for them
1598
+ * to settle, keeping cleanup and late defects inside the caller's lifetime. A
1599
+ * Task that ignores abort can keep them waiting.
1600
+ *
1601
+ * `run.abortable(task)` returns an owned child Fiber and requests abort through
1602
+ * that Fiber; `daemon(task)` starts a daemon child and stops waiting when the
1603
+ * current Run aborts. {@link unabortable} masks abort for a Task that must
1604
+ * finish once started; daemon lets a Task outlive the caller.
1605
+ *
1606
+ * Because the Task starts with {@link Run.daemon}, a recorded abort request
1607
+ * returns AbortError before the Task starts — including a request masked by
1608
+ * {@link unabortable}, even though `run.signal` stays un-aborted inside the
1609
+ * mask. Inside a masked body, wrapping a Task with `daemon` opts the wait back
1610
+ * into abort observation; omit the wrapper when the mask should keep the Task
1611
+ * running.
984
1612
  *
985
1613
  * ### Example
986
1614
  *
987
1615
  * ```ts
988
- * const networkGate = createGate();
1616
+ * import {
1617
+ * createRun,
1618
+ * daemon,
1619
+ * ok,
1620
+ * timeout,
1621
+ * type Task,
1622
+ * } from "@evolu/common";
989
1623
  *
990
- * // Pause processing when offline
991
- * const onOffline = () => networkGate.close();
1624
+ * let finished = false;
1625
+ * let finishTask = (): void => {};
1626
+ * const taskNotUsingAbort: Task<string> = () =>
1627
+ * new Promise((resolve) => {
1628
+ * finishTask = () => {
1629
+ * finished = true;
1630
+ * resolve(ok("done"));
1631
+ * };
1632
+ * });
992
1633
  *
993
- * // Resume processing when online
994
- * const onOnline = () => networkGate.open();
1634
+ * {
1635
+ * await using run = createRun();
1636
+ * const result = await run(timeout(daemon(taskNotUsingAbort), "1ms"));
1637
+ * assert(!result.ok);
1638
+ * expect(result.error.type).toBe("TimeoutError");
1639
+ * expect(finished).toBe(false);
1640
+ * finishTask();
1641
+ * }
1642
+ * expect(finished).toBe(true);
1643
+ * ```
995
1644
  *
996
- * const syncLoop = async (run) => {
997
- * while (true) {
998
- * // Blocks here whenever the gate is closed
999
- * await run(networkGate.wait);
1000
- * await run(uploadNextItem);
1001
- * }
1645
+ * Promise-producing operations should start inside the Task, not before it.
1646
+ *
1647
+ * ```ts
1648
+ * import { createRun, ok, type Result, type Task } from "@evolu/common";
1649
+ *
1650
+ * type ResultValue = string;
1651
+ * interface MyError {
1652
+ * readonly type: "MyError";
1653
+ * }
1654
+ * const createPromiseReturningResult = (): Promise<
1655
+ * Result<ResultValue, MyError>
1656
+ * > => Promise.resolve(ok("value"));
1657
+ *
1658
+ * const task: Task<ResultValue, MyError> = () =>
1659
+ * createPromiseReturningResult();
1660
+ *
1661
+ * await using run = createRun();
1662
+ * expectOk(await run(task), "value");
1663
+ * ```
1664
+ *
1665
+ * Do not reuse an already-running Promise. It started outside the Task, so the
1666
+ * Run cannot own its lifetime or request abort before it begins.
1667
+ *
1668
+ * ```ts
1669
+ * import { ok, type Result, type Task } from "@evolu/common";
1670
+ *
1671
+ * type ResultValue = string;
1672
+ * interface MyError {
1673
+ * readonly type: "MyError";
1674
+ * }
1675
+ * let promiseStarted = false;
1676
+ * const createPromiseReturningResult = (): Promise<
1677
+ * Result<ResultValue, MyError>
1678
+ * > => {
1679
+ * promiseStarted = true;
1680
+ * return Promise.resolve(ok("value"));
1002
1681
  * };
1682
+ *
1683
+ * // Wrong: the Promise starts now, before a Run starts the Task.
1684
+ * const promise = createPromiseReturningResult();
1685
+ * const task: Task<ResultValue, MyError> = () => promise;
1686
+ *
1687
+ * expect(promiseStarted).toBe(true);
1688
+ * expectTypeOf(task).toEqualTypeOf<Task<ResultValue, MyError>>();
1003
1689
  * ```
1004
1690
  *
1005
- * @group Concurrency primitives
1691
+ * @group Lifetime
1006
1692
  */
1007
- export const createGate = () => {
1008
- const env_1 = { stack: [], error: void 0, hasError: false };
1693
+ export const daemon = (task) => async (run) => {
1694
+ const env_3 = { stack: [], error: void 0, hasError: false };
1009
1695
  try {
1010
- const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
1011
- let isOpen = false;
1012
- let deferred = createDeferred();
1013
- disposer.defer(() => {
1014
- deferred[Symbol.dispose]();
1015
- });
1016
- const disposables = disposer.move();
1017
- return {
1018
- wait: (run) => {
1019
- if (disposables.disposed)
1020
- return err(deferredDisposedError);
1021
- if (isOpen)
1022
- return ok();
1023
- return run(deferred.task);
1024
- },
1025
- open: () => {
1026
- assertNotDisposed(disposables);
1027
- if (isOpen)
1028
- return;
1029
- isOpen = true;
1030
- deferred.resolve(ok());
1031
- },
1032
- close: () => {
1033
- assertNotDisposed(disposables);
1034
- if (!isOpen)
1035
- return;
1036
- isOpen = false;
1037
- deferred = createDeferred();
1038
- },
1039
- isOpen: () => {
1040
- assertNotDisposed(disposables);
1041
- return isOpen;
1042
- },
1043
- [Symbol.dispose]: () => disposables.dispose(),
1044
- };
1696
+ let fiber;
1697
+ try {
1698
+ fiber = run.daemon(task);
1699
+ }
1700
+ catch (error) {
1701
+ if (AbortError.is(error))
1702
+ return err(error);
1703
+ throw error;
1704
+ }
1705
+ const aborted = Promise.withResolvers();
1706
+ const _ = __addDisposableResource(env_3, run.onAbort((abortError) => {
1707
+ fiber.abort(abortError.reason);
1708
+ aborted.resolve(err(abortError));
1709
+ }), false);
1710
+ return await Promise.race([fiber, aborted.promise]);
1045
1711
  }
1046
- catch (e_1) {
1047
- env_1.error = e_1;
1048
- env_1.hasError = true;
1712
+ catch (e_3) {
1713
+ env_3.error = e_3;
1714
+ env_3.hasError = true;
1049
1715
  }
1050
1716
  finally {
1051
- __disposeResources(env_1);
1717
+ __disposeResources(env_3);
1052
1718
  }
1053
1719
  };
1054
1720
  /**
1055
- * Creates a {@link Semaphore} that limits concurrent {@link Task}s.
1721
+ * Makes a {@link Task} unabortable after it starts.
1722
+ *
1723
+ * Abort requests are masked while the Task runs, so `run.signal.aborted`
1724
+ * remains false inside the Task. This does not force the Task to start after an
1725
+ * abort request has already reached its Run; unabortable means the Task is not
1726
+ * interrupted once it has started. Disposing the enclosing Run still waits for
1727
+ * the Task to settle.
1728
+ *
1729
+ * Apply at most one abort behavior helper to a Task: do not wrap the same Task
1730
+ * with both unabortable and restore, or apply either helper more than once.
1056
1731
  *
1057
1732
  * ### Example
1058
1733
  *
1059
1734
  * ```ts
1735
+ * import { createRun, ok, unabortable, type Task } from "@evolu/common";
1736
+ *
1737
+ * const commitStarted = Promise.withResolvers<void>();
1738
+ * const finishCommit = Promise.withResolvers<void>();
1739
+ * const commit: Task<string> = unabortable(async ({ signal }) => {
1740
+ * commitStarted.resolve();
1741
+ * await finishCommit.promise;
1742
+ * expect(signal.aborted).toBe(false);
1743
+ * return ok("committed");
1744
+ * });
1745
+ *
1060
1746
  * await using run = createRun();
1747
+ * const fiber = run.abortable(commit);
1748
+ * await commitStarted.promise;
1749
+ * fiber.abort();
1750
+ * finishCommit.resolve();
1061
1751
  *
1062
- * const semaphore = createSemaphore(PositiveInt.orThrow(2));
1752
+ * expectOk(await fiber, "committed");
1753
+ * ```
1063
1754
  *
1064
- * const fetchUser =
1065
- * (id: string): Task<string> =>
1755
+ * @group Abortability
1756
+ */
1757
+ export const unabortable = /*#__PURE__*/ withTaskMeta({
1758
+ abortBehavior: "unabortable",
1759
+ });
1760
+ /**
1761
+ * Like {@link unabortable}, but provides `restore` for child Tasks that should
1762
+ * run with the previous abort mask.
1763
+ *
1764
+ * Use this for acquire/use/release flows where acquire and release must finish
1765
+ * once started, while use should remain abortable. Child Tasks inherit the mask
1766
+ * unless they are wrapped with `restore()` before scheduling.
1767
+ *
1768
+ * An abort request before the mask Task starts prevents entering the mask. Once
1769
+ * the body starts, plain child Tasks inherit the mask, so acquire and release
1770
+ * can run after abort. Put release operations directly in the original mask's
1771
+ * `finally`; do not wrap release in a nested `unabortableMask`, which is a new
1772
+ * critical-section entry and may not start after abort.
1773
+ *
1774
+ * {@link AsyncDisposableStack} is often enough for ordinary acquire/release. Use
1775
+ * it inside `unabortableMask` when masked acquire/release spans multiple or
1776
+ * conditional resources.
1777
+ *
1778
+ * ### Example
1779
+ *
1780
+ * ```ts
1781
+ * import {
1782
+ * AbortError,
1783
+ * createRun,
1784
+ * ok,
1785
+ * unabortableMask,
1786
+ * waitForAbort,
1787
+ * type Task,
1788
+ * } from "@evolu/common";
1789
+ *
1790
+ * await using run = createRun();
1791
+ *
1792
+ * interface Resource {
1793
+ * readonly id: string;
1794
+ * }
1795
+ *
1796
+ * const acquire: Task<Resource> = () => ok({ id: "resource-1" });
1797
+ * const operationStarted = Promise.withResolvers<void>();
1798
+ * const operate =
1799
+ * (resource: Resource): Task<never> =>
1066
1800
  * async (run) => {
1067
- * const { console } = run.deps;
1068
- * console.log("[demo]", "start", id);
1069
- * const slept = await run(sleep("10ms"));
1070
- * if (!slept.ok) return slept;
1071
- * console.log("[demo]", "end", id);
1072
- * return ok(`user:${id}`);
1801
+ * expect(resource.id).toBe("resource-1");
1802
+ * operationStarted.resolve();
1803
+ * return await run(waitForAbort);
1804
+ * };
1805
+ * let released = false;
1806
+ * const release =
1807
+ * (_resource: Resource): Task<void> =>
1808
+ * ({ signal }) => {
1809
+ * // Release inherits the mask even after abort was requested.
1810
+ * expect(signal.aborted).toBe(false);
1811
+ * released = true;
1812
+ * return ok();
1073
1813
  * };
1074
1814
  *
1075
- * const fetchWithPermit = (id: string) =>
1076
- * semaphore.withPermit(fetchUser(id));
1815
+ * const fiber = run.abortable(
1816
+ * unabortableMask((restore) => async (run) => {
1817
+ * // Acquire with abort masked.
1818
+ * const resource = await run.ok(acquire);
1819
+ *
1820
+ * try {
1821
+ * // Use with the previous abort mask restored.
1822
+ * return await run(restore(operate(resource)));
1823
+ * } finally {
1824
+ * // Release with abort masked.
1825
+ * await run.ok(release(resource));
1826
+ * }
1827
+ * }),
1828
+ * );
1077
1829
  *
1078
- * await Promise.all([
1079
- * run(fetchWithPermit("1")),
1080
- * run(fetchWithPermit("2")),
1081
- * run(fetchWithPermit("3")),
1082
- * ]);
1830
+ * await operationStarted.promise;
1831
+ * fiber.abort();
1832
+ * const result = await fiber;
1833
+ * assert(!result.ok);
1834
+ * expect(AbortError.is(result.error)).toBe(true);
1835
+ * expect(released).toBe(true);
1836
+ * ```
1837
+ *
1838
+ * @group Abortability
1839
+ */
1840
+ export const unabortableMask = (fn) => unabortable((run) => {
1841
+ const runInternal = run;
1842
+ // Only verifies the Run is masked. A direct call inside an already masked
1843
+ // Run passes undetected; direct calls bypass Run semantics by design.
1844
+ assert(runInternal.abortMask > abortableMask, "unabortableMask requires a masked Run; use run(task), not a direct call");
1845
+ const restoreToken = Symbol();
1846
+ // The token is local to this Task Run; descendant Runs inherit the token
1847
+ // set so helpers can receive restore while the mask Task is alive.
1848
+ // Each set is bounded by its Run lifetime and is intentionally not pruned.
1849
+ runInternal.restoreTokens = new Set(runInternal.restoreTokens).add(restoreToken);
1850
+ return run(fn(withTaskMeta({
1851
+ abortBehavior: {
1852
+ abortMask: decrement(runInternal.abortMask),
1853
+ restoreToken,
1854
+ },
1855
+ })));
1856
+ });
1857
+ /**
1858
+ * Runs acquire, use, and release as one bracketed {@link Task}.
1859
+ *
1860
+ * Acquire and release are masked once the helper starts, while use runs with
1861
+ * the previous abort mask restored. If acquire returns an error, release is not
1862
+ * run. Release must not return recoverable errors; handle expected cleanup
1863
+ * failures inside the release Task.
1083
1864
  *
1084
- * // [demo] start 1
1085
- * // [demo] start 2
1086
- * // [demo] end 1
1087
- * // [demo] start 3
1088
- * // [demo] end 2
1089
- * // [demo] end 3
1865
+ * Use this when a resource, lease, lock, or session must always be released
1866
+ * after it is acquired, even when use fails or is aborted.
1867
+ *
1868
+ * Prefer native `using`, `await using`, or {@link AsyncDisposableStack} for
1869
+ * owned values that implement {@link Disposable} or {@link AsyncDisposable}. Use
1870
+ * `acquireUseRelease` when acquisition must be balanced with a separate release
1871
+ * operation, such as unlocking, returning a pooled value, releasing a lease, or
1872
+ * logging out of a session.
1873
+ *
1874
+ * ### Example
1875
+ *
1876
+ * ```ts
1877
+ * import {
1878
+ * acquireUseRelease,
1879
+ * createRun,
1880
+ * err,
1881
+ * ok,
1882
+ * type Task,
1883
+ * } from "@evolu/common";
1884
+ *
1885
+ * interface Connection {
1886
+ * readonly user: string;
1887
+ * readonly isAvailable: boolean;
1888
+ * }
1889
+ *
1890
+ * const openConnection: Task<Connection> = () =>
1891
+ * ok({ user: "Ada", isAvailable: false });
1892
+ * const loadUser =
1893
+ * (connection: Connection): Task<string, "Unavailable"> =>
1894
+ * () =>
1895
+ * connection.isAvailable ? ok(connection.user) : err("Unavailable");
1896
+ * let connectionClosed = false;
1897
+ * const closeConnection =
1898
+ * (_connection: Connection): Task<void> =>
1899
+ * () => {
1900
+ * connectionClosed = true;
1901
+ * return ok();
1902
+ * };
1903
+ *
1904
+ * const queryUser = acquireUseRelease(
1905
+ * openConnection,
1906
+ * loadUser,
1907
+ * (connection) => closeConnection(connection),
1908
+ * );
1909
+ *
1910
+ * await using run = createRun();
1911
+ * expectErr(await run(queryUser), "Unavailable");
1912
+ * // Release still runs when use returns a domain error.
1913
+ * expect(connectionClosed).toBe(true);
1090
1914
  * ```
1091
1915
  *
1092
- * @group Concurrency primitives
1916
+ * @group Abortability
1093
1917
  */
1094
- export const createSemaphore = (permits) => {
1095
- const env_2 = { stack: [], error: void 0, hasError: false };
1918
+ export const acquireUseRelease = (acquire, use, release) => unabortableMask((restore) => async (run) => {
1919
+ const resourceResult = await run(acquire);
1920
+ if (!resourceResult.ok)
1921
+ return resourceResult;
1096
1922
  try {
1097
- const disposer = __addDisposableResource(env_2, new DisposableStack(), false);
1098
- const fibers = new Set();
1099
- const waiters = [];
1100
- let taken = zeroNonNegativeInt;
1101
- disposer.defer(() => {
1102
- const env_3 = { stack: [], error: void 0, hasError: false };
1103
- try {
1104
- const disposer = __addDisposableResource(env_3, new DisposableStack(), false);
1105
- for (const fiber of fibers) {
1106
- disposer.adopt(fiber, (fiber) => {
1107
- fiber.abort(semaphoreDisposedError);
1108
- });
1109
- }
1110
- for (const waiter of waiters) {
1111
- waiter.resolve(err(semaphoreDisposedAbortError));
1112
- }
1113
- waiters.length = 0;
1114
- }
1115
- catch (e_3) {
1116
- env_3.error = e_3;
1117
- env_3.hasError = true;
1118
- }
1119
- finally {
1120
- __disposeResources(env_3);
1121
- }
1122
- });
1123
- const disposables = disposer.move();
1124
- const withPermits = (requestedPermits) => (task) => async (run) => {
1125
- const env_4 = { stack: [], error: void 0, hasError: false };
1126
- try {
1127
- const requested = PositiveInt.orThrow(requestedPermits);
1128
- assert(requested <= permits, "Requested permits must not exceed semaphore capacity.");
1129
- if (disposables.disposed)
1130
- return err(semaphoreDisposedAbortError);
1131
- if (waiters.length > 0 || taken + requested > permits) {
1132
- const waiter = Promise.withResolvers();
1133
- const waiting = {
1134
- permits: requested,
1135
- resolve: waiter.resolve,
1136
- };
1137
- waiters.push(waiting);
1138
- run.onAbort((reason) => {
1139
- const i = waiters.indexOf(waiting);
1140
- if (i >= 0)
1141
- waiters.splice(i, 1);
1142
- waiter.resolve(err(createAbortError(reason)));
1143
- });
1144
- const permit = await waiter.promise;
1145
- if (!permit.ok)
1146
- return permit;
1147
- }
1148
- else {
1149
- taken = NonNegativeInt.orThrow(taken + requested);
1150
- }
1151
- let fiber = null;
1152
- const _ = __addDisposableResource(env_4, {
1153
- [Symbol.dispose]: () => {
1154
- if (fiber)
1155
- fibers.delete(fiber);
1156
- taken = NonNegativeInt.orThrow(taken - requested);
1157
- while (waiters.length > 0) {
1158
- const waiter = waiters[0];
1159
- if (taken + waiter.permits > permits)
1160
- break;
1161
- waiters.shift();
1162
- taken = NonNegativeInt.orThrow(taken + waiter.permits);
1163
- waiter.resolve(ok());
1164
- }
1165
- },
1166
- }, false);
1167
- fiber = run(task);
1168
- fibers.add(fiber);
1169
- return await fiber;
1170
- }
1171
- catch (e_4) {
1172
- env_4.error = e_4;
1173
- env_4.hasError = true;
1174
- }
1175
- finally {
1176
- __disposeResources(env_4);
1177
- }
1178
- };
1179
- return {
1180
- withPermits,
1181
- withPermit: (task) => withPermits(1)(task),
1182
- snapshot: () => {
1183
- assertNotDisposed(disposables);
1184
- return {
1185
- permits,
1186
- taken,
1187
- waiting: NonNegativeInt.orThrow(waiters.length),
1188
- available: NonNegativeInt.orThrow(permits - taken),
1189
- isIdle: taken === 0 && waiters.length === 0,
1190
- disposed: disposables.disposed,
1191
- };
1192
- },
1193
- [Symbol.dispose]: () => disposables.dispose(),
1194
- };
1195
- }
1196
- catch (e_2) {
1197
- env_2.error = e_2;
1198
- env_2.hasError = true;
1923
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- `use` is an acquireUseRelease callback, not a React Hook.
1924
+ return await run(restore(use(resourceResult.value)));
1199
1925
  }
1200
1926
  finally {
1201
- __disposeResources(env_2);
1927
+ await run.ok(release(resourceResult.value));
1202
1928
  }
1203
- };
1929
+ });
1204
1930
  /**
1205
- * Abort reason used when a {@link Semaphore} is disposed.
1931
+ * Creates a {@link Deferred}.
1206
1932
  *
1207
1933
  * @group Concurrency primitives
1208
1934
  */
1209
- export const SemaphoreDisposedError = /*#__PURE__*/ typed("SemaphoreDisposedError");
1935
+ export const createDeferred = () => {
1936
+ let resolvedResult;
1937
+ const waiters = new Set();
1938
+ return {
1939
+ task: callback(({ resolve }) => {
1940
+ if (resolvedResult) {
1941
+ resolve(resolvedResult);
1942
+ return;
1943
+ }
1944
+ waiters.add(resolve);
1945
+ return () => {
1946
+ waiters.delete(resolve);
1947
+ };
1948
+ }),
1949
+ resolve: (result) => {
1950
+ if (resolvedResult)
1951
+ return false;
1952
+ resolvedResult = result;
1953
+ for (const wait of waiters)
1954
+ wait(result);
1955
+ waiters.clear();
1956
+ return true;
1957
+ },
1958
+ };
1959
+ };
1210
1960
  /**
1211
- * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}.
1961
+ * Creates a {@link Gate}.
1212
1962
  *
1213
1963
  * @group Concurrency primitives
1214
1964
  */
1215
- export const semaphoreDisposedError = {
1216
- type: "SemaphoreDisposedError",
1965
+ export const createGate = ({ isOpen = false, } = {}) => {
1966
+ let deferred = createDeferred();
1967
+ if (isOpen)
1968
+ deferred.resolve(ok());
1969
+ return {
1970
+ // Direct same-Run delegation is intentional so wait observes the current deferred.
1971
+ // eslint-disable-next-line evolu/no-direct-task-call
1972
+ wait: (run) => deferred.task(run),
1973
+ open: () => {
1974
+ if (isOpen)
1975
+ return false;
1976
+ isOpen = true;
1977
+ deferred.resolve(ok());
1978
+ return true;
1979
+ },
1980
+ close: () => {
1981
+ if (!isOpen)
1982
+ return false;
1983
+ isOpen = false;
1984
+ deferred = createDeferred();
1985
+ return true;
1986
+ },
1987
+ release: () => {
1988
+ if (isOpen)
1989
+ return false;
1990
+ deferred.resolve(ok());
1991
+ deferred = createDeferred();
1992
+ return true;
1993
+ },
1994
+ isOpen: () => isOpen,
1995
+ };
1217
1996
  };
1218
- const semaphoreDisposedAbortError = createAbortError(semaphoreDisposedError);
1219
- export function createSemaphoreByKey(permits, { lookup = identity } = {}) {
1220
- const env_5 = { stack: [], error: void 0, hasError: false };
1221
- try {
1222
- const disposer = __addDisposableResource(env_5, new DisposableStack(), false);
1223
- const semaphoresByKey = createLookupMap({
1224
- lookup,
1225
- });
1226
- disposer.defer(() => {
1227
- const env_6 = { stack: [], error: void 0, hasError: false };
1228
- try {
1229
- const disposer = __addDisposableResource(env_6, new DisposableStack(), false);
1230
- disposer.defer(() => {
1231
- semaphoresByKey.clear();
1232
- });
1233
- for (const semaphore of semaphoresByKey.values()) {
1234
- disposer.use(semaphore);
1235
- }
1236
- }
1237
- catch (e_6) {
1238
- env_6.error = e_6;
1239
- env_6.hasError = true;
1240
- }
1241
- finally {
1242
- __disposeResources(env_6);
1997
+ /**
1998
+ * Creates a {@link Semaphore}.
1999
+ *
2000
+ * @group Concurrency primitives
2001
+ */
2002
+ export const createSemaphore = (initialPermits, { policy = "fifo", } = {}) => {
2003
+ const isGreedy = policy === "greedy";
2004
+ let permits = PositiveInt.orThrow(initialPermits);
2005
+ let taken = NonNegativeInt.orThrow(0);
2006
+ const waiters = new Set();
2007
+ const hasFreePermits = (requested) => taken + requested <= permits;
2008
+ const canTake = (requested) => hasFreePermits(requested) && (isGreedy || waiters.size === 0);
2009
+ const grant = (requested, leakDetector) => {
2010
+ taken = NonNegativeInt.orThrow(taken + requested);
2011
+ let released = false;
2012
+ const handle = {};
2013
+ const release = () => {
2014
+ if (released)
2015
+ return false;
2016
+ released = true;
2017
+ leakDetector.untrack(handle);
2018
+ taken = NonNegativeInt.orThrow(taken - requested);
2019
+ releaseWaiters();
2020
+ return true;
2021
+ };
2022
+ const permit = {
2023
+ permits: requested,
2024
+ release,
2025
+ [Symbol.dispose]: release,
2026
+ };
2027
+ leakDetector.track(permit, { name: "SemaphorePermit", isLeaked: () => !released }, handle);
2028
+ return permit;
2029
+ };
2030
+ const releaseWaiters = () => {
2031
+ for (const waiter of waiters) {
2032
+ if (!hasFreePermits(waiter.permits)) {
2033
+ if (!isGreedy)
2034
+ break;
2035
+ continue;
1243
2036
  }
1244
- });
1245
- const disposables = disposer.move();
1246
- const withPermits = (key, requestedPermits) => (task) => async (run) => {
1247
- const env_7 = { stack: [], error: void 0, hasError: false };
2037
+ waiters.delete(waiter);
2038
+ waiter.resolve(ok(grant(waiter.permits, waiter.leakDetector)));
2039
+ }
2040
+ };
2041
+ const take = (requestedPermits) => callback(({ run: { deps }, resolve }) => {
2042
+ const requested = PositiveInt.orThrow(requestedPermits);
2043
+ if (canTake(requested)) {
2044
+ resolve(ok(grant(requested, deps.leakDetector)));
2045
+ return;
2046
+ }
2047
+ const waiter = {
2048
+ permits: requested,
2049
+ leakDetector: deps.leakDetector,
2050
+ resolve,
2051
+ };
2052
+ waiters.add(waiter);
2053
+ return () => {
2054
+ if (waiters.delete(waiter))
2055
+ releaseWaiters();
2056
+ };
2057
+ });
2058
+ const withPermits = (requestedPermits) => (task) => async (run) => {
2059
+ const env_4 = { stack: [], error: void 0, hasError: false };
2060
+ try {
2061
+ const _permit = __addDisposableResource(env_4, await run.ok(take(requestedPermits)), false);
2062
+ return await run(task);
2063
+ }
2064
+ catch (e_4) {
2065
+ env_4.error = e_4;
2066
+ env_4.hasError = true;
2067
+ }
2068
+ finally {
2069
+ __disposeResources(env_4);
2070
+ }
2071
+ };
2072
+ const isIdle = () => taken === 0 && waiters.size === 0;
2073
+ return {
2074
+ withPermit: (task) => withPermits(1)(task),
2075
+ withPermits,
2076
+ withPermitsIfAvailable: (requestedPermits) => (task) => async (run) => {
2077
+ const env_5 = { stack: [], error: void 0, hasError: false };
1248
2078
  try {
1249
- if (disposables.disposed)
1250
- return err(semaphoreDisposedAbortError);
1251
- let semaphore = semaphoresByKey.get(key);
1252
- if (!semaphore) {
1253
- semaphore = createSemaphore(permits);
1254
- semaphoresByKey.set(key, semaphore);
1255
- }
1256
- const _ = __addDisposableResource(env_7, {
1257
- [Symbol.dispose]: () => {
1258
- if (semaphoresByKey.get(key) !== semaphore)
1259
- return;
1260
- const snapshot = semaphore.snapshot();
1261
- if (snapshot.isIdle) {
1262
- semaphoresByKey.delete(key);
1263
- semaphore[Symbol.dispose]();
1264
- }
1265
- },
1266
- }, false);
1267
- return await run(semaphore.withPermits(requestedPermits)(task));
2079
+ const requested = PositiveInt.orThrow(requestedPermits);
2080
+ if (!canTake(requested))
2081
+ return ok(none);
2082
+ const _permit = __addDisposableResource(env_5, grant(requested, run.deps.leakDetector), false);
2083
+ const result = await run(task);
2084
+ if (!result.ok)
2085
+ return result;
2086
+ return ok(some(result.value));
1268
2087
  }
1269
- catch (e_7) {
1270
- env_7.error = e_7;
1271
- env_7.hasError = true;
2088
+ catch (e_5) {
2089
+ env_5.error = e_5;
2090
+ env_5.hasError = true;
1272
2091
  }
1273
2092
  finally {
1274
- __disposeResources(env_7);
2093
+ __disposeResources(env_5);
1275
2094
  }
1276
- };
1277
- return {
1278
- withPermit: (key, task) => withPermits(key, 1)(task),
1279
- withPermits,
1280
- snapshot: (key) => {
1281
- assertNotDisposed(disposables);
1282
- return semaphoresByKey.get(key)?.snapshot() ?? null;
1283
- },
1284
- [Symbol.dispose]: () => disposables.dispose(),
1285
- };
1286
- }
1287
- catch (e_5) {
1288
- env_5.error = e_5;
1289
- env_5.hasError = true;
1290
- }
1291
- finally {
1292
- __disposeResources(env_5);
1293
- }
1294
- }
2095
+ },
2096
+ take,
2097
+ resize: (newPermits) => {
2098
+ permits = PositiveInt.orThrow(newPermits);
2099
+ releaseWaiters();
2100
+ },
2101
+ snapshot: () => ({
2102
+ policy,
2103
+ permits,
2104
+ taken,
2105
+ waiters: Array.from(waiters, ({ permits }) => ({ permits })),
2106
+ available: NonNegativeInt.orThrow(!isGreedy && waiters.size > 0 ? 0 : Math.max(0, permits - taken)),
2107
+ isIdle: isIdle(),
2108
+ }),
2109
+ isIdle,
2110
+ };
2111
+ };
1295
2112
  /**
1296
2113
  * Creates a {@link Mutex}.
1297
2114
  *
1298
2115
  * @group Concurrency primitives
1299
2116
  */
1300
2117
  export const createMutex = () => {
1301
- const semaphore = createSemaphore(onePositiveInt);
2118
+ const semaphore = createSemaphore(1);
1302
2119
  return {
1303
2120
  withLock: semaphore.withPermit,
1304
2121
  snapshot: semaphore.snapshot,
1305
- [Symbol.dispose]: semaphore[Symbol.dispose],
1306
2122
  };
1307
2123
  };
2124
+ export function createSemaphoreByKey(initialPermits, { lookup = identity } = {}) {
2125
+ const semaphoresByKey = createLookupMap({ lookup });
2126
+ const getSemaphore = (key) => semaphoresByKey.getOrInsertComputed(key, () => createSemaphore(initialPermits));
2127
+ const withPermits = (key, requestedPermits) => (task) => async (run) => {
2128
+ const semaphore = getSemaphore(key);
2129
+ try {
2130
+ const env_6 = { stack: [], error: void 0, hasError: false };
2131
+ try {
2132
+ const _permit = __addDisposableResource(env_6, await run.ok(semaphore.take(requestedPermits)), false);
2133
+ return await run(task);
2134
+ }
2135
+ catch (e_6) {
2136
+ env_6.error = e_6;
2137
+ env_6.hasError = true;
2138
+ }
2139
+ finally {
2140
+ __disposeResources(env_6);
2141
+ }
2142
+ }
2143
+ finally {
2144
+ if (semaphore.isIdle())
2145
+ semaphoresByKey.delete(key);
2146
+ }
2147
+ };
2148
+ return {
2149
+ withPermit: (key, task) => withPermits(key, 1)(task),
2150
+ withPermits,
2151
+ isIdle: (key) => semaphoresByKey.get(key)?.isIdle() ?? true,
2152
+ snapshot: (key) => semaphoresByKey.get(key)?.snapshot() ?? null,
2153
+ };
2154
+ }
1308
2155
  export function createMutexByKey({ lookup = identity, } = {}) {
1309
- const semaphoreByKey = createSemaphoreByKey(onePositiveInt, {
1310
- lookup,
1311
- });
2156
+ const semaphoreByKey = createSemaphoreByKey(1, { lookup });
1312
2157
  return {
1313
2158
  withLock: (key, task) => semaphoreByKey.withPermit(key, task),
2159
+ isIdle: semaphoreByKey.isIdle,
1314
2160
  snapshot: semaphoreByKey.snapshot,
1315
- [Symbol.dispose]: semaphoreByKey[Symbol.dispose],
1316
2161
  };
1317
2162
  }
1318
2163
  /**
1319
- * Creates a {@link MutexRef} with the given initial immutable value.
2164
+ * Creates a {@link MutexRef}.
1320
2165
  *
1321
2166
  * @group Concurrency primitives
1322
2167
  */
@@ -1347,288 +2192,40 @@ export const createMutexRef = (initialValue) => {
1347
2192
  return ok(previousValue);
1348
2193
  }),
1349
2194
  updateAndGet: (updater) => mutex.withLock(async (run) => {
1350
- const currentValue = ref.get();
1351
- const nextValue = await run(updater(currentValue));
2195
+ const nextValue = await run(updater(ref.get()));
1352
2196
  if (!nextValue.ok)
1353
2197
  return nextValue;
1354
2198
  ref.set(nextValue.value);
1355
2199
  return ok(nextValue.value);
1356
2200
  }),
1357
2201
  modify: (modifier) => mutex.withLock(async (run) => {
1358
- const nextValue = await run(modifier(ref.get()));
1359
- if (!nextValue.ok)
1360
- return nextValue;
1361
- const [result, updatedValue] = nextValue.value;
1362
- ref.set(updatedValue);
1363
- return ok(result);
2202
+ const result = await run(modifier(ref.get()));
2203
+ if (!result.ok)
2204
+ return result;
2205
+ const [value, nextValue] = result.value;
2206
+ ref.set(nextValue);
2207
+ return ok(value);
1364
2208
  }),
1365
- [Symbol.dispose]: mutex[Symbol.dispose],
2209
+ snapshot: mutex.snapshot,
1366
2210
  };
1367
2211
  };
1368
- export function all(input, options) {
1369
- return collect("all", input, options);
1370
- }
1371
- /**
1372
- * Abort reason used by {@link all} when aborting remaining tasks.
1373
- *
1374
- * Used when a Task fails and other Tasks need to be aborted.
1375
- *
1376
- * @group Composition
1377
- */
1378
- export const AllAbortError = /*#__PURE__*/ typed("AllAbortError");
1379
- /**
1380
- * {@link AllAbortError} used as abort reason in {@link all}.
1381
- *
1382
- * @group Composition
1383
- */
1384
- export const allAbortError = { type: "AllAbortError" };
1385
- export function allSettled(input, options) {
1386
- return collect("allSettled", input, options);
1387
- }
1388
- /**
1389
- * Abort reason used by {@link allSettled} when aborted externally.
1390
- *
1391
- * @group Composition
1392
- */
1393
- export const AllSettledAbortError = /*#__PURE__*/ typed("AllSettledAbortError");
1394
- /**
1395
- * {@link AllSettledAbortError} used as abort reason in {@link allSettled}.
1396
- *
1397
- * @group Composition
1398
- */
1399
- export const allSettledAbortError = {
1400
- type: "AllSettledAbortError",
1401
- };
1402
- export function map(items, fn, { abortReason = mapAbortError, ...options } = {}) {
1403
- const mapped = mapInput(items, fn);
1404
- return all(mapped, {
1405
- ...options,
1406
- abortReason,
1407
- });
1408
- }
1409
- /**
1410
- * Abort reason used by {@link map} when aborting remaining tasks.
1411
- *
1412
- * @group Composition
1413
- */
1414
- export const MapAbortError = /*#__PURE__*/ typed("MapAbortError");
1415
- /**
1416
- * {@link MapAbortError} used as abort reason in {@link map}.
1417
- *
1418
- * @group Composition
1419
- */
1420
- export const mapAbortError = {
1421
- type: "MapAbortError",
1422
- };
1423
- export function mapSettled(items, task, options) {
1424
- const mapped = mapInput(items, task);
1425
- return allSettled(mapped, options);
1426
- }
1427
- export function any(tasks, options) {
1428
- const { allFailed = "input" } = options ?? {};
1429
- return pool(tasks, {
1430
- stopOn: "success",
1431
- collect: false,
1432
- abortReason: anyAbortError,
1433
- allFailed,
1434
- });
1435
- }
1436
- /**
1437
- * Abort reason used by {@link any} when aborting remaining tasks.
1438
- *
1439
- * @group Composition
1440
- */
1441
- export const AnyAbortError = /*#__PURE__*/ typed("AnyAbortError");
1442
- /**
1443
- * {@link AnyAbortError} used as abort reason in {@link any}.
1444
- *
1445
- * @group Composition
1446
- */
1447
- export const anyAbortError = { type: "AnyAbortError" };
1448
- /** Shared implementation for {@link all} and {@link allSettled}. */
1449
- const collect = (type, input, { collect = true, abortReason = type === "all" ? allAbortError : allSettledAbortError, } = {}) => {
1450
- const stopOn = type === "all" ? "error" : null;
1451
- if (isIterable(input)) {
1452
- const tasks = input;
1453
- const array = arrayFrom(tasks);
1454
- if (!isNonEmptyArray(array))
1455
- return () => ok(collect ? emptyArray : undefined);
1456
- return pool(array, {
1457
- stopOn,
1458
- collect,
1459
- abortReason,
1460
- });
1461
- }
1462
- const keys = [];
1463
- const taskArray = [];
1464
- for (const key in input) {
1465
- keys.push(key);
1466
- taskArray.push(input[key]);
1467
- }
1468
- if (keys.length === 0)
1469
- return () => ok(collect ? emptyRecord : undefined);
1470
- return async (run) => {
1471
- const result = await run(pool(taskArray, { stopOn, collect, abortReason }));
1472
- if (!result.ok)
1473
- return result;
1474
- if (!collect)
1475
- return ok();
1476
- const record = createRecord();
1477
- for (let i = 0; i < keys.length; i++) {
1478
- record[keys[i]] = result.value[i];
1479
- }
1480
- return ok(record);
1481
- };
1482
- };
1483
- const mapInput = (input, fn) => isIterable(input) ? mapArray(arrayFrom(input), fn) : mapObject(input, fn);
1484
- function pool(tasksIterable, { stopOn = null, collect, abortReason, allFailed, }) {
1485
- const tasks = arrayFrom(tasksIterable);
1486
- const { length } = tasks;
1487
- if (length === 0)
1488
- return () => ok(emptyArray);
1489
- return async (run) => {
1490
- const env_8 = { stack: [], error: void 0, hasError: false };
1491
- try {
1492
- const results = collect ? new Array(length) : null;
1493
- const aborted = Promise.withResolvers();
1494
- const stopSignal = stopOn ? Promise.withResolvers() : null;
1495
- let index = 0;
1496
- let stopped = null;
1497
- let lastResult = null;
1498
- let lastIndexResult = null;
1499
- const worker = async (run) => {
1500
- while (index < length && (stopOn ? !stopped : true)) {
1501
- const i = index++;
1502
- const result = (await run(tasks[i]));
1503
- lastResult = result;
1504
- if (i === length - 1)
1505
- lastIndexResult = result;
1506
- if (!stopOn) {
1507
- if (results)
1508
- results[i] = result;
1509
- continue;
1510
- }
1511
- const stop = stopOn === "first" ||
1512
- (stopOn === "error" && !result.ok) ||
1513
- (stopOn === "success" && result.ok);
1514
- if (!stop) {
1515
- if (results)
1516
- results[i] = result.value;
1517
- continue;
1518
- }
1519
- if (!stopped) {
1520
- stopped = result;
1521
- abortWorkers(!result.ok && AbortError.is(result.error)
1522
- ? result.error.reason
1523
- : abortReason);
1524
- stopSignal?.resolve();
1525
- }
1526
- break;
1527
- }
1528
- return ok();
1529
- };
1530
- let workersAborted = false;
1531
- const abortWorkers = (reason) => {
1532
- if (workersAborted)
1533
- return;
1534
- workersAborted = true;
1535
- for (const worker of workers)
1536
- worker.abort(reason);
1537
- };
1538
- const workerCount = Math.min(run.concurrency, length);
1539
- const workers = arrayFrom(workerCount, () => run.daemon(worker, run.deps));
1540
- const _ = __addDisposableResource(env_8, new DisposableStack(), false);
1541
- _.defer(() => {
1542
- abortWorkers(abortReason);
1543
- });
1544
- run.onAbort((reason) => {
1545
- abortWorkers(reason);
1546
- aborted.resolve();
1547
- });
1548
- const waitFor = [Promise.all(workers), aborted.promise];
1549
- if (stopSignal)
1550
- waitFor.push(stopSignal.promise);
1551
- await Promise.race(waitFor);
1552
- if (run.signal.aborted) {
1553
- return err(run.signal.reason);
1554
- }
1555
- if (!stopOn)
1556
- return results ? ok(results) : ok();
1557
- if (stopped)
1558
- return stopped;
1559
- if (results)
1560
- return ok(results);
1561
- // For all/allSettled/map/mapSettled with collect: false (no allFailed handler)
1562
- if (!allFailed)
1563
- return ok();
1564
- return allFailed === "completion" ? lastResult : lastIndexResult;
1565
- }
1566
- catch (e_8) {
1567
- env_8.error = e_8;
1568
- env_8.hasError = true;
1569
- }
1570
- finally {
1571
- __disposeResources(env_8);
1572
- }
1573
- };
1574
- }
1575
- /**
1576
- * Error returned when a {@link fetch} {@link Task} fails.
1577
- *
1578
- * @group Composition
1579
- */
1580
- export const FetchError = /*#__PURE__*/ typed("FetchError", { error: Unknown });
1581
- /**
1582
- * Creates a {@link Task} that wraps the native
1583
- * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}.
1584
- *
1585
- * Handles cross-browser abort behavior — WebKit throws a `DOMException` with
1586
- * message "Fetch is aborted" instead of propagating `signal.reason`. This
1587
- * helper normalizes the behavior to always return {@link AbortError}.
1588
- *
1589
- * ### Example
1590
- *
1591
- * ```ts
1592
- * await using run = createRun();
1593
- *
1594
- * const result = await run(fetch("https://api.example.com/users"));
1595
- *
1596
- * if (!result.ok) {
1597
- * // Handle FetchError or AbortError
1598
- * }
1599
- *
1600
- * // Compose with timeout and retry
1601
- * const fetchWithRetry = (url: string) =>
1602
- * retry(timeout(fetch(url), "10s"), retryStrategyAws);
1603
- * ```
1604
- *
1605
- * @group Composition
1606
- */
1607
- export const fetch = (input, init) => ({ signal }) => tryAsync(() => globalThis.fetch(input, { ...init, signal }), (error) => {
1608
- if (AbortError.is(error))
1609
- return error;
1610
- // WebKit throws DOMException with message "Fetch is aborted" instead of
1611
- // propagating signal.reason. Detect this and create AbortError.
1612
- if (signal.aborted &&
1613
- error instanceof Error &&
1614
- error.message === "Fetch is aborted") {
1615
- return createAbortError(signal.reason);
1616
- }
1617
- return { type: "FetchError", error };
1618
- });
1619
- // TODO: Prioritized Task Scheduling API integration
1620
- // https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
1621
- //
1622
- // - `run(task, { priority })` - Fiber-level priority via TaskController
1623
- // (extends AbortController), so the fiber's signal gets priority and all
1624
- // nested work inherits it.
1625
- // - `fiber.setPriority(priority)` - Dynamic priority changes mid-flight via
1626
- // TaskController.setPriority().
1627
- //
1628
- // Note: scheduler.yield() inherits priority from enclosing postTask, so
1629
- // yieldNow doesn't need a priority argument — just run the fiber at priority.
1630
- //
1631
- // Safari doesn't support it yet, Node.js probably never will (use setImmediate).
1632
- // For Safari, scheduler-polyfill can be used.
1633
- // https://www.npmjs.com/package/scheduler-polyfill
1634
- // TODO: Do we really need specialized aborts?
2212
+ // TODO: Add Run observability after Task migration.
2213
+ // - Structured logging with levels, inherited log annotations, JSON output,
2214
+ // filtering, and pluggable log sinks.
2215
+ // - Tracing spans with names, timing, parent-child relationships, attributes,
2216
+ // error status, and helpers for annotating the current or child spans.
2217
+ // - Metrics for counters, gauges, histograms, and operation durations.
2218
+ // - Resource metadata for service name, service version, deployment
2219
+ // environment, and user-provided attributes.
2220
+ // - Exporters for production telemetry backends, including OTLP-compatible
2221
+ // logs, traces, and metrics.
2222
+ // - Transferable snapshots for local and worker monitoring. Current snapshots
2223
+ // can contain arbitrary Result values and Error objects, so serialization must
2224
+ // replace non-transferable values with safe diagnostics.
2225
+ // - Incremental snapshot invalidation so polling unchanged large Run trees
2226
+ // avoids recursive traversal and child-array allocation.
2227
+ // - Run labels and structured annotations for rendering useful snapshot trees
2228
+ // instead of anonymous ids.
2229
+ // - Snapshot and trace views should preserve ownership boundaries, so reusable
2230
+ // resources and long-lived operations appear as labeled subtrees instead of
2231
+ // unrelated child operations.