@evolu/common 8.0.0-next.4 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/src/Array.d.ts +279 -173
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +105 -101
  4. package/dist/src/Assert.d.ts +44 -44
  5. package/dist/src/Assert.d.ts.map +1 -1
  6. package/dist/src/Assert.js +44 -32
  7. package/dist/src/BigInt.d.ts +6 -3
  8. package/dist/src/BigInt.d.ts.map +1 -1
  9. package/dist/src/BigInt.js +5 -2
  10. package/dist/src/Brand.d.ts +27 -40
  11. package/dist/src/Brand.d.ts.map +1 -1
  12. package/dist/src/Buffer.d.ts +27 -11
  13. package/dist/src/Buffer.d.ts.map +1 -1
  14. package/dist/src/Cache.d.ts +9 -4
  15. package/dist/src/Cache.d.ts.map +1 -1
  16. package/dist/src/Cache.js +8 -3
  17. package/dist/src/Callbacks.d.ts +30 -19
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Console.d.ts +145 -66
  20. package/dist/src/Console.d.ts.map +1 -1
  21. package/dist/src/Console.js +91 -43
  22. package/dist/src/Crypto.d.ts +85 -42
  23. package/dist/src/Crypto.d.ts.map +1 -1
  24. package/dist/src/Crypto.js +44 -20
  25. package/dist/src/Eq.d.ts +87 -21
  26. package/dist/src/Eq.d.ts.map +1 -1
  27. package/dist/src/Eq.js +67 -19
  28. package/dist/src/Error.d.ts +5 -3
  29. package/dist/src/Error.d.ts.map +1 -1
  30. package/dist/src/Error.js +16 -0
  31. package/dist/src/Function.d.ts +119 -58
  32. package/dist/src/Function.d.ts.map +1 -1
  33. package/dist/src/Function.js +86 -43
  34. package/dist/src/Http.d.ts +394 -0
  35. package/dist/src/Http.d.ts.map +1 -0
  36. package/dist/src/Http.js +102 -0
  37. package/dist/src/Identicon.d.ts +16 -7
  38. package/dist/src/Identicon.d.ts.map +1 -1
  39. package/dist/src/Identicon.js +14 -5
  40. package/dist/src/LeakDetector.d.ts +88 -0
  41. package/dist/src/LeakDetector.d.ts.map +1 -0
  42. package/dist/src/LeakDetector.js +86 -0
  43. package/dist/src/LockManager.d.ts +18 -9
  44. package/dist/src/LockManager.d.ts.map +1 -1
  45. package/dist/src/LockManager.js +7 -8
  46. package/dist/src/Lookup.d.ts +43 -13
  47. package/dist/src/Lookup.d.ts.map +1 -1
  48. package/dist/src/Lookup.js +28 -8
  49. package/dist/src/Microtask.d.ts +1 -1
  50. package/dist/src/Microtask.d.ts.map +1 -1
  51. package/dist/src/Microtask.js +3 -7
  52. package/dist/src/Number.d.ts +49 -13
  53. package/dist/src/Number.d.ts.map +1 -1
  54. package/dist/src/Number.js +34 -5
  55. package/dist/src/Object.d.ts +160 -40
  56. package/dist/src/Object.d.ts.map +1 -1
  57. package/dist/src/Object.js +92 -46
  58. package/dist/src/Option.d.ts +10 -7
  59. package/dist/src/Option.d.ts.map +1 -1
  60. package/dist/src/Option.js +9 -6
  61. package/dist/src/Order.d.ts +21 -21
  62. package/dist/src/Order.d.ts.map +1 -1
  63. package/dist/src/Order.js +18 -21
  64. package/dist/src/Platform.d.ts +31 -0
  65. package/dist/src/Platform.d.ts.map +1 -1
  66. package/dist/src/Platform.js +98 -0
  67. package/dist/src/Random.d.ts +32 -17
  68. package/dist/src/Random.d.ts.map +1 -1
  69. package/dist/src/Random.js +12 -8
  70. package/dist/src/Redacted.d.ts +43 -29
  71. package/dist/src/Redacted.d.ts.map +1 -1
  72. package/dist/src/Redacted.js +14 -3
  73. package/dist/src/Ref.d.ts +11 -3
  74. package/dist/src/Ref.d.ts.map +1 -1
  75. package/dist/src/Ref.js +2 -2
  76. package/dist/src/RefCount.d.ts +2 -6
  77. package/dist/src/RefCount.d.ts.map +1 -1
  78. package/dist/src/RefCount.js +33 -90
  79. package/dist/src/Relation.d.ts +70 -5
  80. package/dist/src/Relation.d.ts.map +1 -1
  81. package/dist/src/Relation.js +77 -2
  82. package/dist/src/Resource.d.ts +590 -155
  83. package/dist/src/Resource.d.ts.map +1 -1
  84. package/dist/src/Resource.js +592 -343
  85. package/dist/src/Result.d.ts +838 -191
  86. package/dist/src/Result.d.ts.map +1 -1
  87. package/dist/src/Result.js +163 -102
  88. package/dist/src/Schedule.d.ts +684 -231
  89. package/dist/src/Schedule.d.ts.map +1 -1
  90. package/dist/src/Schedule.js +766 -295
  91. package/dist/src/Set.d.ts +89 -40
  92. package/dist/src/Set.d.ts.map +1 -1
  93. package/dist/src/Set.js +50 -29
  94. package/dist/src/Sqlite.d.ts +29 -72
  95. package/dist/src/Sqlite.d.ts.map +1 -1
  96. package/dist/src/Sqlite.js +41 -46
  97. package/dist/src/StackTrace.d.ts +40 -0
  98. package/dist/src/StackTrace.d.ts.map +1 -0
  99. package/dist/src/StackTrace.js +140 -0
  100. package/dist/src/Store.d.ts +5 -4
  101. package/dist/src/Store.d.ts.map +1 -1
  102. package/dist/src/Store.js +5 -4
  103. package/dist/src/String.d.ts +7 -1
  104. package/dist/src/String.d.ts.map +1 -1
  105. package/dist/src/String.js +20 -7
  106. package/dist/src/Task.d.ts +3572 -1770
  107. package/dist/src/Task.d.ts.map +1 -1
  108. package/dist/src/Task.js +1879 -1274
  109. package/dist/src/Test.d.ts +17 -125
  110. package/dist/src/Test.d.ts.map +1 -1
  111. package/dist/src/Test.js +17 -68
  112. package/dist/src/Time.d.ts +108 -49
  113. package/dist/src/Time.d.ts.map +1 -1
  114. package/dist/src/Time.js +172 -73
  115. package/dist/src/Type.d.ts +3542 -1926
  116. package/dist/src/Type.d.ts.map +1 -1
  117. package/dist/src/Type.js +3070 -1784
  118. package/dist/src/Types.d.ts +200 -108
  119. package/dist/src/Types.d.ts.map +1 -1
  120. package/dist/src/Types.js +39 -8
  121. package/dist/src/WebSocket.d.ts +48 -23
  122. package/dist/src/WebSocket.d.ts.map +1 -1
  123. package/dist/src/WebSocket.js +15 -6
  124. package/dist/src/Worker.d.ts +94 -36
  125. package/dist/src/Worker.d.ts.map +1 -1
  126. package/dist/src/Worker.js +58 -24
  127. package/dist/src/index.d.ts +55 -52
  128. package/dist/src/index.d.ts.map +1 -1
  129. package/dist/src/index.js +3 -1
  130. package/dist/src/intl/cs.d.ts +94 -0
  131. package/dist/src/intl/cs.d.ts.map +1 -0
  132. package/dist/src/intl/cs.js +220 -0
  133. package/dist/src/intl/index.d.ts +7 -0
  134. package/dist/src/intl/index.d.ts.map +1 -0
  135. package/dist/src/intl/index.js +6 -0
  136. package/dist/src/local-first/Db.d.ts +12 -8
  137. package/dist/src/local-first/Db.d.ts.map +1 -1
  138. package/dist/src/local-first/Db.js +109 -132
  139. package/dist/src/local-first/Error.d.ts +4 -4
  140. package/dist/src/local-first/Evolu.d.ts +338 -92
  141. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  142. package/dist/src/local-first/Evolu.js +47 -55
  143. package/dist/src/local-first/LocalAuth.d.ts +4 -4
  144. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.d.ts +61 -24
  146. package/dist/src/local-first/Owner.d.ts.map +1 -1
  147. package/dist/src/local-first/Owner.js +45 -8
  148. package/dist/src/local-first/Protocol.d.ts +44 -31
  149. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  150. package/dist/src/local-first/Protocol.js +36 -13
  151. package/dist/src/local-first/Query.d.ts +147 -62
  152. package/dist/src/local-first/Query.d.ts.map +1 -1
  153. package/dist/src/local-first/Query.js +112 -58
  154. package/dist/src/local-first/Relay.d.ts +44 -14
  155. package/dist/src/local-first/Relay.d.ts.map +1 -1
  156. package/dist/src/local-first/Schema.d.ts +92 -41
  157. package/dist/src/local-first/Schema.d.ts.map +1 -1
  158. package/dist/src/local-first/Schema.js +25 -8
  159. package/dist/src/local-first/Shared.d.ts +23 -21
  160. package/dist/src/local-first/Shared.d.ts.map +1 -1
  161. package/dist/src/local-first/Shared.js +100 -57
  162. package/dist/src/local-first/Storage.d.ts +53 -32
  163. package/dist/src/local-first/Storage.d.ts.map +1 -1
  164. package/dist/src/local-first/Storage.js +194 -234
  165. package/dist/src/local-first/Timestamp.d.ts +21 -21
  166. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  167. package/dist/src/local-first/Timestamp.js +2 -2
  168. package/dist/src/local-first/index.d.ts +11 -11
  169. package/package.json +14 -13
  170. package/src/Array.ts +306 -205
  171. package/src/Assert.ts +54 -69
  172. package/src/BigInt.ts +6 -3
  173. package/src/Brand.ts +27 -40
  174. package/src/Buffer.ts +28 -12
  175. package/src/Cache.ts +9 -4
  176. package/src/Callbacks.ts +30 -19
  177. package/src/Console.ts +156 -84
  178. package/src/Crypto.ts +83 -41
  179. package/src/Eq.ts +95 -22
  180. package/src/Error.ts +8 -4
  181. package/src/Function.ts +167 -59
  182. package/src/Http.ts +549 -0
  183. package/src/Identicon.ts +16 -7
  184. package/src/LeakDetector.ts +182 -0
  185. package/src/LockManager.ts +43 -39
  186. package/src/Lookup.ts +62 -28
  187. package/src/Microtask.ts +19 -23
  188. package/src/Number.ts +64 -12
  189. package/src/Object.ts +173 -49
  190. package/src/Option.ts +10 -7
  191. package/src/Order.ts +23 -21
  192. package/src/Platform.ts +136 -0
  193. package/src/Polyfills.ts +6 -12
  194. package/src/Random.ts +32 -19
  195. package/src/Redacted.ts +44 -30
  196. package/src/Ref.ts +14 -6
  197. package/src/RefCount.ts +38 -80
  198. package/src/Relation.ts +202 -10
  199. package/src/Resource.ts +1199 -591
  200. package/src/Result.ts +930 -271
  201. package/src/Schedule.ts +859 -343
  202. package/src/Set.ts +89 -40
  203. package/src/Sqlite.ts +144 -138
  204. package/src/StackTrace.ts +230 -0
  205. package/src/Store.ts +8 -7
  206. package/src/String.ts +22 -5
  207. package/src/Task.ts +5101 -2832
  208. package/src/Test.ts +20 -168
  209. package/src/Time.ts +293 -90
  210. package/src/Type.ts +10034 -4117
  211. package/src/Types.ts +227 -110
  212. package/src/WebSocket.ts +64 -36
  213. package/src/Worker.ts +180 -72
  214. package/src/index.ts +55 -52
  215. package/src/intl/cs.ts +388 -0
  216. package/src/intl/index.ts +7 -0
  217. package/src/local-first/Db.ts +152 -179
  218. package/src/local-first/Error.ts +4 -4
  219. package/src/local-first/Evolu.ts +495 -250
  220. package/src/local-first/LocalAuth.ts +6 -12
  221. package/src/local-first/Owner.ts +58 -21
  222. package/src/local-first/Protocol.ts +49 -31
  223. package/src/local-first/Query.ts +146 -61
  224. package/src/local-first/Relay.ts +51 -21
  225. package/src/local-first/Schema.ts +109 -51
  226. package/src/local-first/Shared.ts +266 -226
  227. package/src/local-first/Storage.ts +266 -264
  228. package/src/local-first/Timestamp.ts +25 -18
  229. package/src/local-first/index.ts +11 -11
  230. package/dist/src/Skiplist.d.ts +0 -28
  231. package/dist/src/Skiplist.d.ts.map +0 -1
  232. package/dist/src/Skiplist.js +0 -64
  233. package/dist/src/Tracer.d.ts +0 -48
  234. package/dist/src/Tracer.d.ts.map +0 -1
  235. package/dist/src/Tracer.js +0 -6
  236. package/src/Skiplist.ts +0 -108
  237. package/src/Tracer.ts +0 -54
package/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,1261 +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 runDeps = {
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 RunDeps} 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 = RunDeps & 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<RunDeps>
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) => {
236
- const mergedDeps = { ...runDeps, ...deps };
237
- return createRunInternal(createRef(mergedDeps))();
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
+ };
238
280
  };
239
- const createRunInternal = (depsRef) => (parent, daemon, abortBehavior, concurrencyBehavior) => {
240
- const parentMask = parent?.abortMask ?? isAbortable;
241
- let abortMask;
242
- switch (abortBehavior) {
243
- case undefined:
244
- abortMask = parentMask;
245
- break;
246
- case "unabortable":
247
- abortMask = increment(parentMask);
248
- break;
249
- default:
250
- assert(abortBehavior <= parentMask, "restore used outside its unabortableMask");
251
- abortMask = abortBehavior;
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");
252
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.
253
315
  const requestController = new AbortController();
254
316
  const signalController = new AbortController();
255
- let state = running;
256
- let result;
257
- let outcome;
258
- let children = emptySet;
259
- const requestAbort = (reason) => {
260
- const abortError = reason;
261
- if (abortMask === isAbortable)
262
- signalController.abort(abortError);
263
- 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
+ }
264
327
  };
265
- if (parent) {
266
- subscribeToAbort(parent.requestSignal, () => requestAbort(parent.requestSignal.reason), { signal: requestController.signal });
267
- }
268
328
  const emitEvent = (data) => {
269
- const deps = depsRef.get();
270
- if (!deps.runConfig?.eventsEnabled.get())
271
- return;
272
- const e = { id: self.id, timestamp: deps.time.now(), data };
273
- for (let node = self; node; node = node.parent) {
274
- node.onEvent?.(e);
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
+ }
275
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)
366
+ return;
367
+ const abortError = createAbortError(reason);
368
+ requestController.abort(abortError);
369
+ if (abortMask === abortableMask)
370
+ signalController.abort(abortError);
371
+ commitState({ type: "Aborted", abort: currentAbort() });
276
372
  };
277
- const run = (task) => {
278
- const childRun = createRunInternal(depsRef)(self, daemon ?? self, getAbortBehavior(task), getConcurrencyBehavior(task));
279
- if (state !== running) {
280
- childRun.requestAbort(runStoppedAbortError);
281
- task = () => err(runStoppedAbortError);
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
+ });
282
401
  }
283
- else if (signalController.signal.aborted &&
284
- childRun.abortMask === isAbortable) {
285
- childRun.requestAbort(signalController.signal.reason);
286
- task = () => err(signalController.signal.reason);
402
+ else {
403
+ disposePromise = Promise.all(children.values()).then(settle);
287
404
  }
288
- const childFiber = Object.assign(Promise.try(task, childRun)
289
- .then(childRun.handleTaskFulfilled)
290
- .finally(childRun.handleTaskSettled)
291
- .finally(childRun[Symbol.asyncDispose])
292
- .finally(() => {
293
- children = deleteFromSet(children, childFiber);
294
- emitEvent({ type: "ChildRemoved", childId: childRun.id });
295
- }), {
296
- run: childRun,
297
- abort: (reason) => {
298
- childRun.requestAbort(createAbortError(reason));
299
- },
300
- getState: () => childRun.getState(),
301
- [Symbol.dispose]: () => {
302
- childFiber.abort();
303
- },
304
- });
305
- children = addToSet(children, childFiber);
306
- emitEvent({ type: "ChildAdded", childId: childRun.id });
307
- return childFiber;
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;
308
412
  };
309
- const self = run;
310
- {
311
- const run = self;
312
- const id = createId(depsRef.get());
313
- let snapshot = null;
314
- let disposingPromise = null;
315
- run.orThrow = async (task) => getOrThrow(await self(task));
316
- run.id = id;
317
- run.parent = parent ?? null;
318
- run.signal = signalController.signal;
319
- run.abortMask = abortMask;
320
- run.onAbort = (callback) => {
321
- if (abortMask !== isAbortable)
322
- return;
323
- subscribeToAbort(signalController.signal, () => callback(signalController.signal.reason.reason), { once: true, signal: requestController.signal });
324
- };
325
- run.getState = () => state;
326
- run.getChildren = () => children;
327
- run.snapshot = () => {
328
- const childSnapshots = Array.from(children).map((fiber) => fiber.run.snapshot());
329
- if (snapshot?.state !== state ||
330
- !eqArrayStrict(snapshot.children, childSnapshots)) {
331
- snapshot = {
332
- id,
333
- state: state,
334
- children: childSnapshots,
335
- abortMask,
336
- };
337
- }
338
- return snapshot;
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
+ ...{
421
+ console: deps.console,
422
+ leakDetector: deps.leakDetector,
423
+ nativeFetch: deps.nativeFetch,
424
+ randomBytes: deps.randomBytes,
425
+ random: deps.random,
426
+ reportDefect: deps.reportDefect,
427
+ time: deps.time,
428
+ ...(deps.runConfig && { runConfig: deps.runConfig }),
429
+ },
430
+ ...taskDeps,
339
431
  };
340
- run.daemon = daemon ?? self;
341
- run.create = () => run.daemon(createDeferred().task).run;
342
- Object.defineProperty(run, "deps", { get: depsRef.get });
343
- run.concurrency =
344
- concurrencyBehavior ?? parent?.concurrency ?? defaultConcurrency;
345
- run.addDeps = (newDeps) => {
346
- depsRef.modify((currentDeps) => {
347
- const duplicate = Object.keys(newDeps).find((k) => k in currentDeps);
348
- assert(!duplicate, `Dependency '${duplicate}' already added. ` +
349
- `This assert ensures dependencies are created once. ` +
350
- `Automatic deduplication would mask bugs.`);
351
- return [undefined, { ...currentDeps, ...newDeps }];
352
- });
353
- return self;
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);
354
445
  };
355
- run[Symbol.asyncDispose] = () => {
356
- if (disposingPromise)
357
- return disposingPromise;
358
- state = { type: "Disposing" };
359
- emitEvent({ type: "StateChanged", state });
360
- requestAbort(runStoppedAbortError);
361
- disposingPromise = Promise.allSettled((run.ownTaskSettled
362
- ? [run.ownTaskSettled.promise, ...children]
363
- : children))
364
- .then(lazyVoid)
365
- .finally(() => {
366
- /**
367
- * Root and daemon Runs have no own Task, so
368
- * `run.handleTaskFulfilled` never populates their terminal values.
369
- * In that case disposal publishes `ok()` for both `result` and
370
- * `outcome`. Task-backed Runs normally reach this point with both
371
- * values already set.
372
- */
373
- [result, outcome] = [result ?? ok(), outcome ?? ok()];
374
- state = { type: "Settled", result, outcome };
375
- 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,
376
452
  });
377
- 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;
378
511
  };
379
- // Internal
380
- run.ownTaskSettled = parent ? Promise.withResolvers() : null;
381
- run.requestAbort = requestAbort;
382
- run.requestSignal = requestController.signal;
383
- run.handleTaskFulfilled = (taskOutcome) => {
384
- const taskResult = run.signal.aborted
385
- ? err(run.signal.reason)
386
- : taskOutcome;
387
- result = taskResult;
388
- outcome = taskOutcome;
389
- 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
+ }
390
555
  };
391
- run.handleTaskSettled = () => {
392
- run.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
+ },
393
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]]));
394
660
  }
395
- 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());
396
677
  };
397
- const running = { type: "Running" };
678
+ const mapInput = (input, fn) => Array.isArray(input)
679
+ ? mapArray(input, (value, index) => fn(value, index))
680
+ : mapObject(input, fn);
398
681
  /**
399
- * 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.
400
717
  *
401
- * Covers both disposing and settled Runs.
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
+ * );
733
+ *
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
+ * ```
402
742
  *
403
- * @group Creating Run
743
+ * @group Interop
404
744
  */
405
- 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
+ };
406
756
  /**
407
- * Shared {@link RunStoppedError} instance used as the default
408
- * {@link AbortError.reason} when a Task is started on a non-running {@link Run}.
757
+ * Pauses execution for a specified {@link PositiveDuration}.
409
758
  *
410
- * @group Creating Run
759
+ * Aborting the Task clears the scheduled timeout.
760
+ *
761
+ * @group Timing
411
762
  */
412
- export const runStoppedError = {
413
- type: "RunStoppedError",
414
- };
415
- const createAbortError = (reason) => ({
416
- type: "AbortError",
417
- reason,
418
- });
419
- const subscribeToAbort = (signal, handler, options) => {
420
- if (signal.aborted)
421
- handler();
422
- else
423
- signal.addEventListener("abort", handler, options);
424
- };
425
- const runStoppedAbortError = createAbortError(runStoppedError);
426
- const isAbortable = AbortMask.orThrow(0);
427
- const abortBehaviorSymbol = Symbol("evolu.Task.abortBehavior");
428
- const getAbortBehavior = (task) => task[abortBehaviorSymbol];
429
- const abortBehavior = (behavior) => (task) => Object.assign((run) => run(task), {
430
- [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);
431
766
  });
432
767
  /**
433
- * Makes a {@link Task} unabortable.
768
+ * Typed error returned by {@link timeout} when a Task exceeds its duration.
434
769
  *
435
- * Once started, an unabortable Task always completes — abort requests are
436
- * ignored and `signal.aborted` remains `false`.
770
+ * @group Timing
771
+ */
772
+ export const TimeoutError =
773
+ /*#__PURE__*/ typed("TimeoutError");
774
+ /**
775
+ * The {@link TimeoutError} instance returned by {@link timeout}.
437
776
  *
438
- * If the parent {@link Run} is already disposing or settled, `run(task)`
439
- * short-circuits before task execution and returns `err(AbortError)` with
440
- * {@link runStoppedError} as reason. So `unabortable` means “do not interrupt
441
- * 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.
442
782
  *
443
- * When that pre-start abort would be a programmer error, assert it explicitly
444
- * with `assertNotAborted` after awaiting the result.
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)`.
445
788
  *
446
789
  * ### Example
447
790
  *
448
791
  * ```ts
792
+ * import {
793
+ * createRun,
794
+ * timeout,
795
+ * timeoutError,
796
+ * waitForAbort,
797
+ * type Result,
798
+ * type TimeoutError,
799
+ * } from "@evolu/common";
800
+ *
449
801
  * await using run = createRun();
450
802
  *
451
- * const events: Array<string> = [];
452
- * const canComplete = Promise.withResolvers<void>();
453
- * let signalAbortedInAnalytics = true;
454
- *
455
- * // Simulate async analytics API (abortable by default)
456
- * const sendToAnalytics =
457
- * (event: number): Task<void, never> =>
458
- * async ({ signal }) => {
459
- * await canComplete.promise;
460
- * signalAbortedInAnalytics = signal.aborted;
461
- * events.push(`sent ${event}`);
462
- * return ok();
463
- * };
803
+ * const result = await run(timeout(waitForAbort, "1ms"));
804
+ * expectTypeOf(result).toEqualTypeOf<Result<never, TimeoutError>>();
805
+ * expectErr(result, timeoutError);
806
+ * ```
464
807
  *
465
- * // Important events must be sent even if the user navigates away
466
- * const trackImportantEvent = (event: number) =>
467
- * 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}.
468
819
  *
469
- * // User clicks, we start tracking (Task runs until first await)
470
- * 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.
471
823
  *
472
- * // User navigates away (abort requested while task is running)
473
- * fiber.abort();
474
- * 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`.
475
830
  *
476
- * 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.
477
833
  *
478
- * expect(signalAbortedInAnalytics).toBe(false);
479
- * // Analytics was sent despite abort
480
- * expect(events).toEqual(["sent 123"]);
481
- * expect(result).toEqual(ok());
482
- * ```
834
+ * ### Example
483
835
  *
484
- * @group Abort masking
485
- */
486
- // TODO: Clear AbortError from unabortable task results.
487
- export const unabortable = /*#__PURE__*/ abortBehavior("unabortable");
488
- /**
489
- * Like {@link unabortable}, but provides `restore` to restore abortability for
490
- * 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";
491
846
  *
492
- * Tasks inherit abort masking from their parent. This means:
847
+ * await using run = createRun();
493
848
  *
494
- * - Tasks run inside `unabortableMask` are unabortable by default
495
- * - Tasks wrapped with `restore()` restore the previous abortability
849
+ * interface FetchDataError {
850
+ * readonly type: "FetchDataError";
851
+ * }
496
852
  *
497
- * @group Abort masking
498
- */
499
- export const unabortableMask = (fn) => unabortable((run) => fn(abortBehavior(AbortMask.orThrow(decrement(run.abortMask))))(run));
500
- const defaultConcurrency = 1;
501
- const concurrencyBehaviorSymbol = Symbol("evolu.Task.concurrencyBehavior");
502
- const getConcurrencyBehavior = (task) => task[concurrencyBehaviorSymbol];
503
- export function concurrently(concurrencyOrTask, taskOrFallback) {
504
- const isTask = isFunction(concurrencyOrTask);
505
- const task = isTask ? concurrencyOrTask : taskOrFallback;
506
- return Object.assign((run) => run(task), {
507
- [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask,
508
- });
509
- }
510
- /**
511
- * Yields execution to allow other work to proceed.
853
+ * const fetchData: Task<string, FetchDataError> = () =>
854
+ * err({ type: "FetchDataError" });
512
855
  *
513
- * Long-running JavaScript blocks the main thread. In browsers, this makes the
514
- * UI unresponsive (user interactions, animations). In Node.js, it prevents I/O
515
- * callbacks, timers, and other requests from being handled. Inserting yield
516
- * points lets the runtime process high-priority work between chunks of code.
856
+ * const fetchWithRetry = retry(fetchData, recurs(2));
517
857
  *
518
- * Uses `scheduler.yield()` in browsers for optimal main thread scheduling,
519
- * 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
+ * ```
520
868
  *
521
869
  * ### Example
522
870
  *
523
871
  * ```ts
524
- * const processLargeArray: Task<void, never> = async (run) => {
525
- * const { time } = run.deps;
526
- * let lastYield = time.now();
527
- *
528
- * for (const item of largeArray) {
529
- * processItem(item);
530
- *
531
- * // Yield periodically to keep UI responsive
532
- * if (time.now() - lastYield > msLongTask) {
533
- * const r = await run(yieldNow);
534
- * if (!r.ok) return r;
535
- * lastYield = time.now();
536
- * }
537
- * }
872
+ * import { createRun, err, recurs, retry, type Task } from "@evolu/common";
538
873
  *
539
- * return ok();
540
- * };
541
- * ```
542
- *
543
- * Recursive tasks also benefit from periodic yields — without them, deep
544
- * recursion overflows the call stack:
874
+ * interface FetchDataError {
875
+ * readonly type: "RecoverableError" | "FatalError";
876
+ * }
545
877
  *
546
- * ```ts
547
- * const processRecursive =
548
- * (count: number, index: number, sum: number): Task<number> =>
549
- * async (run) => {
550
- * if (index >= count) return ok(sum);
878
+ * const fetchData: Task<string, FetchDataError> = () =>
879
+ * err({ type: "FatalError" });
551
880
  *
552
- * // Yield periodically to break synchronous call chains.
553
- * if (index > 0 && index % 1000 === 0) {
554
- * const y = await run(yieldNow);
555
- * if (!y.ok) return y;
556
- * }
881
+ * const fetchWithRetry = retry(fetchData, recurs(5), {
882
+ * shouldRetry: (error) => error.type !== "FatalError",
883
+ * });
557
884
  *
558
- * // Direct tail-call: no fiber overhead, stack-safe thanks to yieldNow.
559
- * return await processRecursive(count, index + 1, sum + index)(run);
560
- * };
885
+ * await using run = createRun();
886
+ * expectErr(await run(fetchWithRetry), {
887
+ * type: "RetryError",
888
+ * attempts: 1,
889
+ * lastError: { type: "FatalError" },
890
+ * });
561
891
  * ```
562
892
  *
563
- * @group Composition
564
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
565
- * @see https://web.dev/articles/optimize-long-tasks
893
+ * @group Resilience
566
894
  */
567
- export const yieldNow = () => tryAsync(() => yieldImpl(), // TODO: yieldImpl(run.signal)
568
- (reason) => createAbortError(reason));
569
- const scheduler = globalThis.scheduler;
570
- const yieldImpl = typeof scheduler?.yield === "function"
571
- ? () => scheduler.yield()
572
- : typeof setImmediate !== "undefined"
573
- ? () => new Promise((resolve) => setImmediate(resolve))
574
- : () => 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
+ };
575
922
  /**
576
- * Creates a {@link Task} from a callback-based API.
923
+ * Repeats a Task according to a {@link Schedule}.
577
924
  *
578
- * Use this to wrap callback-style APIs (event listeners, Node.js callbacks,
579
- * 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.
580
930
  *
581
- * 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}.
582
935
  *
583
- * This helper can also wrap promise APIs that do not observe `AbortSignal`.
584
- * Start the promise work inside the callback, and aborting the Task stops
585
- * waiting for it immediately.
936
+ * With `take(n)`, the Task runs n+1 times: the initial attempt plus n repeats.
586
937
  *
587
938
  * ### Example
588
939
  *
589
940
  * ```ts
590
- * // The sleep helper is implemented using callback:
591
- * const sleep = (duration: Duration): Task<void> =>
592
- * callback(({ ok, deps: { time } }) => {
593
- * const id = time.setTimeout(ok, durationToMillis(duration));
594
- * return () => time.clearTimeout(id);
595
- * });
941
+ * import { createRun, ok, recurs, repeat, type Task } from "@evolu/common";
596
942
  *
597
- * // Wrap an event listener — use signal directly
598
- * const waitForClick = (element: HTMLElement): Task<MouseEvent> =>
599
- * callback(({ ok, signal }) => {
600
- * element.addEventListener("click", ok, { once: true, signal });
601
- * });
943
+ * let attempts = 0;
944
+ * const checkStatus: Task<string> = () => {
945
+ * attempts += 1;
946
+ * return ok("pending");
947
+ * };
602
948
  *
603
- * // Wrap Node.js callback API
604
- * const readFile = (path: string): Task<string, NodeJS.ErrnoException> =>
605
- * callback(({ ok, err }) => {
606
- * fs.readFile(path, "utf8", (error, data) => {
607
- * if (error) err(error);
608
- * else ok(data);
609
- * });
610
- * });
949
+ * const poll = repeat(checkStatus, recurs(3));
611
950
  *
612
- * // Wrap a non-cooperating promise API. Aborting the Task stops waiting even
613
- * // if promiseApi itself ignores AbortSignal.
614
- * const fromPromiseApi = (): Task<string> =>
615
- * callback(({ ok, err }) => {
616
- * void promiseApi().then(ok, err);
617
- * });
951
+ * await using run = createRun();
952
+ * expectOk(await run(poll), "pending");
953
+ * expect(attempts).toBe(4);
618
954
  * ```
619
955
  *
620
- * @group Composition
621
- */
622
- export const callback = (callback) => (run) => new Promise((resolve) => {
623
- const teardown = callback({
624
- ok: (value) => resolve(ok(value)),
625
- err: (error) => resolve(err(error)),
626
- signal: run.signal,
627
- deps: run.deps,
628
- });
629
- run.onAbort((reason) => {
630
- if (teardown)
631
- teardown();
632
- resolve(err(createAbortError(reason)));
633
- });
634
- });
635
- /**
636
- * Pauses execution for a specified duration.
637
- *
638
956
  * ### Example
639
957
  *
640
958
  * ```ts
641
- * const task: Task<void> = async (run) => {
642
- * console.log("Starting...");
643
- * await run(sleep("1s"));
644
- * console.log("Done after 1 second");
645
- * return ok();
646
- * };
647
- * ```
959
+ * import {
960
+ * createRun,
961
+ * done,
962
+ * err,
963
+ * ok,
964
+ * repeat,
965
+ * spaced,
966
+ * type NextTask,
967
+ * } from "@evolu/common";
648
968
  *
649
- * @group Composition
650
- */
651
- export const sleep = (duration) => callback(({ ok, deps: { time } }) => {
652
- const id = time.setTimeout(ok, durationToMillis(duration));
653
- return () => time.clearTimeout(id);
654
- });
655
- /**
656
- * Returns a {@link Task} that completes first.
969
+ * await using run = createRun();
657
970
  *
658
- * Like
659
- * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race},
660
- * the first Task to complete (whether success or failure) wins. All other Tasks
661
- * are aborted. Use {@link any} if you need the first Task to succeed instead.
971
+ * interface Item {
972
+ * readonly id: string;
973
+ * }
662
974
  *
663
- * Requires a non-empty array racing zero Tasks has no meaningful result
664
- * (there's no "first to complete" without participants). This is enforced at
665
- * compile time for non-empty tuple types. For other arrays, guard with
666
- * {@link isNonEmptyArray}:
975
+ * const queue: Array<Item> = [{ id: "item-1" }];
667
976
  *
668
- * ```ts
669
- * if (isNonEmptyArray(tasks)) {
670
- * await run(race(tasks));
671
- * }
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([]);
672
985
  * ```
673
986
  *
674
- * ### Example
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
+ *
1026
+ * ### Example
675
1027
  *
676
1028
  * ```ts
677
- * const fast: Task<string> = () => ok("fast");
678
- * const slow: Task<string> = async (run) => {
679
- * await run(sleep("10ms"));
680
- * 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");
681
1044
  * };
682
1045
  *
683
- * // First wins, others are aborted.
684
- * const result = await run(race([fast, slow])); // ok("fast")
685
- * ```
1046
+ * await using run = createRun();
1047
+ * const result = await run(any([unavailable, fallback]));
686
1048
  *
687
- * Always runs with unlimited concurrency — a sequential race makes no sense
688
- * since the first Task would always "win".
1049
+ * expectTypeOf(result).toEqualTypeOf<Result<string, "Unavailable">>();
1050
+ * expectOk(result, "fallback");
1051
+ * expect(fallbackStarted).toBe(true);
1052
+ * ```
689
1053
  *
690
- * @group Composition
1054
+ * @group Racing
691
1055
  */
692
- 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
+ };
693
1076
  /**
694
- * Abort reason for tasks that lose a {@link race}.
1077
+ * Runs Tasks until the first Task settles.
695
1078
  *
696
- * @group Composition
697
- */
698
- export const RaceLostError = /*#__PURE__*/ typed("RaceLostError");
699
- /**
700
- * {@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}.
701
1081
  *
702
- * @group Composition
703
- */
704
- export const raceLostError = { type: "RaceLostError" };
705
- /**
706
- * 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.
1085
+ *
1086
+ * Tasks always run concurrently because racing sequentially would be
1087
+ * meaningless.
707
1088
  *
708
- * Returns {@link TimeoutError} if the Task doesn't complete within the specified
709
- * duration. The original Task is aborted when the timeout fires.
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}:
710
1097
  *
711
1098
  * ### Example
712
1099
  *
713
1100
  * ```ts
714
- * const fetchWithTimeout = timeout(fetchData, "5s");
1101
+ * import {
1102
+ * createRun,
1103
+ * isNonEmptyArray,
1104
+ * ok,
1105
+ * race,
1106
+ * type Task,
1107
+ * } from "@evolu/common";
715
1108
  *
716
- * const result = await run(fetchWithTimeout);
717
- * if (!result.ok && result.error.type === "TimeoutError") {
718
- * 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");
719
1114
  * }
720
1115
  * ```
721
1116
  *
722
- * @group Composition
723
- */
724
- export const timeout = (task, duration, { abortReason = timeoutError, } = {}) => race([
725
- task,
726
- async (run) => {
727
- await run(sleep(duration));
728
- return err(timeoutError);
729
- },
730
- ], { abortReason });
731
- /**
732
- * Typed error returned by {@link timeout} when a task exceeds its time limit.
1117
+ * ### Example
733
1118
  *
734
- * @group Composition
735
- */
736
- export const TimeoutError = /*#__PURE__*/ typed("TimeoutError");
737
- /**
738
- * {@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";
739
1128
  *
740
- * @group Composition
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
+ * };
1138
+ *
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
741
1148
  */
742
- 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
+ };
743
1159
  /**
744
- * Wraps a {@link Task} with retry logic.
1160
+ * Runs Tasks until `count` Tasks return {@link Ok} or all Tasks settle.
1161
+ *
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.
745
1166
  *
746
- * Retries the Task according to the {@link Schedule}'s rules. Use
747
- * {@link RetryOptions.retryable} to filter which errors should trigger retries.
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.
748
1170
  *
749
- * All non-abort errors are wrapped in {@link RetryError}:
1171
+ * ### Example
750
1172
  *
751
- * - Task succeeds → `ok(value)`
752
- * - Task returns {@link AbortError} → `err(AbortError)` — passed through, no
753
- * retry, no wrapping
754
- * - Task returns any other error → retry until schedule exhausted or `retryable`
755
- * returns false → `err(RetryError)` with `cause` = the last error
1173
+ * ```ts
1174
+ * import {
1175
+ * createRun,
1176
+ * err,
1177
+ * firstN,
1178
+ * ok,
1179
+ * sleep,
1180
+ * type Task,
1181
+ * } from "@evolu/common";
756
1182
  *
757
- * The `RetryError` is informative: "I tried N times, here's why I finally gave
758
- * 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.
759
1225
  *
760
1226
  * ### Example
761
1227
  *
762
1228
  * ```ts
763
1229
  * import {
764
- * exponential,
765
- * jitter,
766
- * maxDelay,
767
- * retry,
768
- * take,
1230
+ * createRun,
1231
+ * err,
1232
+ * firstNSettled,
1233
+ * ok,
1234
+ * sleep,
1235
+ * type Task,
769
1236
  * } from "@evolu/common";
770
1237
  *
771
- * const fetchWithRetry = retry(
772
- * fetchData,
773
- * // A jittered, capped, limited exponential backoff.
774
- * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
775
- * );
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();
776
1250
  *
777
- * const result = await run(fetchWithRetry);
778
- * if (!result.ok) {
779
- * if (AbortError.is(result.error)) {
780
- * // Was aborted externally
781
- * } else {
782
- * // RetryError — failed after retrying
783
- * console.log(`Failed after ${result.error.attempts} attempts`);
784
- * console.log(`Last error:`, result.error.cause);
785
- * }
786
- * }
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);
787
1258
  * ```
788
1259
  *
789
- * The schedule receives the error as input, enabling error-aware strategies
790
- * 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
791
1298
  *
792
1299
  * ```ts
793
- * import { whileScheduleInput } from "@evolu/common";
1300
+ * import {
1301
+ * createRun,
1302
+ * each,
1303
+ * err,
1304
+ * ok,
1305
+ * sleep,
1306
+ * type Task,
1307
+ * } from "@evolu/common";
794
1308
  *
795
- * // Don't retry fatal errors
796
- * const smartRetry = retry(
797
- * fetchData,
798
- * whileScheduleInput((e: FetchError) => e.type !== "FatalError")(
799
- * 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 },
800
1331
  * ),
801
1332
  * );
1333
+ *
1334
+ * expectOk(result, undefined);
1335
+ * expect(first).toEqual(["fast", 2]);
1336
+ * expect(slowCompleted).toBe(false);
802
1337
  * ```
803
1338
  *
804
- * @group Composition
805
- * @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
806
1354
  */
807
- export const retry = (task, schedule, { retryable = lazyTrue, onRetry, } = {}) => async (run) => {
808
- const step = schedule(run.deps);
809
- let attempt = onePositiveInt;
810
- let error;
811
- for (;;) {
812
- if (error !== undefined) {
813
- const scheduleResult = step(error);
814
- if (!scheduleResult.ok) {
815
- return err({
816
- type: "RetryError",
817
- cause: error,
818
- attempts: PositiveInt.orThrow(decrement(attempt)),
819
- });
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
+ }
820
1392
  }
821
- const [output, delay] = scheduleResult.value;
822
- onRetry?.({
823
- error,
824
- attempt: PositiveInt.orThrow(decrement(attempt)),
825
- output,
826
- delay,
827
- });
828
- if (delay > 0) {
829
- const sleepResult = await run(sleep(delay));
830
- if (!sleepResult.ok)
831
- return sleepResult;
1393
+ catch (error) {
1394
+ stopped = true;
1395
+ throw error;
832
1396
  }
833
- }
834
- const result = await run(task);
835
- if (result.ok)
836
- return result;
837
- if (AbortError.is(result.error))
838
- return err(result.error);
839
- error = result.error;
840
- if (!retryable(error)) {
841
- return err({
842
- type: "RetryError",
843
- cause: error,
844
- attempts: attempt,
845
- });
846
- }
847
- 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);
848
1414
  }
849
1415
  };
850
1416
  /**
851
- * 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.
852
1425
  *
853
- * Runs the Task, then checks the schedule to determine if it should repeat. The
854
- * schedule controls how many repetitions occur and the delay between them.
855
- * 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.
856
1428
  *
857
- * 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);
858
1436
  *
859
- * Also works with {@link NextTask} — when the Task returns `Err(Done<D>)`,
860
- * 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.
861
1454
  *
862
1455
  * ### Example
863
1456
  *
864
1457
  * ```ts
865
- * import { fixed, take } from "@evolu/common/schedule";
866
- * import { repeat } from "@evolu/common";
1458
+ * import { createRun, ok, yieldNow, type Task } from "@evolu/common";
867
1459
  *
868
- * // Heartbeat every 30 seconds (runs forever until aborted)
869
- * const heartbeat = repeat(sendHeartbeat, fixed("30s"));
1460
+ * const sumTo =
1461
+ * (count: number): Task<number> =>
1462
+ * async (run) => {
1463
+ * let sum = 0;
870
1464
  *
871
- * // Poll 4 times total (initial + 3 repetitions), 1 second apart
872
- * 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
+ * }
873
1469
  *
874
- * // Process queue items until empty (NextTask pattern)
875
- * const processQueue: NextTask<Item, ProcessError, void> = async (run) => {
876
- * const item = queue.dequeue();
877
- * if (!item) return err(done()); // Queue empty, stop
878
- * await process(item);
879
- * return ok(item);
880
- * };
1470
+ * return ok(sum);
1471
+ * };
881
1472
  *
882
- * const result = await run(repeat(processQueue, fixed("100ms")));
883
- * if (!result.ok && result.error.type === "Done") {
884
- * console.log("Queue exhausted");
885
- * }
1473
+ * await using run = createRun();
1474
+ * expectOk(await run(sumTo(1001)), 500500);
886
1475
  * ```
887
1476
  *
888
- * @group Composition
1477
+ * @group Scheduling
889
1478
  */
890
- export const repeat = (task, schedule, { repeatable = lazyTrue, onRepeat, } = {}) => async (run) => {
891
- const step = schedule(run.deps);
892
- let lastResult;
893
- let attempt = onePositiveInt;
894
- for (;;) {
895
- const result = await run(task);
896
- if (!result.ok)
897
- return result;
898
- lastResult = result;
899
- if (!repeatable(result.value))
900
- return lastResult;
901
- const next = step(result.value);
902
- if (!next.ok)
903
- break;
904
- const [output, delay] = next.value;
905
- onRepeat?.({
906
- value: result.value,
907
- attempt,
908
- output,
909
- 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);
910
1495
  });
911
- attempt = PositiveInt.orThrow(increment(attempt));
912
- if (delay > 0) {
913
- const sleepResult = await run(sleep(delay));
914
- if (!sleepResult.ok)
915
- return sleepResult;
916
- }
917
1496
  }
918
- return lastResult;
1497
+ run.signal.throwIfAborted();
1498
+ return ok();
919
1499
  };
1500
+ // Abortability
920
1501
  /**
921
- * Creates a {@link Deferred}.
1502
+ * Waits until the current {@link Run} aborts, then rejects with its
1503
+ * {@link AbortError}.
922
1504
  *
923
- * @group Concurrency primitives
924
- */
925
- export const createDeferred = () => {
926
- let resolved = null;
927
- const resolvers = new Set();
928
- const resolve = (result) => {
929
- if (resolved !== null)
930
- return false;
931
- resolved = result;
932
- for (const resolver of resolvers)
933
- resolver(result);
934
- resolvers.clear();
935
- return true;
936
- };
937
- return {
938
- task: (run) => {
939
- if (resolved !== null)
940
- return resolved;
941
- return new Promise((resolvePromise) => {
942
- const resolve = (result) => {
943
- resolvers.delete(resolve);
944
- resolvePromise(result);
945
- };
946
- resolvers.add(resolve);
947
- run.onAbort((reason) => {
948
- resolve(err(createAbortError(reason)));
949
- });
950
- });
951
- },
952
- resolve,
953
- [Symbol.dispose]: () => {
954
- resolve(err(deferredDisposedError));
955
- },
956
- };
957
- };
958
- /**
959
- * 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.
960
1507
  *
961
- * @group Concurrency primitives
962
- */
963
- export const DeferredDisposedError = /*#__PURE__*/ typed("DeferredDisposedError");
964
- /**
965
- * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}.
1508
+ * ### Example
966
1509
  *
967
- * @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
968
1557
  */
969
- export const deferredDisposedError = {
970
- 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
+ }
971
1572
  };
972
1573
  /**
973
- * Creates a {@link Gate} that starts closed.
974
- *
975
- * Useful for "stop/go" logic where multiple Tasks need to wait for a state
976
- * 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.
977
1612
  *
978
1613
  * ### Example
979
1614
  *
980
1615
  * ```ts
981
- * const networkGate = createGate();
1616
+ * import {
1617
+ * createRun,
1618
+ * daemon,
1619
+ * ok,
1620
+ * timeout,
1621
+ * type Task,
1622
+ * } from "@evolu/common";
982
1623
  *
983
- * // Pause processing when offline
984
- * 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
+ * });
985
1633
  *
986
- * // Resume processing when online
987
- * 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
+ * ```
988
1644
  *
989
- * const syncLoop = async (run) => {
990
- * while (true) {
991
- * // Blocks here whenever the gate is closed
992
- * await run(networkGate.wait);
993
- * await run(uploadNextItem);
994
- * }
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"));
995
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>>();
996
1689
  * ```
997
1690
  *
998
- * @group Concurrency primitives
1691
+ * @group Lifetime
999
1692
  */
1000
- export const createGate = () => {
1001
- 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 };
1002
1695
  try {
1003
- const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
1004
- let isOpen = false;
1005
- let deferred = createDeferred();
1006
- disposer.defer(() => {
1007
- deferred[Symbol.dispose]();
1008
- });
1009
- const disposables = disposer.move();
1010
- return {
1011
- wait: (run) => {
1012
- if (disposables.disposed)
1013
- return err(deferredDisposedError);
1014
- if (isOpen)
1015
- return ok();
1016
- return run(deferred.task);
1017
- },
1018
- open: () => {
1019
- assertNotDisposed(disposables);
1020
- if (isOpen)
1021
- return;
1022
- isOpen = true;
1023
- deferred.resolve(ok());
1024
- },
1025
- close: () => {
1026
- assertNotDisposed(disposables);
1027
- if (!isOpen)
1028
- return;
1029
- isOpen = false;
1030
- deferred = createDeferred();
1031
- },
1032
- isOpen: () => {
1033
- assertNotDisposed(disposables);
1034
- return isOpen;
1035
- },
1036
- [Symbol.dispose]: () => disposables.dispose(),
1037
- };
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]);
1038
1711
  }
1039
- catch (e_1) {
1040
- env_1.error = e_1;
1041
- env_1.hasError = true;
1712
+ catch (e_3) {
1713
+ env_3.error = e_3;
1714
+ env_3.hasError = true;
1042
1715
  }
1043
1716
  finally {
1044
- __disposeResources(env_1);
1717
+ __disposeResources(env_3);
1045
1718
  }
1046
1719
  };
1047
1720
  /**
1048
- * 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.
1731
+ *
1732
+ * ### Example
1733
+ *
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
+ *
1746
+ * await using run = createRun();
1747
+ * const fiber = run.abortable(commit);
1748
+ * await commitStarted.promise;
1749
+ * fiber.abort();
1750
+ * finishCommit.resolve();
1751
+ *
1752
+ * expectOk(await fiber, "committed");
1753
+ * ```
1754
+ *
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.
1049
1777
  *
1050
1778
  * ### Example
1051
1779
  *
1052
1780
  * ```ts
1781
+ * import {
1782
+ * AbortError,
1783
+ * createRun,
1784
+ * ok,
1785
+ * unabortableMask,
1786
+ * waitForAbort,
1787
+ * type Task,
1788
+ * } from "@evolu/common";
1789
+ *
1053
1790
  * await using run = createRun();
1054
1791
  *
1055
- * const semaphore = createSemaphore(PositiveInt.orThrow(2));
1792
+ * interface Resource {
1793
+ * readonly id: string;
1794
+ * }
1056
1795
  *
1057
- * const fetchUser =
1058
- * (id: string): Task<string> =>
1796
+ * const acquire: Task<Resource> = () => ok({ id: "resource-1" });
1797
+ * const operationStarted = Promise.withResolvers<void>();
1798
+ * const operate =
1799
+ * (resource: Resource): Task<never> =>
1059
1800
  * async (run) => {
1060
- * const { console } = run.deps;
1061
- * console.log("[demo]", "start", id);
1062
- * const slept = await run(sleep("10ms"));
1063
- * if (!slept.ok) return slept;
1064
- * console.log("[demo]", "end", id);
1065
- * 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();
1066
1813
  * };
1067
1814
  *
1068
- * const fetchWithPermit = (id: string) =>
1069
- * 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
+ * );
1070
1829
  *
1071
- * await Promise.all([
1072
- * run(fetchWithPermit("1")),
1073
- * run(fetchWithPermit("2")),
1074
- * run(fetchWithPermit("3")),
1075
- * ]);
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.
1076
1864
  *
1077
- * // [demo] start 1
1078
- * // [demo] start 2
1079
- * // [demo] end 1
1080
- * // [demo] start 3
1081
- * // [demo] end 2
1082
- * // [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);
1083
1914
  * ```
1084
1915
  *
1085
- * @group Concurrency primitives
1916
+ * @group Abortability
1086
1917
  */
1087
- export const createSemaphore = (permits) => {
1088
- 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;
1089
1922
  try {
1090
- const disposer = __addDisposableResource(env_2, new DisposableStack(), false);
1091
- const fibers = new Set();
1092
- const waiters = [];
1093
- let taken = zeroNonNegativeInt;
1094
- disposer.defer(() => {
1095
- const env_3 = { stack: [], error: void 0, hasError: false };
1096
- try {
1097
- const disposer = __addDisposableResource(env_3, new DisposableStack(), false);
1098
- for (const fiber of fibers) {
1099
- disposer.adopt(fiber, (fiber) => {
1100
- fiber.abort(semaphoreDisposedError);
1101
- });
1102
- }
1103
- for (const waiter of waiters) {
1104
- waiter.resolve(err(semaphoreDisposedAbortError));
1105
- }
1106
- waiters.length = 0;
1107
- }
1108
- catch (e_3) {
1109
- env_3.error = e_3;
1110
- env_3.hasError = true;
1111
- }
1112
- finally {
1113
- __disposeResources(env_3);
1114
- }
1115
- });
1116
- const disposables = disposer.move();
1117
- const withPermits = (requestedPermits) => (task) => async (run) => {
1118
- const env_4 = { stack: [], error: void 0, hasError: false };
1119
- try {
1120
- const requested = PositiveInt.orThrow(requestedPermits);
1121
- assert(requested <= permits, "Requested permits must not exceed semaphore capacity.");
1122
- if (disposables.disposed)
1123
- return err(semaphoreDisposedAbortError);
1124
- if (waiters.length > 0 || taken + requested > permits) {
1125
- const waiter = Promise.withResolvers();
1126
- const waiting = {
1127
- permits: requested,
1128
- resolve: waiter.resolve,
1129
- };
1130
- waiters.push(waiting);
1131
- run.onAbort((reason) => {
1132
- const i = waiters.indexOf(waiting);
1133
- if (i >= 0)
1134
- waiters.splice(i, 1);
1135
- waiter.resolve(err(createAbortError(reason)));
1136
- });
1137
- const permit = await waiter.promise;
1138
- if (!permit.ok)
1139
- return permit;
1140
- }
1141
- else {
1142
- taken = NonNegativeInt.orThrow(taken + requested);
1143
- }
1144
- let fiber = null;
1145
- const _ = __addDisposableResource(env_4, {
1146
- [Symbol.dispose]: () => {
1147
- if (fiber)
1148
- fibers.delete(fiber);
1149
- taken = NonNegativeInt.orThrow(taken - requested);
1150
- while (waiters.length > 0) {
1151
- const waiter = waiters[0];
1152
- if (taken + waiter.permits > permits)
1153
- break;
1154
- waiters.shift();
1155
- taken = NonNegativeInt.orThrow(taken + waiter.permits);
1156
- waiter.resolve(ok());
1157
- }
1158
- },
1159
- }, false);
1160
- fiber = run(task);
1161
- fibers.add(fiber);
1162
- return await fiber;
1163
- }
1164
- catch (e_4) {
1165
- env_4.error = e_4;
1166
- env_4.hasError = true;
1167
- }
1168
- finally {
1169
- __disposeResources(env_4);
1170
- }
1171
- };
1172
- return {
1173
- withPermits,
1174
- withPermit: (task) => withPermits(1)(task),
1175
- snapshot: () => {
1176
- assertNotDisposed(disposables);
1177
- return {
1178
- permits,
1179
- taken,
1180
- waiting: NonNegativeInt.orThrow(waiters.length),
1181
- available: NonNegativeInt.orThrow(permits - taken),
1182
- isIdle: taken === 0 && waiters.length === 0,
1183
- disposed: disposables.disposed,
1184
- };
1185
- },
1186
- [Symbol.dispose]: () => disposables.dispose(),
1187
- };
1188
- }
1189
- catch (e_2) {
1190
- env_2.error = e_2;
1191
- 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)));
1192
1925
  }
1193
1926
  finally {
1194
- __disposeResources(env_2);
1927
+ await run.ok(release(resourceResult.value));
1195
1928
  }
1196
- };
1929
+ });
1197
1930
  /**
1198
- * Abort reason used when a {@link Semaphore} is disposed.
1931
+ * Creates a {@link Deferred}.
1199
1932
  *
1200
1933
  * @group Concurrency primitives
1201
1934
  */
1202
- 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
+ };
1203
1960
  /**
1204
- * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}.
1961
+ * Creates a {@link Gate}.
1205
1962
  *
1206
1963
  * @group Concurrency primitives
1207
1964
  */
1208
- export const semaphoreDisposedError = {
1209
- 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
+ };
1210
1996
  };
1211
- const semaphoreDisposedAbortError = createAbortError(semaphoreDisposedError);
1212
- export function createSemaphoreByKey(permits, { lookup = identity } = {}) {
1213
- const env_5 = { stack: [], error: void 0, hasError: false };
1214
- try {
1215
- const disposer = __addDisposableResource(env_5, new DisposableStack(), false);
1216
- const semaphoresByKey = createLookupMap({
1217
- lookup,
1218
- });
1219
- disposer.defer(() => {
1220
- const env_6 = { stack: [], error: void 0, hasError: false };
1221
- try {
1222
- const disposer = __addDisposableResource(env_6, new DisposableStack(), false);
1223
- disposer.defer(() => {
1224
- semaphoresByKey.clear();
1225
- });
1226
- for (const semaphore of semaphoresByKey.values()) {
1227
- disposer.use(semaphore);
1228
- }
1229
- }
1230
- catch (e_6) {
1231
- env_6.error = e_6;
1232
- env_6.hasError = true;
1233
- }
1234
- finally {
1235
- __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;
1236
2036
  }
1237
- });
1238
- const disposables = disposer.move();
1239
- const withPermits = (key, requestedPermits) => (task) => async (run) => {
1240
- 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 };
1241
2078
  try {
1242
- if (disposables.disposed)
1243
- return err(semaphoreDisposedAbortError);
1244
- let semaphore = semaphoresByKey.get(key);
1245
- if (!semaphore) {
1246
- semaphore = createSemaphore(permits);
1247
- semaphoresByKey.set(key, semaphore);
1248
- }
1249
- const _ = __addDisposableResource(env_7, {
1250
- [Symbol.dispose]: () => {
1251
- if (semaphoresByKey.get(key) !== semaphore)
1252
- return;
1253
- const snapshot = semaphore.snapshot();
1254
- if (snapshot.isIdle) {
1255
- semaphoresByKey.delete(key);
1256
- semaphore[Symbol.dispose]();
1257
- }
1258
- },
1259
- }, false);
1260
- 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));
1261
2087
  }
1262
- catch (e_7) {
1263
- env_7.error = e_7;
1264
- env_7.hasError = true;
2088
+ catch (e_5) {
2089
+ env_5.error = e_5;
2090
+ env_5.hasError = true;
1265
2091
  }
1266
2092
  finally {
1267
- __disposeResources(env_7);
2093
+ __disposeResources(env_5);
1268
2094
  }
1269
- };
1270
- return {
1271
- withPermit: (key, task) => withPermits(key, 1)(task),
1272
- withPermits,
1273
- snapshot: (key) => {
1274
- assertNotDisposed(disposables);
1275
- return semaphoresByKey.get(key)?.snapshot() ?? null;
1276
- },
1277
- [Symbol.dispose]: () => disposables.dispose(),
1278
- };
1279
- }
1280
- catch (e_5) {
1281
- env_5.error = e_5;
1282
- env_5.hasError = true;
1283
- }
1284
- finally {
1285
- __disposeResources(env_5);
1286
- }
1287
- }
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
+ };
1288
2112
  /**
1289
2113
  * Creates a {@link Mutex}.
1290
2114
  *
1291
2115
  * @group Concurrency primitives
1292
2116
  */
1293
2117
  export const createMutex = () => {
1294
- const semaphore = createSemaphore(onePositiveInt);
2118
+ const semaphore = createSemaphore(1);
1295
2119
  return {
1296
2120
  withLock: semaphore.withPermit,
1297
2121
  snapshot: semaphore.snapshot,
1298
- [Symbol.dispose]: semaphore[Symbol.dispose],
1299
2122
  };
1300
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
+ }
1301
2155
  export function createMutexByKey({ lookup = identity, } = {}) {
1302
- const semaphoreByKey = createSemaphoreByKey(onePositiveInt, {
1303
- lookup,
1304
- });
2156
+ const semaphoreByKey = createSemaphoreByKey(1, { lookup });
1305
2157
  return {
1306
2158
  withLock: (key, task) => semaphoreByKey.withPermit(key, task),
2159
+ isIdle: semaphoreByKey.isIdle,
1307
2160
  snapshot: semaphoreByKey.snapshot,
1308
- [Symbol.dispose]: semaphoreByKey[Symbol.dispose],
1309
2161
  };
1310
2162
  }
1311
2163
  /**
1312
- * Creates a {@link MutexRef} with the given initial immutable value.
2164
+ * Creates a {@link MutexRef}.
1313
2165
  *
1314
2166
  * @group Concurrency primitives
1315
2167
  */
@@ -1340,287 +2192,40 @@ export const createMutexRef = (initialValue) => {
1340
2192
  return ok(previousValue);
1341
2193
  }),
1342
2194
  updateAndGet: (updater) => mutex.withLock(async (run) => {
1343
- const currentValue = ref.get();
1344
- const nextValue = await run(updater(currentValue));
2195
+ const nextValue = await run(updater(ref.get()));
1345
2196
  if (!nextValue.ok)
1346
2197
  return nextValue;
1347
2198
  ref.set(nextValue.value);
1348
2199
  return ok(nextValue.value);
1349
2200
  }),
1350
2201
  modify: (modifier) => mutex.withLock(async (run) => {
1351
- const nextValue = await run(modifier(ref.get()));
1352
- if (!nextValue.ok)
1353
- return nextValue;
1354
- const [result, updatedValue] = nextValue.value;
1355
- ref.set(updatedValue);
1356
- 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);
1357
2208
  }),
1358
- [Symbol.dispose]: mutex[Symbol.dispose],
2209
+ snapshot: mutex.snapshot,
1359
2210
  };
1360
2211
  };
1361
- export function all(input, options) {
1362
- return collect("all", input, options);
1363
- }
1364
- /**
1365
- * Abort reason used by {@link all} when aborting remaining tasks.
1366
- *
1367
- * Used when a Task fails and other Tasks need to be aborted.
1368
- *
1369
- * @group Composition
1370
- */
1371
- export const AllAbortError = /*#__PURE__*/ typed("AllAbortError");
1372
- /**
1373
- * {@link AllAbortError} used as abort reason in {@link all}.
1374
- *
1375
- * @group Composition
1376
- */
1377
- export const allAbortError = { type: "AllAbortError" };
1378
- export function allSettled(input, options) {
1379
- return collect("allSettled", input, options);
1380
- }
1381
- /**
1382
- * Abort reason used by {@link allSettled} when aborted externally.
1383
- *
1384
- * @group Composition
1385
- */
1386
- export const AllSettledAbortError = /*#__PURE__*/ typed("AllSettledAbortError");
1387
- /**
1388
- * {@link AllSettledAbortError} used as abort reason in {@link allSettled}.
1389
- *
1390
- * @group Composition
1391
- */
1392
- export const allSettledAbortError = {
1393
- type: "AllSettledAbortError",
1394
- };
1395
- export function map(items, fn, { abortReason = mapAbortError, ...options } = {}) {
1396
- const mapped = mapInput(items, fn);
1397
- return all(mapped, {
1398
- ...options,
1399
- abortReason,
1400
- });
1401
- }
1402
- /**
1403
- * Abort reason used by {@link map} when aborting remaining tasks.
1404
- *
1405
- * @group Composition
1406
- */
1407
- export const MapAbortError = /*#__PURE__*/ typed("MapAbortError");
1408
- /**
1409
- * {@link MapAbortError} used as abort reason in {@link map}.
1410
- *
1411
- * @group Composition
1412
- */
1413
- export const mapAbortError = {
1414
- type: "MapAbortError",
1415
- };
1416
- export function mapSettled(items, task, options) {
1417
- const mapped = mapInput(items, task);
1418
- return allSettled(mapped, options);
1419
- }
1420
- export function any(tasks, options) {
1421
- const { allFailed = "input" } = options ?? {};
1422
- return pool(tasks, {
1423
- stopOn: "success",
1424
- collect: false,
1425
- abortReason: anyAbortError,
1426
- allFailed,
1427
- });
1428
- }
1429
- /**
1430
- * Abort reason used by {@link any} when aborting remaining tasks.
1431
- *
1432
- * @group Composition
1433
- */
1434
- export const AnyAbortError = /*#__PURE__*/ typed("AnyAbortError");
1435
- /**
1436
- * {@link AnyAbortError} used as abort reason in {@link any}.
1437
- *
1438
- * @group Composition
1439
- */
1440
- export const anyAbortError = { type: "AnyAbortError" };
1441
- /** Shared implementation for {@link all} and {@link allSettled}. */
1442
- const collect = (type, input, { collect = true, abortReason = type === "all" ? allAbortError : allSettledAbortError, } = {}) => {
1443
- const stopOn = type === "all" ? "error" : null;
1444
- if (isIterable(input)) {
1445
- const array = arrayFrom(input);
1446
- if (!isNonEmptyArray(array))
1447
- return () => ok(collect ? emptyArray : undefined);
1448
- return pool(array, {
1449
- stopOn,
1450
- collect,
1451
- abortReason,
1452
- });
1453
- }
1454
- const keys = [];
1455
- const taskArray = [];
1456
- for (const key in input) {
1457
- keys.push(key);
1458
- taskArray.push(input[key]);
1459
- }
1460
- if (keys.length === 0)
1461
- return () => ok(collect ? emptyRecord : undefined);
1462
- return async (run) => {
1463
- const result = await run(pool(taskArray, { stopOn, collect, abortReason }));
1464
- if (!result.ok)
1465
- return result;
1466
- if (!collect)
1467
- return ok();
1468
- const record = createRecord();
1469
- for (let i = 0; i < keys.length; i++) {
1470
- record[keys[i]] = result.value[i];
1471
- }
1472
- return ok(record);
1473
- };
1474
- };
1475
- const mapInput = (input, fn) => isIterable(input) ? mapArray(arrayFrom(input), fn) : mapObject(input, fn);
1476
- function pool(tasksIterable, { stopOn = null, collect, abortReason, allFailed, }) {
1477
- const tasks = arrayFrom(tasksIterable);
1478
- const { length } = tasks;
1479
- if (length === 0)
1480
- return () => ok(emptyArray);
1481
- return async (run) => {
1482
- const env_8 = { stack: [], error: void 0, hasError: false };
1483
- try {
1484
- const results = collect ? new Array(length) : null;
1485
- const aborted = Promise.withResolvers();
1486
- const stopSignal = stopOn ? Promise.withResolvers() : null;
1487
- let index = 0;
1488
- let stopped = null;
1489
- let lastResult = null;
1490
- let lastIndexResult = null;
1491
- const worker = async (run) => {
1492
- while (index < length && (stopOn ? !stopped : true)) {
1493
- const i = index++;
1494
- const result = (await run(tasks[i]));
1495
- lastResult = result;
1496
- if (i === length - 1)
1497
- lastIndexResult = result;
1498
- if (!stopOn) {
1499
- if (results)
1500
- results[i] = result;
1501
- continue;
1502
- }
1503
- const stop = stopOn === "first" ||
1504
- (stopOn === "error" && !result.ok) ||
1505
- (stopOn === "success" && result.ok);
1506
- if (!stop) {
1507
- if (results)
1508
- results[i] = result.value;
1509
- continue;
1510
- }
1511
- if (!stopped) {
1512
- stopped = result;
1513
- abortWorkers(!result.ok && AbortError.is(result.error)
1514
- ? result.error.reason
1515
- : abortReason);
1516
- stopSignal?.resolve();
1517
- }
1518
- break;
1519
- }
1520
- return ok();
1521
- };
1522
- let workersAborted = false;
1523
- const abortWorkers = (reason) => {
1524
- if (workersAborted)
1525
- return;
1526
- workersAborted = true;
1527
- for (const worker of workers)
1528
- worker.abort(reason);
1529
- };
1530
- const workerCount = Math.min(run.concurrency, length);
1531
- const workers = arrayFrom(workerCount, () => run.daemon(worker));
1532
- const _ = __addDisposableResource(env_8, new DisposableStack(), false);
1533
- _.defer(() => {
1534
- abortWorkers(abortReason);
1535
- });
1536
- run.onAbort((reason) => {
1537
- abortWorkers(reason);
1538
- aborted.resolve();
1539
- });
1540
- const waitFor = [Promise.all(workers), aborted.promise];
1541
- if (stopSignal)
1542
- waitFor.push(stopSignal.promise);
1543
- await Promise.race(waitFor);
1544
- if (run.signal.aborted) {
1545
- return err(run.signal.reason);
1546
- }
1547
- if (!stopOn)
1548
- return results ? ok(results) : ok();
1549
- if (stopped)
1550
- return stopped;
1551
- if (results)
1552
- return ok(results);
1553
- // For all/allSettled/map/mapSettled with collect: false (no allFailed handler)
1554
- if (!allFailed)
1555
- return ok();
1556
- return allFailed === "completion" ? lastResult : lastIndexResult;
1557
- }
1558
- catch (e_8) {
1559
- env_8.error = e_8;
1560
- env_8.hasError = true;
1561
- }
1562
- finally {
1563
- __disposeResources(env_8);
1564
- }
1565
- };
1566
- }
1567
- /**
1568
- * Error returned when a {@link fetch} {@link Task} fails.
1569
- *
1570
- * @group Composition
1571
- */
1572
- export const FetchError = /*#__PURE__*/ typed("FetchError", { error: Unknown });
1573
- /**
1574
- * Creates a {@link Task} that wraps the native
1575
- * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}.
1576
- *
1577
- * Handles cross-browser abort behavior — WebKit throws a `DOMException` with
1578
- * message "Fetch is aborted" instead of propagating `signal.reason`. This
1579
- * helper normalizes the behavior to always return {@link AbortError}.
1580
- *
1581
- * ### Example
1582
- *
1583
- * ```ts
1584
- * await using run = createRun();
1585
- *
1586
- * const result = await run(fetch("https://api.example.com/users"));
1587
- *
1588
- * if (!result.ok) {
1589
- * // Handle FetchError or AbortError
1590
- * }
1591
- *
1592
- * // Compose with timeout and retry
1593
- * const fetchWithRetry = (url: string) =>
1594
- * retry(timeout(fetch(url), "10s"), retryStrategyAws);
1595
- * ```
1596
- *
1597
- * @group Composition
1598
- */
1599
- export const fetch = (input, init) => ({ signal }) => tryAsync(() => globalThis.fetch(input, { ...init, signal }), (error) => {
1600
- if (AbortError.is(error))
1601
- return error;
1602
- // WebKit throws DOMException with message "Fetch is aborted" instead of
1603
- // propagating signal.reason. Detect this and create AbortError.
1604
- if (signal.aborted &&
1605
- error instanceof Error &&
1606
- error.message === "Fetch is aborted") {
1607
- return createAbortError(signal.reason);
1608
- }
1609
- return { type: "FetchError", error };
1610
- });
1611
- // TODO: Prioritized Task Scheduling API integration
1612
- // https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
1613
- //
1614
- // - `run(task, { priority })` - Fiber-level priority via TaskController
1615
- // (extends AbortController), so the fiber's signal gets priority and all
1616
- // nested work inherits it.
1617
- // - `fiber.setPriority(priority)` - Dynamic priority changes mid-flight via
1618
- // TaskController.setPriority().
1619
- //
1620
- // Note: scheduler.yield() inherits priority from enclosing postTask, so
1621
- // yieldNow doesn't need a priority argument — just run the fiber at priority.
1622
- //
1623
- // Safari doesn't support it yet, Node.js probably never will (use setImmediate).
1624
- // For Safari, scheduler-polyfill can be used.
1625
- // https://www.npmjs.com/package/scheduler-polyfill
1626
- // 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.