@evolu/common 7.4.0 → 8.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +4 -61
  2. package/dist/src/Array.d.ts +343 -102
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +181 -85
  5. package/dist/src/Assert.d.ts +57 -11
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +47 -11
  8. package/dist/src/BigInt.d.ts +6 -1
  9. package/dist/src/BigInt.d.ts.map +1 -1
  10. package/dist/src/BigInt.js +5 -0
  11. package/dist/src/Brand.d.ts +40 -12
  12. package/dist/src/Brand.d.ts.map +1 -1
  13. package/dist/src/Brand.js +5 -0
  14. package/dist/src/Buffer.d.ts +6 -1
  15. package/dist/src/Buffer.d.ts.map +1 -1
  16. package/dist/src/Buffer.js +7 -2
  17. package/dist/src/Cache.d.ts +10 -2
  18. package/dist/src/Cache.d.ts.map +1 -1
  19. package/dist/src/Cache.js +6 -0
  20. package/dist/src/Callbacks.d.ts +10 -7
  21. package/dist/src/Callbacks.d.ts.map +1 -1
  22. package/dist/src/Callbacks.js +23 -13
  23. package/dist/src/Console.d.ts +361 -69
  24. package/dist/src/Console.d.ts.map +1 -1
  25. package/dist/src/Console.js +217 -117
  26. package/dist/src/Crypto.d.ts +70 -21
  27. package/dist/src/Crypto.d.ts.map +1 -1
  28. package/dist/src/Crypto.js +72 -27
  29. package/dist/src/Eq.d.ts +22 -2
  30. package/dist/src/Eq.d.ts.map +1 -1
  31. package/dist/src/Eq.js +21 -1
  32. package/dist/src/Error.d.ts +23 -12
  33. package/dist/src/Error.d.ts.map +1 -1
  34. package/dist/src/Error.js +27 -11
  35. package/dist/src/Function.d.ts +128 -56
  36. package/dist/src/Function.d.ts.map +1 -1
  37. package/dist/src/Function.js +118 -14
  38. package/dist/src/Identicon.d.ts +5 -0
  39. package/dist/src/Identicon.d.ts.map +1 -1
  40. package/dist/src/Identicon.js +6 -1
  41. package/dist/src/Lookup.d.ts +160 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +192 -0
  44. package/dist/src/Microtask.d.ts +21 -0
  45. package/dist/src/Microtask.d.ts.map +1 -0
  46. package/dist/src/Microtask.js +37 -0
  47. package/dist/src/Number.d.ts +18 -4
  48. package/dist/src/Number.d.ts.map +1 -1
  49. package/dist/src/Number.js +23 -1
  50. package/dist/src/Object.d.ts +110 -5
  51. package/dist/src/Object.d.ts.map +1 -1
  52. package/dist/src/Object.js +108 -5
  53. package/dist/src/Option.d.ts +58 -0
  54. package/dist/src/Option.d.ts.map +1 -0
  55. package/dist/src/Option.js +43 -0
  56. package/dist/src/Order.d.ts +5 -0
  57. package/dist/src/Order.d.ts.map +1 -1
  58. package/dist/src/Order.js +8 -3
  59. package/dist/src/Platform.d.ts +36 -2
  60. package/dist/src/Platform.d.ts.map +1 -1
  61. package/dist/src/Platform.js +10 -6
  62. package/dist/src/Polyfills.d.ts +27 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +299 -0
  65. package/dist/src/Random.d.ts +18 -7
  66. package/dist/src/Random.d.ts.map +1 -1
  67. package/dist/src/Random.js +9 -7
  68. package/dist/src/Redacted.d.ts +7 -4
  69. package/dist/src/Redacted.d.ts.map +1 -1
  70. package/dist/src/Redacted.js +5 -0
  71. package/dist/src/Ref.d.ts +31 -16
  72. package/dist/src/Ref.d.ts.map +1 -1
  73. package/dist/src/Ref.js +35 -7
  74. package/dist/src/RefCount.d.ts +62 -0
  75. package/dist/src/RefCount.d.ts.map +1 -0
  76. package/dist/src/RefCount.js +83 -0
  77. package/dist/src/Relation.d.ts +84 -29
  78. package/dist/src/Relation.d.ts.map +1 -1
  79. package/dist/src/Relation.js +83 -105
  80. package/dist/src/Resource.d.ts +263 -0
  81. package/dist/src/Resource.d.ts.map +1 -0
  82. package/dist/src/Resource.js +389 -0
  83. package/dist/src/Result.d.ts +390 -374
  84. package/dist/src/Result.d.ts.map +1 -1
  85. package/dist/src/Result.js +142 -70
  86. package/dist/src/Schedule.d.ts +953 -0
  87. package/dist/src/Schedule.d.ts.map +1 -0
  88. package/dist/src/Schedule.js +1199 -0
  89. package/dist/src/Set.d.ts +181 -0
  90. package/dist/src/Set.d.ts.map +1 -0
  91. package/dist/src/Set.js +137 -0
  92. package/dist/src/Skiplist.d.ts +6 -1
  93. package/dist/src/Skiplist.d.ts.map +1 -1
  94. package/dist/src/Skiplist.js +5 -0
  95. package/dist/src/Sqlite.d.ts +160 -60
  96. package/dist/src/Sqlite.d.ts.map +1 -1
  97. package/dist/src/Sqlite.js +290 -190
  98. package/dist/src/Store.d.ts +36 -28
  99. package/dist/src/Store.d.ts.map +1 -1
  100. package/dist/src/Store.js +55 -15
  101. package/dist/src/String.d.ts +5 -0
  102. package/dist/src/String.d.ts.map +1 -1
  103. package/dist/src/String.js +5 -0
  104. package/dist/src/Task.d.ts +2263 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1488 -348
  107. package/dist/src/Test.d.ts +122 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +66 -0
  110. package/dist/src/Time.d.ts +201 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +190 -86
  113. package/dist/src/Tracer.d.ts +48 -0
  114. package/dist/src/Tracer.d.ts.map +1 -0
  115. package/dist/src/Tracer.js +6 -0
  116. package/dist/src/Type.d.ts +582 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +362 -240
  119. package/dist/src/Types.d.ts +160 -6
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/Types.js +22 -2
  122. package/dist/src/WebSocket.d.ts +113 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +210 -118
  125. package/dist/src/Worker.d.ts +245 -60
  126. package/dist/src/Worker.d.ts.map +1 -1
  127. package/dist/src/Worker.js +220 -64
  128. package/dist/src/index.d.ts +25 -3
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +19 -3
  131. package/dist/src/local-first/Db.d.ts +24 -213
  132. package/dist/src/local-first/Db.d.ts.map +1 -1
  133. package/dist/src/local-first/Db.js +622 -293
  134. package/dist/src/local-first/Error.d.ts +12 -0
  135. package/dist/src/local-first/Error.d.ts.map +1 -0
  136. package/dist/src/local-first/Error.js +6 -0
  137. package/dist/src/local-first/Evolu.d.ts +330 -285
  138. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  139. package/dist/src/local-first/Evolu.js +472 -402
  140. package/dist/src/local-first/LocalAuth.d.ts +10 -10
  141. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  142. package/dist/src/local-first/LocalAuth.js +6 -1
  143. package/dist/src/local-first/Owner.d.ts +70 -27
  144. package/dist/src/local-first/Owner.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.js +27 -14
  146. package/dist/src/local-first/Protocol.d.ts +58 -50
  147. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  148. package/dist/src/local-first/Protocol.js +253 -155
  149. package/dist/src/local-first/Query.d.ts +133 -43
  150. package/dist/src/local-first/Query.d.ts.map +1 -1
  151. package/dist/src/local-first/Query.js +139 -90
  152. package/dist/src/local-first/Relay.d.ts +18 -38
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -171
  155. package/dist/src/local-first/Schema.d.ts +139 -144
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +78 -163
  158. package/dist/src/local-first/Shared.d.ts +125 -0
  159. package/dist/src/local-first/Shared.d.ts.map +1 -0
  160. package/dist/src/local-first/Shared.js +569 -0
  161. package/dist/src/local-first/Storage.d.ts +50 -53
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +121 -173
  164. package/dist/src/local-first/Timestamp.d.ts +28 -35
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +24 -25
  167. package/dist/src/local-first/index.d.ts +2 -15
  168. package/dist/src/local-first/index.d.ts.map +1 -1
  169. package/dist/src/local-first/index.js +2 -15
  170. package/package.json +36 -26
  171. package/src/Array.ts +467 -106
  172. package/src/Assert.ts +87 -12
  173. package/src/BigInt.ts +7 -1
  174. package/src/Brand.ts +41 -12
  175. package/src/Buffer.ts +10 -4
  176. package/src/Cache.ts +11 -2
  177. package/src/Callbacks.ts +33 -23
  178. package/src/Console.ts +567 -178
  179. package/src/Crypto.ts +111 -62
  180. package/src/Eq.ts +30 -3
  181. package/src/Error.ts +50 -34
  182. package/src/Function.ts +140 -68
  183. package/src/Identicon.ts +7 -1
  184. package/src/Lookup.ts +415 -0
  185. package/src/Microtask.ts +58 -0
  186. package/src/Number.ts +40 -7
  187. package/src/Object.ts +155 -16
  188. package/src/Option.ts +74 -0
  189. package/src/Order.ts +11 -3
  190. package/src/Platform.ts +43 -7
  191. package/src/Polyfills.ts +465 -0
  192. package/src/Random.ts +25 -11
  193. package/src/Redacted.ts +8 -4
  194. package/src/Ref.ts +72 -21
  195. package/src/RefCount.ts +170 -0
  196. package/src/Relation.ts +188 -136
  197. package/src/Resource.ts +864 -0
  198. package/src/Result.ts +548 -381
  199. package/src/Schedule.ts +1471 -0
  200. package/src/Set.ts +247 -0
  201. package/src/Skiplist.ts +7 -1
  202. package/src/Sqlite.ts +409 -304
  203. package/src/Store.ts +94 -48
  204. package/src/String.ts +6 -0
  205. package/src/Task.ts +3685 -740
  206. package/src/Test.ts +162 -0
  207. package/src/Time.ts +348 -145
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +1064 -630
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +311 -178
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +871 -597
  215. package/src/local-first/Error.ts +17 -0
  216. package/src/local-first/Evolu.ts +884 -823
  217. package/src/local-first/LocalAuth.ts +10 -10
  218. package/src/local-first/Owner.ts +94 -42
  219. package/src/local-first/Protocol.ts +440 -303
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +91 -252
  222. package/src/local-first/Schema.ts +284 -394
  223. package/src/local-first/Shared.ts +932 -0
  224. package/src/local-first/Storage.ts +209 -282
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/LICENSE +0 -21
  228. package/dist/src/Instances.d.ts +0 -34
  229. package/dist/src/Instances.d.ts.map +0 -1
  230. package/dist/src/Instances.js +0 -44
  231. package/dist/src/Resources.d.ts +0 -118
  232. package/dist/src/Resources.d.ts.map +0 -1
  233. package/dist/src/Resources.js +0 -197
  234. package/dist/src/local-first/Platform.d.ts +0 -25
  235. package/dist/src/local-first/Platform.d.ts.map +0 -1
  236. package/dist/src/local-first/Platform.js +0 -1
  237. package/dist/src/local-first/Public.d.ts +0 -22
  238. package/dist/src/local-first/Public.d.ts.map +0 -1
  239. package/dist/src/local-first/Public.js +0 -15
  240. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  241. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  242. package/dist/src/local-first/PublicKysely.js +0 -184
  243. package/dist/src/local-first/Sync.d.ts +0 -112
  244. package/dist/src/local-first/Sync.d.ts.map +0 -1
  245. package/dist/src/local-first/Sync.js +0 -529
  246. package/src/Instances.ts +0 -90
  247. package/src/Resources.ts +0 -367
  248. package/src/local-first/Platform.ts +0 -27
  249. package/src/local-first/Public.ts +0 -42
  250. package/src/local-first/PublicKysely.ts +0 -239
  251. package/src/local-first/Sync.ts +0 -960
package/dist/src/Task.js CHANGED
@@ -1,466 +1,1606 @@
1
- import { isNonEmptyArray, shiftArray } from "./Array.js";
2
- import { err, ok } from "./Result.js";
3
- import { durationToNonNegativeInt } from "./Time.js";
4
- import { NonNegativeInt, PositiveInt } from "./Type.js";
5
- /** Narrower check to detect AbortError objects at runtime. */
6
- const isAbortError = (error) => typeof error === "object" &&
7
- error !== null &&
8
- error.type === "AbortError";
9
- // For React Native
10
- if (typeof AbortSignal.any !== "function") {
11
- AbortSignal.any = function (signals) {
12
- const controller = new AbortController();
13
- const onAbort = (event) => {
14
- controller.abort(event.target.reason);
15
- cleanup();
16
- };
17
- const cleanup = () => {
18
- for (const s of signals)
19
- s.removeEventListener("abort", onAbort);
20
- };
21
- for (const s of signals) {
22
- if (s.aborted) {
23
- controller.abort(s.reason);
24
- return controller.signal;
1
+ /**
2
+ * JavaScript-native structured concurrency.
3
+ *
4
+ * @module
5
+ */
6
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
7
+ if (value !== null && value !== void 0) {
8
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
9
+ var dispose, inner;
10
+ if (async) {
11
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
12
+ dispose = value[Symbol.asyncDispose];
13
+ }
14
+ if (dispose === void 0) {
15
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
16
+ dispose = value[Symbol.dispose];
17
+ if (async) inner = dispose;
18
+ }
19
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
20
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
21
+ env.stack.push({ value: value, dispose: dispose, async: async });
22
+ }
23
+ else if (async) {
24
+ env.stack.push({ async: true });
25
+ }
26
+ return value;
27
+ };
28
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
29
+ return function (env) {
30
+ function fail(e) {
31
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
32
+ env.hasError = true;
33
+ }
34
+ var r, s = 0;
35
+ function next() {
36
+ while (r = env.stack.pop()) {
37
+ try {
38
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
39
+ if (r.dispose) {
40
+ var result = r.dispose.call(r.value);
41
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
42
+ }
43
+ else s |= 1;
44
+ }
45
+ catch (e) {
46
+ fail(e);
47
+ }
25
48
  }
26
- s.addEventListener("abort", onAbort);
49
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
50
+ if (env.hasError) throw env.error;
27
51
  }
28
- return controller.signal;
52
+ return next();
29
53
  };
30
- }
54
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
55
+ var e = new Error(message);
56
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
57
+ });
58
+ import { arrayFrom, emptyArray, isNonEmptyArray, mapArray, } from "./Array.js";
59
+ import { assert } from "./Assert.js";
60
+ import { createConsole } from "./Console.js";
61
+ import { createRandomBytes } from "./Crypto.js";
62
+ import { eqArrayStrict } from "./Eq.js";
63
+ import { identity, lazyTrue, lazyVoid } from "./Function.js";
64
+ 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";
68
+ 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";
31
74
  /**
32
- * Combines user signal from context with an internal signal.
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)`.
85
+ *
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.
92
+ *
93
+ * @group Core Types
94
+ */
95
+ export const AbortError = /*#__PURE__*/ typed("AbortError", {
96
+ reason: Unknown,
97
+ });
98
+ /**
99
+ * Abort mask depth for a {@link Run} or {@link Fiber}.
100
+ *
101
+ * - `0` — abortable (default)
102
+ * - `>= 1` — inside {@link unabortable}, abort requests are ignored
103
+ *
104
+ * The mask tracks nested unabortable regions. When abort is requested, the
105
+ * signal only propagates if `mask === 0`.
106
+ *
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
110
+ *
111
+ * This enables nested resource lifecycle patterns where each level can have its
112
+ * own abortable section while outer acquisitions remain protected.
113
+ *
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.
33
116
  *
34
- * If the context has a signal, combines both signals using AbortSignal.any().
35
- * Otherwise, returns just the internal signal.
117
+ * @group Abort masking
36
118
  */
37
- const combineSignal = (context, internalSignal) => context?.signal
38
- ? AbortSignal.any([context.signal, internalSignal])
39
- : internalSignal;
119
+ export const AbortMask = /*#__PURE__*/ brand("AbortMask", NonNegativeInt);
40
120
  /**
41
- * Converts async function returning {@link Result} to a {@link Task}.
121
+ * {@link RunSnapshot} state Type.
122
+ *
123
+ * @group Monitoring
124
+ */
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
+ }));
132
+ /**
133
+ * The event-specific payload of a {@link RunEvent}.
134
+ *
135
+ * @group Monitoring
136
+ */
137
+ export const RunEventData = /*#__PURE__*/ union(
138
+ /*#__PURE__*/ typed("ChildAdded", { childId: Id }),
139
+ /*#__PURE__*/ typed("ChildRemoved", { childId: Id }),
140
+ /*#__PURE__*/ typed("StateChanged", { state: RunSnapshotState }));
141
+ /**
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.
146
+ *
147
+ * @group Monitoring
148
+ */
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(),
162
+ };
163
+ /**
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}
42
184
  *
43
185
  * ### Example
44
186
  *
45
187
  * ```ts
46
- * interface FetchError {
47
- * readonly type: "FetchError";
48
- * readonly error: unknown;
188
+ * // App entry point
189
+ * await using run = createRun();
190
+ *
191
+ * const result = await run(fetchData);
192
+ * ```
193
+ *
194
+ * ### Example with custom dependencies
195
+ *
196
+ * ```ts
197
+ * // Define dependency interfaces
198
+ * interface Config {
199
+ * readonly apiUrl: string;
200
+ * }
201
+ *
202
+ * interface ConfigDep {
203
+ * readonly config: Config;
49
204
  * }
50
205
  *
51
- * // Task version of fetch with proper error handling and cancellation support.
52
- * const fetch = (url: string) =>
53
- * toTask((context) =>
54
- * tryAsync(
55
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
56
- * (error): FetchError => ({ type: "FetchError", error }),
57
- * ),
58
- * );
59
- *
60
- * // `satisfies` shows the expected type signature.
61
- * fetch satisfies (url: string) => Task<Response, FetchError>;
62
- *
63
- * const result1 = await fetch("https://api.example.com/data")();
64
- * result1 satisfies Result<Response, FetchError>;
65
- *
66
- * // With AbortController
67
- * const controller = new AbortController();
68
- * const result2 = await fetch("https://api.example.com/data")(controller);
69
- * result2 satisfies Result<Response, FetchError | AbortError>;
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
+ * };
214
+ *
215
+ * // Composition root: create a Run with custom deps
216
+ * type AppDeps = RunDeps & ConfigDep;
217
+ *
218
+ * const appDeps: AppDeps = {
219
+ * ...testCreateDeps(), // or spread individual deps
220
+ * config: { apiUrl: "https://api.example.com" },
221
+ * };
222
+ *
223
+ * await using run = createRun(appDeps);
224
+ *
225
+ * // Run type is inferred from the deps argument
226
+ * const result = await run(fetchUser("123"));
227
+ *
228
+ * // TypeScript catches missing deps at compile time:
229
+ * // await using run2 = createRun(); // Run<RunDeps>
230
+ * // run2(fetchUser("123")); // Error: Property 'config' is missing
70
231
  * ```
232
+ *
233
+ * @group Creating Run
71
234
  */
72
- export const toTask = (fn) =>
73
- // Note: Not using async to avoid Promise wrapper overhead in fast path
74
- ((context) => {
75
- const signal = context?.signal;
76
- // Fast path when no signal return promise directly
77
- if (!signal) {
78
- // Preserve future context fields (e.g., tracing) even without a signal
79
- return fn(context);
235
+ export const createRun = (deps) => {
236
+ const mergedDeps = { ...runDeps, ...deps };
237
+ return createRunInternal(createRef(mergedDeps))();
238
+ };
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;
80
252
  }
81
- if (signal.aborted) {
82
- return Promise.resolve(err({ type: "AbortError", reason: signal.reason }));
253
+ const requestController = new AbortController();
254
+ 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);
264
+ };
265
+ if (parent) {
266
+ subscribeToAbort(parent.requestSignal, () => requestAbort(parent.requestSignal.reason), { signal: requestController.signal });
83
267
  }
84
- // Use Promise.withResolvers for clean abort handling and cleanup
85
- const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers();
86
- const handleAbort = () => {
87
- resolveAbort(err({ type: "AbortError", reason: signal.reason }));
268
+ 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);
275
+ }
88
276
  };
89
- signal.addEventListener("abort", handleAbort, { once: true });
90
- // No finally: we expect no throws in normal flow; Result path removes listener.
91
- // Unexpected throws indicate a bug and are allowed to crash (no recovery here).
92
- return Promise.race([
93
- abortPromise,
94
- fn(context).then((result) => {
95
- signal.removeEventListener("abort", handleAbort);
96
- return result;
97
- }),
98
- ]);
99
- });
100
- // For React Native
101
- if (typeof AbortSignal.timeout !== "function") {
102
- AbortSignal.timeout = function (ms) {
103
- const controller = new AbortController();
104
- const id = setTimeout(() => {
105
- controller.abort();
106
- }, ms);
107
- // clear timeout if aborted early
108
- controller.signal.addEventListener("abort", () => {
109
- clearTimeout(id);
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);
282
+ }
283
+ else if (signalController.signal.aborted &&
284
+ childRun.abortMask === isAbortable) {
285
+ childRun.requestAbort(signalController.signal.reason);
286
+ task = () => err(signalController.signal.reason);
287
+ }
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
+ },
110
304
  });
111
- return controller.signal;
305
+ children = addToSet(children, childFiber);
306
+ emitEvent({ type: "ChildAdded", childId: childRun.id });
307
+ return childFiber;
112
308
  };
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;
339
+ };
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;
354
+ };
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 });
376
+ });
377
+ return disposingPromise;
378
+ };
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;
390
+ };
391
+ run.handleTaskSettled = () => {
392
+ run.ownTaskSettled?.resolve();
393
+ };
394
+ }
395
+ return self;
396
+ };
397
+ const running = { type: "Running" };
398
+ /**
399
+ * Abort reason indicating a {@link Run} can no longer start new Tasks.
400
+ *
401
+ * Covers both disposing and settled Runs.
402
+ *
403
+ * @group Creating Run
404
+ */
405
+ export const RunStoppedError = /*#__PURE__*/ typed("RunStoppedError");
406
+ /**
407
+ * Shared {@link RunStoppedError} instance used as the default
408
+ * {@link AbortError.reason} when a Task is started on a non-running {@link Run}.
409
+ *
410
+ * @group Creating Run
411
+ */
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,
431
+ });
432
+ /**
433
+ * Makes a {@link Task} unabortable.
434
+ *
435
+ * Once started, an unabortable Task always completes — abort requests are
436
+ * ignored and `signal.aborted` remains `false`.
437
+ *
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”.
442
+ *
443
+ * When that pre-start abort would be a programmer error, assert it explicitly
444
+ * with `assertNotAborted` after awaiting the result.
445
+ *
446
+ * ### Example
447
+ *
448
+ * ```ts
449
+ * await using run = createRun();
450
+ *
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
+ * };
464
+ *
465
+ * // Important events must be sent even if the user navigates away
466
+ * const trackImportantEvent = (event: number) =>
467
+ * unabortable(sendToAnalytics(event));
468
+ *
469
+ * // User clicks, we start tracking (Task runs until first await)
470
+ * const fiber = run(trackImportantEvent(123));
471
+ *
472
+ * // User navigates away (abort requested while task is running)
473
+ * fiber.abort();
474
+ * canComplete.resolve();
475
+ *
476
+ * const result = await fiber;
477
+ *
478
+ * expect(signalAbortedInAnalytics).toBe(false);
479
+ * // Analytics was sent despite abort
480
+ * expect(events).toEqual(["sent 123"]);
481
+ * expect(result).toEqual(ok());
482
+ * ```
483
+ *
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.
491
+ *
492
+ * Tasks inherit abort masking from their parent. This means:
493
+ *
494
+ * - Tasks run inside `unabortableMask` are unabortable by default
495
+ * - Tasks wrapped with `restore()` restore the previous abortability
496
+ *
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
+ });
113
509
  }
114
510
  /**
115
- * Creates a {@link Task} that waits for the specified duration.
511
+ * Yields execution to allow other work to proceed.
512
+ *
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.
517
+ *
518
+ * Uses `scheduler.yield()` in browsers for optimal main thread scheduling,
519
+ * falls back to `setImmediate` in Node.js, or `setTimeout` elsewhere.
116
520
  *
117
521
  * ### Example
118
522
  *
119
523
  * ```ts
120
- * const result1 = await wait("10ms")();
121
- * result1 satisfies Result<void, never>;
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
+ * }
122
538
  *
123
- * // With AbortController
124
- * const controller = new AbortController();
125
- * const result2 = await wait("10ms")(controller);
126
- * result2 satisfies Result<void, AbortError>;
539
+ * return ok();
540
+ * };
127
541
  * ```
542
+ *
543
+ * Recursive tasks also benefit from periodic yields — without them, deep
544
+ * recursion overflows the call stack:
545
+ *
546
+ * ```ts
547
+ * const processRecursive =
548
+ * (count: number, index: number, sum: number): Task<number> =>
549
+ * async (run) => {
550
+ * if (index >= count) return ok(sum);
551
+ *
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
+ * }
557
+ *
558
+ * // Direct tail-call: no fiber overhead, stack-safe thanks to yieldNow.
559
+ * return await processRecursive(count, index + 1, sum + index)(run);
560
+ * };
561
+ * ```
562
+ *
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
128
566
  */
129
- export const wait = (duration) => toTask((context) => new Promise((resolve) => {
130
- const ms = durationToNonNegativeInt(duration);
131
- const timeoutSignal = AbortSignal.timeout(ms);
132
- const signal = combineSignal(context, timeoutSignal);
133
- // Listen for abort - either from timeout completion or external abort
134
- signal.addEventListener("abort", () => {
135
- resolve(ok());
136
- }, { once: true });
137
- }));
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
138
575
  /**
139
- * Adds timeout behavior to a {@link Task}.
576
+ * Creates a {@link Task} from a callback-based API.
577
+ *
578
+ * Use this to wrap callback-style APIs (event listeners, Node.js callbacks,
579
+ * etc.) into Tasks with proper abort handling.
580
+ *
581
+ * Optionally return a teardown function that runs on abort.
140
582
  *
141
583
  * ### Example
142
584
  *
143
585
  * ```ts
144
- * interface FetchError {
145
- * readonly type: "FetchError";
146
- * readonly error: unknown;
147
- * }
586
+ * // The sleep helper is implemented using callback:
587
+ * const sleep = (duration: Duration): Task<void> =>
588
+ * callback(({ ok, deps: { time } }) => {
589
+ * const id = time.setTimeout(ok, durationToMillis(duration));
590
+ * return () => time.clearTimeout(id);
591
+ * });
148
592
  *
149
- * // Task version of fetch with proper error handling and cancellation support.
150
- * const fetch = (url: string) =>
151
- * toTask((context) =>
152
- * tryAsync(
153
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
154
- * (error): FetchError => ({ type: "FetchError", error }),
155
- * ),
156
- * );
593
+ * // Wrap an event listener use signal directly
594
+ * const waitForClick = (element: HTMLElement): Task<MouseEvent> =>
595
+ * callback(({ ok, signal }) => {
596
+ * element.addEventListener("click", ok, { once: true, signal });
597
+ * });
157
598
  *
158
- * // `satisfies` shows the expected type signature.
159
- * fetch satisfies (url: string) => Task<Response, FetchError>;
599
+ * // Wrap Node.js callback API
600
+ * const readFile = (path: string): Task<string, NodeJS.ErrnoException> =>
601
+ * callback(({ ok, err }) => {
602
+ * fs.readFile(path, "utf8", (error, data) => {
603
+ * if (error) err(error);
604
+ * else ok(data);
605
+ * });
606
+ * });
607
+ * ```
160
608
  *
161
- * const fetchWithTimeout = (url: string) => timeout("2m", fetch(url));
609
+ * @group Composition
610
+ */
611
+ export const callback = (callback) => (run) => new Promise((resolve) => {
612
+ const teardown = callback({
613
+ ok: (value) => resolve(ok(value)),
614
+ err: (error) => resolve(err(error)),
615
+ signal: run.signal,
616
+ deps: run.deps,
617
+ });
618
+ run.onAbort((reason) => {
619
+ if (teardown)
620
+ teardown();
621
+ resolve(err(createAbortError(reason)));
622
+ });
623
+ });
624
+ /**
625
+ * Pauses execution for a specified duration.
162
626
  *
163
- * const result1 = await fetchWithTimeout("https://api.example.com/data")();
164
- * result1 satisfies Result<Response, FetchError | TimeoutError>;
627
+ * ### Example
165
628
  *
166
- * // With AbortController
167
- * const controller = new AbortController();
168
- * const result2 = await fetchWithTimeout("https://api.example.com/data")(
169
- * controller,
170
- * );
171
- * result2 satisfies Result<
172
- * Response,
173
- * FetchError | TimeoutError | AbortError
174
- * >;
629
+ * ```ts
630
+ * const task: Task<void> = async (run) => {
631
+ * console.log("Starting...");
632
+ * await run(sleep("1s"));
633
+ * console.log("Done after 1 second");
634
+ * return ok();
635
+ * };
175
636
  * ```
637
+ *
638
+ * @group Composition
176
639
  */
177
- export const timeout = (duration, task) => toTask(async (context) => {
178
- const timeoutMs = durationToNonNegativeInt(duration);
179
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
180
- const signal = combineSignal(context, timeoutSignal);
181
- const result = await task({ signal });
182
- if (timeoutSignal.aborted) {
183
- return err({ type: "TimeoutError", timeoutMs });
184
- }
185
- return result;
640
+ export const sleep = (duration) => callback(({ ok, deps: { time } }) => {
641
+ const id = time.setTimeout(ok, durationToMillis(duration));
642
+ return () => time.clearTimeout(id);
186
643
  });
187
644
  /**
188
- * Adds retry logic with exponential backoff and jitter to a {@link Task}.
645
+ * Returns a {@link Task} that completes first.
646
+ *
647
+ * Like
648
+ * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race},
649
+ * the first Task to complete (whether success or failure) wins. All other Tasks
650
+ * are aborted. Use {@link any} if you need the first Task to succeed instead.
651
+ *
652
+ * Requires a non-empty array — racing zero Tasks has no meaningful result
653
+ * (there's no "first to complete" without participants). This is enforced at
654
+ * compile time for non-empty tuple types. For other arrays, guard with
655
+ * {@link isNonEmptyArray}:
656
+ *
657
+ * ```ts
658
+ * if (isNonEmptyArray(tasks)) {
659
+ * await run(race(tasks));
660
+ * }
661
+ * ```
662
+ *
663
+ * ### Example
664
+ *
665
+ * ```ts
666
+ * const fast: Task<string> = () => ok("fast");
667
+ * const slow: Task<string> = async (run) => {
668
+ * await run(sleep("10ms"));
669
+ * return ok("slow");
670
+ * };
671
+ *
672
+ * // First wins, others are aborted.
673
+ * const result = await run(race([fast, slow])); // ok("fast")
674
+ * ```
675
+ *
676
+ * Always runs with unlimited concurrency — a sequential race makes no sense
677
+ * since the first Task would always "win".
678
+ *
679
+ * @group Composition
680
+ */
681
+ export const race = (tasks, { abortReason = raceLostError, } = {}) => concurrently(pool(tasks, { stopOn: "first", collect: false, abortReason }));
682
+ /**
683
+ * Abort reason for tasks that lose a {@link race}.
684
+ *
685
+ * @group Composition
686
+ */
687
+ export const RaceLostError = /*#__PURE__*/ typed("RaceLostError");
688
+ /**
689
+ * {@link RaceLostError} used as abort reason in {@link race}.
690
+ *
691
+ * @group Composition
692
+ */
693
+ export const raceLostError = { type: "RaceLostError" };
694
+ /**
695
+ * Wraps a {@link Task} with a time limit.
696
+ *
697
+ * Returns {@link TimeoutError} if the Task doesn't complete within the specified
698
+ * duration. The original Task is aborted when the timeout fires.
189
699
  *
190
700
  * ### Example
191
701
  *
192
702
  * ```ts
193
- * interface FetchError {
194
- * readonly type: "FetchError";
195
- * readonly error: unknown;
703
+ * const fetchWithTimeout = timeout(fetchData, "5s");
704
+ *
705
+ * const result = await run(fetchWithTimeout);
706
+ * if (!result.ok && result.error.type === "TimeoutError") {
707
+ * console.log("Request timed out");
196
708
  * }
709
+ * ```
197
710
  *
198
- * // Task version of fetch with proper error handling and cancellation support.
199
- * const fetch = (url: string) =>
200
- * toTask((context) =>
201
- * tryAsync(
202
- * () => globalThis.fetch(url, { signal: context?.signal ?? null }),
203
- * (error): FetchError => ({ type: "FetchError", error }),
204
- * ),
205
- * );
711
+ * @group Composition
712
+ */
713
+ export const timeout = (task, duration, { abortReason = timeoutError, } = {}) => race([
714
+ task,
715
+ async (run) => {
716
+ await run(sleep(duration));
717
+ return err(timeoutError);
718
+ },
719
+ ], { abortReason });
720
+ /**
721
+ * Typed error returned by {@link timeout} when a task exceeds its time limit.
206
722
  *
207
- * // `satisfies` shows the expected type signature.
208
- * fetch satisfies (url: string) => Task<Response, FetchError>;
723
+ * @group Composition
724
+ */
725
+ export const TimeoutError = /*#__PURE__*/ typed("TimeoutError");
726
+ /**
727
+ * {@link TimeoutError} used as abort reason in {@link timeout}.
209
728
  *
210
- * const fetchWithRetry = (url: string) =>
211
- * retry({ retries: PositiveInt.orThrow(3) }, fetch(url));
729
+ * @group Composition
730
+ */
731
+ export const timeoutError = { type: "TimeoutError" };
732
+ /**
733
+ * Wraps a {@link Task} with retry logic.
734
+ *
735
+ * Retries the Task according to the {@link Schedule}'s rules. Use
736
+ * {@link RetryOptions.retryable} to filter which errors should trigger retries.
737
+ *
738
+ * All non-abort errors are wrapped in {@link RetryError}:
739
+ *
740
+ * - Task succeeds → `ok(value)`
741
+ * - Task returns {@link AbortError} → `err(AbortError)` — passed through, no
742
+ * retry, no wrapping
743
+ * - Task returns any other error → retry until schedule exhausted or `retryable`
744
+ * returns false → `err(RetryError)` with `cause` = the last error
745
+ *
746
+ * The `RetryError` is informative: "I tried N times, here's why I finally gave
747
+ * up" — and `cause` contains the actual underlying error.
212
748
  *
213
- * const result1 = await fetchWithRetry("https://api.example.com/data")();
214
- * result1 satisfies Result<Response, FetchError | RetryError<FetchError>>;
749
+ * ### Example
750
+ *
751
+ * ```ts
752
+ * import {
753
+ * exponential,
754
+ * jitter,
755
+ * maxDelay,
756
+ * retry,
757
+ * take,
758
+ * } from "@evolu/common";
759
+ *
760
+ * const fetchWithRetry = retry(
761
+ * fetchData,
762
+ * // A jittered, capped, limited exponential backoff.
763
+ * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))),
764
+ * );
765
+ *
766
+ * const result = await run(fetchWithRetry);
767
+ * if (!result.ok) {
768
+ * if (AbortError.is(result.error)) {
769
+ * // Was aborted externally
770
+ * } else {
771
+ * // RetryError — failed after retrying
772
+ * console.log(`Failed after ${result.error.attempts} attempts`);
773
+ * console.log(`Last error:`, result.error.cause);
774
+ * }
775
+ * }
776
+ * ```
777
+ *
778
+ * The schedule receives the error as input, enabling error-aware strategies
779
+ * like stopping on fatal errors:
215
780
  *
216
- * // With AbortController
217
- * const controller = new AbortController();
218
- * const result2 = await fetchWithRetry("https://api.example.com/data")(
219
- * controller,
781
+ * ```ts
782
+ * import { whileScheduleInput } from "@evolu/common";
783
+ *
784
+ * // Don't retry fatal errors
785
+ * const smartRetry = retry(
786
+ * fetchData,
787
+ * whileScheduleInput((e: FetchError) => e.type !== "FatalError")(
788
+ * take(5)(spaced("1s")),
789
+ * ),
220
790
  * );
221
- * result2 satisfies Result<
222
- * Response,
223
- * FetchError | RetryError<FetchError> | AbortError
224
- * >;
225
791
  * ```
792
+ *
793
+ * @group Composition
794
+ * @see {@link RetryOptions}
226
795
  */
227
- export const retry = ({ retries, initialDelay = "1s", maxDelay = "30s", factor = 2, jitter = 0.5, retryable = (error) => !isAbortError(error), onRetry, }, task) => toTask(async (context) => {
228
- const initialDelayMs = durationToNonNegativeInt(initialDelay);
229
- const maxDelayMs = durationToNonNegativeInt(maxDelay);
230
- const maxRetries = PositiveInt.orThrow(retries);
231
- let attempt = 0;
232
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
233
- while (true) {
234
- const result = await task(context);
235
- if (result.ok) {
236
- return result;
796
+ export const retry = (task, schedule, { retryable = lazyTrue, onRetry, } = {}) => async (run) => {
797
+ const step = schedule(run.deps);
798
+ let attempt = onePositiveInt;
799
+ let error;
800
+ for (;;) {
801
+ if (error !== undefined) {
802
+ const scheduleResult = step(error);
803
+ if (!scheduleResult.ok) {
804
+ return err({
805
+ type: "RetryError",
806
+ cause: error,
807
+ attempts: PositiveInt.orThrow(decrement(attempt)),
808
+ });
809
+ }
810
+ const [output, delay] = scheduleResult.value;
811
+ onRetry?.({
812
+ error,
813
+ attempt: PositiveInt.orThrow(decrement(attempt)),
814
+ output,
815
+ delay,
816
+ });
817
+ if (delay > 0) {
818
+ const sleepResult = await run(sleep(delay));
819
+ if (!sleepResult.ok)
820
+ return sleepResult;
821
+ }
237
822
  }
238
- // Never retry on AbortError; propagate it directly
239
- if (isAbortError(result.error)) {
823
+ const result = await run(task);
824
+ if (result.ok)
825
+ return result;
826
+ if (AbortError.is(result.error))
240
827
  return err(result.error);
241
- }
242
- attempt += 1;
243
- if (attempt > maxRetries || !retryable(result.error)) {
828
+ error = result.error;
829
+ if (!retryable(error)) {
244
830
  return err({
245
831
  type: "RetryError",
246
- cause: result.error,
832
+ cause: error,
247
833
  attempts: attempt,
248
834
  });
249
835
  }
250
- // Calculate delay with exponential backoff
251
- const exponentialDelay = initialDelayMs * Math.pow(factor, attempt - 1);
252
- const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
253
- // Apply jitter to prevent thundering herd problem
254
- const randomFactor = 1 - jitter + Math.random() * jitter * 2;
255
- const delay = Math.floor(cappedDelay * randomFactor);
256
- if (onRetry) {
257
- onRetry(result.error, attempt, delay);
258
- }
259
- // Wait before retry
260
- {
261
- const result = await wait(NonNegativeInt.orThrow(delay))(context);
262
- if (!result.ok) {
263
- // If delay was aborted, return AbortError (will be handled by toTask)
264
- return result;
265
- }
836
+ attempt = PositiveInt.orThrow(increment(attempt));
837
+ }
838
+ };
839
+ /**
840
+ * Repeats a {@link Task} according to a {@link Schedule}.
841
+ *
842
+ * Runs the Task, then checks the schedule to determine if it should repeat. The
843
+ * schedule controls how many repetitions occur and the delay between them.
844
+ * Continues until the schedule returns `Err(Done<void>)` or the Task fails.
845
+ *
846
+ * With `take(n)`, the task runs n+1 times (initial run plus n repetitions).
847
+ *
848
+ * Also works with {@link NextTask} — when the Task returns `Err(Done<D>)`,
849
+ * repeat stops and propagates the done signal.
850
+ *
851
+ * ### Example
852
+ *
853
+ * ```ts
854
+ * import { fixed, take } from "@evolu/common/schedule";
855
+ * import { repeat } from "@evolu/common";
856
+ *
857
+ * // Heartbeat every 30 seconds (runs forever until aborted)
858
+ * const heartbeat = repeat(sendHeartbeat, fixed("30s"));
859
+ *
860
+ * // Poll 4 times total (initial + 3 repetitions), 1 second apart
861
+ * const poll = repeat(checkStatus, take(3)(fixed("1s")));
862
+ *
863
+ * // Process queue items until empty (NextTask pattern)
864
+ * const processQueue: NextTask<Item, ProcessError, void> = async (run) => {
865
+ * const item = queue.dequeue();
866
+ * if (!item) return err(done()); // Queue empty, stop
867
+ * await process(item);
868
+ * return ok(item);
869
+ * };
870
+ *
871
+ * const result = await run(repeat(processQueue, fixed("100ms")));
872
+ * if (!result.ok && result.error.type === "Done") {
873
+ * console.log("Queue exhausted");
874
+ * }
875
+ * ```
876
+ *
877
+ * @group Composition
878
+ */
879
+ export const repeat = (task, schedule, { repeatable = lazyTrue, onRepeat, } = {}) => async (run) => {
880
+ const step = schedule(run.deps);
881
+ let lastResult;
882
+ let attempt = onePositiveInt;
883
+ for (;;) {
884
+ const result = await run(task);
885
+ if (!result.ok)
886
+ return result;
887
+ lastResult = result;
888
+ if (!repeatable(result.value))
889
+ return lastResult;
890
+ const next = step(result.value);
891
+ if (!next.ok)
892
+ break;
893
+ const [output, delay] = next.value;
894
+ onRepeat?.({
895
+ value: result.value,
896
+ attempt,
897
+ output,
898
+ delay,
899
+ });
900
+ attempt = PositiveInt.orThrow(increment(attempt));
901
+ if (delay > 0) {
902
+ const sleepResult = await run(sleep(delay));
903
+ if (!sleepResult.ok)
904
+ return sleepResult;
266
905
  }
267
906
  }
268
- });
907
+ return lastResult;
908
+ };
269
909
  /**
270
- * Creates a semaphore that limits concurrent async Tasks to the specified
271
- * count.
910
+ * Creates a {@link Deferred}.
272
911
  *
273
- * A semaphore controls access to a resource by maintaining a count of available
274
- * permits. Tasks acquire a permit before executing and release it when
275
- * complete.
912
+ * @group Concurrency primitives
913
+ */
914
+ export const createDeferred = () => {
915
+ let resolved = null;
916
+ const resolvers = new Set();
917
+ const resolve = (result) => {
918
+ if (resolved !== null)
919
+ return false;
920
+ resolved = result;
921
+ for (const resolver of resolvers)
922
+ resolver(result);
923
+ resolvers.clear();
924
+ return true;
925
+ };
926
+ return {
927
+ task: (run) => {
928
+ if (resolved !== null)
929
+ return resolved;
930
+ return new Promise((resolvePromise) => {
931
+ const resolve = (result) => {
932
+ resolvers.delete(resolve);
933
+ resolvePromise(result);
934
+ };
935
+ resolvers.add(resolve);
936
+ run.onAbort((reason) => {
937
+ resolve(err(createAbortError(reason)));
938
+ });
939
+ });
940
+ },
941
+ resolve,
942
+ [Symbol.dispose]: () => {
943
+ resolve(err(deferredDisposedError));
944
+ },
945
+ };
946
+ };
947
+ /**
948
+ * Abort reason used when a {@link Deferred} is disposed.
276
949
  *
277
- * For mutual exclusion (exactly one Task at a time), consider using
278
- * {@link createMutex} instead.
950
+ * @group Concurrency primitives
951
+ */
952
+ export const DeferredDisposedError = /*#__PURE__*/ typed("DeferredDisposedError");
953
+ /**
954
+ * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}.
955
+ *
956
+ * @group Concurrency primitives
957
+ */
958
+ export const deferredDisposedError = {
959
+ type: "DeferredDisposedError",
960
+ };
961
+ /**
962
+ * Creates a {@link Gate} that starts closed.
963
+ *
964
+ * Useful for "stop/go" logic where multiple Tasks need to wait for a state
965
+ * change.
279
966
  *
280
967
  * ### Example
281
968
  *
282
969
  * ```ts
283
- * // Allow maximum 3 concurrent Tasks
284
- * const semaphore = createSemaphore(PositiveInt.orThrow(3));
970
+ * const networkGate = createGate();
285
971
  *
286
- * let currentConcurrent = 0;
287
- * const events: Array<string> = [];
972
+ * // Pause processing when offline
973
+ * const onOffline = () => networkGate.close();
288
974
  *
289
- * const fetchData = (id: number) =>
290
- * toTask<number, never>(async (context) => {
291
- * currentConcurrent++;
292
- * events.push(`start ${id} (concurrent: ${currentConcurrent})`);
975
+ * // Resume processing when online
976
+ * const onOnline = () => networkGate.open();
293
977
  *
294
- * await wait("10ms")(context);
978
+ * const syncLoop = async (run) => {
979
+ * while (true) {
980
+ * // Blocks here whenever the gate is closed
981
+ * await run(networkGate.wait);
982
+ * await run(uploadNextItem);
983
+ * }
984
+ * };
985
+ * ```
295
986
  *
296
- * currentConcurrent--;
297
- * events.push(`end ${id} (concurrent: ${currentConcurrent})`);
298
- * return ok(id * 10);
299
- * });
987
+ * @group Concurrency primitives
988
+ */
989
+ export const createGate = () => {
990
+ let isOpen = false;
991
+ let disposed = false;
992
+ let deferred = createDeferred();
993
+ return {
994
+ wait: (run) => {
995
+ if (disposed)
996
+ return err(deferredDisposedError);
997
+ if (isOpen)
998
+ return ok();
999
+ return run(deferred.task);
1000
+ },
1001
+ open: () => {
1002
+ if (disposed || isOpen)
1003
+ return;
1004
+ isOpen = true;
1005
+ deferred.resolve(ok());
1006
+ },
1007
+ close: () => {
1008
+ if (disposed || !isOpen)
1009
+ return;
1010
+ isOpen = false;
1011
+ deferred = createDeferred();
1012
+ },
1013
+ isOpen: () => isOpen,
1014
+ [Symbol.dispose]: () => {
1015
+ if (disposed)
1016
+ return;
1017
+ disposed = true;
1018
+ deferred[Symbol.dispose]();
1019
+ },
1020
+ };
1021
+ };
1022
+ /**
1023
+ * Creates a {@link Semaphore} that limits concurrent {@link Task}s.
1024
+ *
1025
+ * ### Example
1026
+ *
1027
+ * ```ts
1028
+ * await using run = createRun();
1029
+ *
1030
+ * const semaphore = createSemaphore(PositiveInt.orThrow(2));
300
1031
  *
301
- * // These will execute with at most 3 running concurrently
302
- * const results = await Promise.all([
303
- * semaphore.withPermit(fetchData(1))(),
304
- * semaphore.withPermit(fetchData(2))(),
305
- * semaphore.withPermit(fetchData(3))(),
306
- * semaphore.withPermit(fetchData(4))(), // waits for one above to complete
307
- * semaphore.withPermit(fetchData(5))(), // waits for permit
1032
+ * const fetchUser =
1033
+ * (id: string): Task<string> =>
1034
+ * async (run) => {
1035
+ * const { console } = run.deps;
1036
+ * console.log("[demo]", "start", id);
1037
+ * const slept = await run(sleep("10ms"));
1038
+ * if (!slept.ok) return slept;
1039
+ * console.log("[demo]", "end", id);
1040
+ * return ok(`user:${id}`);
1041
+ * };
1042
+ *
1043
+ * const fetchWithPermit = (id: string) =>
1044
+ * semaphore.withPermit(fetchUser(id));
1045
+ *
1046
+ * await Promise.all([
1047
+ * run(fetchWithPermit("1")),
1048
+ * run(fetchWithPermit("2")),
1049
+ * run(fetchWithPermit("3")),
308
1050
  * ]);
309
1051
  *
310
- * expect(results.map(getOrThrow)).toEqual([10, 20, 30, 40, 50]);
311
- * expect(events).toMatchInlineSnapshot(`
312
- * [
313
- * "start 1 (concurrent: 1)",
314
- * "start 2 (concurrent: 2)",
315
- * "start 3 (concurrent: 3)",
316
- * "end 1 (concurrent: 2)",
317
- * "start 4 (concurrent: 3)",
318
- * "end 2 (concurrent: 2)",
319
- * "start 5 (concurrent: 3)",
320
- * "end 3 (concurrent: 2)",
321
- * "end 4 (concurrent: 1)",
322
- * "end 5 (concurrent: 0)",
323
- * ]
324
- * `);
1052
+ * // [demo] start 1
1053
+ * // [demo] start 2
1054
+ * // [demo] end 1
1055
+ * // [demo] start 3
1056
+ * // [demo] end 2
1057
+ * // [demo] end 3
325
1058
  * ```
1059
+ *
1060
+ * @group Concurrency primitives
326
1061
  */
327
- export const createSemaphore = (maxConcurrent) => {
328
- let isDisposed = false;
329
- let availablePermits = maxConcurrent;
330
- const waitingQueue = [];
331
- const semaphoreController = new AbortController();
332
- const acquire = () => {
333
- if (availablePermits > 0) {
334
- availablePermits--;
335
- return Promise.resolve();
1062
+ export const createSemaphore = (permits) => {
1063
+ const fibers = new Set();
1064
+ const waiters = [];
1065
+ let taken = zeroNonNegativeInt;
1066
+ let disposed = false;
1067
+ const withPermits = (requestedPermits) => (task) => async (run) => {
1068
+ const env_1 = { stack: [], error: void 0, hasError: false };
1069
+ try {
1070
+ const requested = PositiveInt.orThrow(requestedPermits);
1071
+ assert(requested <= permits, "Requested permits must not exceed semaphore capacity.");
1072
+ if (disposed)
1073
+ return err(semaphoreDisposedAbortError);
1074
+ if (waiters.length > 0 || taken + requested > permits) {
1075
+ const waiter = Promise.withResolvers();
1076
+ const waiting = {
1077
+ permits: requested,
1078
+ resolve: waiter.resolve,
1079
+ };
1080
+ waiters.push(waiting);
1081
+ run.onAbort((reason) => {
1082
+ const i = waiters.indexOf(waiting);
1083
+ if (i >= 0)
1084
+ waiters.splice(i, 1);
1085
+ waiter.resolve(err(createAbortError(reason)));
1086
+ });
1087
+ const permit = await waiter.promise;
1088
+ if (!permit.ok)
1089
+ return permit;
1090
+ }
1091
+ else {
1092
+ taken = NonNegativeInt.orThrow(taken + requested);
1093
+ }
1094
+ let fiber = null;
1095
+ const _ = __addDisposableResource(env_1, {
1096
+ [Symbol.dispose]: () => {
1097
+ if (fiber)
1098
+ fibers.delete(fiber);
1099
+ taken = NonNegativeInt.orThrow(taken - requested);
1100
+ while (waiters.length > 0) {
1101
+ const waiter = waiters[0];
1102
+ if (taken + waiter.permits > permits)
1103
+ break;
1104
+ waiters.shift();
1105
+ taken = NonNegativeInt.orThrow(taken + waiter.permits);
1106
+ waiter.resolve(ok());
1107
+ }
1108
+ },
1109
+ }, false);
1110
+ fiber = run(task);
1111
+ fibers.add(fiber);
1112
+ return await fiber;
336
1113
  }
337
- return new Promise((resolve) => {
338
- waitingQueue.push(resolve);
339
- });
340
- };
341
- const release = () => {
342
- if (isNonEmptyArray(waitingQueue)) {
343
- shiftArray(waitingQueue)();
1114
+ catch (e_1) {
1115
+ env_1.error = e_1;
1116
+ env_1.hasError = true;
344
1117
  }
345
- else {
346
- availablePermits++;
1118
+ finally {
1119
+ __disposeResources(env_1);
347
1120
  }
348
1121
  };
349
1122
  return {
350
- withPermit: (task) => toTask(async (context) => {
351
- await acquire();
352
- // Check if semaphore was disposed while waiting
353
- if (isDisposed) {
354
- return err({
355
- type: "AbortError",
356
- reason: "Semaphore disposed",
357
- });
358
- }
359
- const signal = combineSignal(context, semaphoreController.signal);
360
- const result = await task({ signal });
361
- release();
362
- return result;
1123
+ withPermits,
1124
+ withPermit: (task) => withPermits(1)(task),
1125
+ snapshot: () => ({
1126
+ permits,
1127
+ taken,
1128
+ waiting: NonNegativeInt.orThrow(waiters.length),
1129
+ available: NonNegativeInt.orThrow(permits - taken),
1130
+ isIdle: taken === 0 && waiters.length === 0,
1131
+ disposed,
363
1132
  }),
364
1133
  [Symbol.dispose]: () => {
365
- if (isDisposed)
366
- return;
367
- isDisposed = true;
368
- // Cancel all running and waiting tasks
369
- semaphoreController.abort("Semaphore disposed");
370
- // Release all waiting tasks so they can continue and check isDisposed
371
- while (isNonEmptyArray(waitingQueue)) {
372
- shiftArray(waitingQueue)();
1134
+ const env_2 = { stack: [], error: void 0, hasError: false };
1135
+ try {
1136
+ if (disposed)
1137
+ return;
1138
+ disposed = true;
1139
+ const stack = __addDisposableResource(env_2, new DisposableStack(), false);
1140
+ for (const fiber of fibers) {
1141
+ stack.adopt(fiber, (fiber) => {
1142
+ fiber.abort(semaphoreDisposedError);
1143
+ });
1144
+ }
1145
+ for (const waiter of waiters) {
1146
+ waiter.resolve(err(semaphoreDisposedAbortError));
1147
+ }
1148
+ waiters.length = 0;
1149
+ }
1150
+ catch (e_2) {
1151
+ env_2.error = e_2;
1152
+ env_2.hasError = true;
1153
+ }
1154
+ finally {
1155
+ __disposeResources(env_2);
373
1156
  }
374
1157
  },
375
1158
  };
376
1159
  };
377
1160
  /**
378
- * Creates a new mutex for ensuring mutual exclusion.
1161
+ * Abort reason used when a {@link Semaphore} is disposed.
379
1162
  *
380
- * A mutex is a {@link createSemaphore} with exactly one permit, ensuring that
381
- * only one Task can execute at a time.
1163
+ * @group Concurrency primitives
1164
+ */
1165
+ export const SemaphoreDisposedError = /*#__PURE__*/ typed("SemaphoreDisposedError");
1166
+ /**
1167
+ * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}.
382
1168
  *
383
- * ### Example
1169
+ * @group Concurrency primitives
1170
+ */
1171
+ export const semaphoreDisposedError = {
1172
+ type: "SemaphoreDisposedError",
1173
+ };
1174
+ const semaphoreDisposedAbortError = createAbortError(semaphoreDisposedError);
1175
+ export function createSemaphoreByKey(permits, { lookup = identity } = {}) {
1176
+ const semaphoresByKey = createLookupMap({
1177
+ lookup,
1178
+ });
1179
+ let disposed = false;
1180
+ const withPermits = (key, requestedPermits) => (task) => async (run) => {
1181
+ const env_3 = { stack: [], error: void 0, hasError: false };
1182
+ try {
1183
+ if (disposed)
1184
+ return err(semaphoreDisposedAbortError);
1185
+ let semaphore = semaphoresByKey.get(key);
1186
+ if (!semaphore) {
1187
+ semaphore = createSemaphore(permits);
1188
+ semaphoresByKey.set(key, semaphore);
1189
+ }
1190
+ const _ = __addDisposableResource(env_3, {
1191
+ [Symbol.dispose]: () => {
1192
+ const snapshot = semaphore.snapshot();
1193
+ if (snapshot.isIdle) {
1194
+ semaphoresByKey.delete(key);
1195
+ semaphore[Symbol.dispose]();
1196
+ }
1197
+ },
1198
+ }, false);
1199
+ return await run(semaphore.withPermits(requestedPermits)(task));
1200
+ }
1201
+ catch (e_3) {
1202
+ env_3.error = e_3;
1203
+ env_3.hasError = true;
1204
+ }
1205
+ finally {
1206
+ __disposeResources(env_3);
1207
+ }
1208
+ };
1209
+ return {
1210
+ withPermit: (key, task) => withPermits(key, 1)(task),
1211
+ withPermits,
1212
+ snapshot: (key) => semaphoresByKey.get(key)?.snapshot() ?? null,
1213
+ [Symbol.dispose]: () => {
1214
+ const env_4 = { stack: [], error: void 0, hasError: false };
1215
+ try {
1216
+ if (disposed)
1217
+ return;
1218
+ disposed = true;
1219
+ const stack = __addDisposableResource(env_4, new DisposableStack(), false);
1220
+ for (const semaphore of semaphoresByKey.values()) {
1221
+ stack.use(semaphore);
1222
+ }
1223
+ semaphoresByKey.clear();
1224
+ }
1225
+ catch (e_4) {
1226
+ env_4.error = e_4;
1227
+ env_4.hasError = true;
1228
+ }
1229
+ finally {
1230
+ __disposeResources(env_4);
1231
+ }
1232
+ },
1233
+ };
1234
+ }
1235
+ /**
1236
+ * Creates a {@link Mutex}.
384
1237
  *
385
- * ```ts
386
- * const mutex = createMutex();
387
- *
388
- * const updateTask = (id: number) =>
389
- * toTask((context) =>
390
- * tryAsync(
391
- * () => updateSharedResource(id, context),
392
- * (error): UpdateError => ({ type: "UpdateError", error }),
393
- * ),
394
- * );
395
- *
396
- * // These Tasks will execute one at a time
397
- * const results = await Promise.all([
398
- * mutex.withLock(updateTask(1))(),
399
- * mutex.withLock(updateTask(2))(),
400
- * mutex.withLock(updateTask(3))(),
401
- * ]);
402
- * ```
1238
+ * @group Concurrency primitives
403
1239
  */
404
1240
  export const createMutex = () => {
405
- const mutex = createSemaphore(PositiveInt.orThrow(1));
1241
+ const semaphore = createSemaphore(onePositiveInt);
1242
+ return {
1243
+ withLock: semaphore.withPermit,
1244
+ snapshot: semaphore.snapshot,
1245
+ [Symbol.dispose]: semaphore[Symbol.dispose],
1246
+ };
1247
+ };
1248
+ export function createMutexByKey({ lookup = identity, } = {}) {
1249
+ const semaphoreByKey = createSemaphoreByKey(onePositiveInt, {
1250
+ lookup,
1251
+ });
1252
+ return {
1253
+ withLock: (key, task) => semaphoreByKey.withPermit(key, task),
1254
+ snapshot: semaphoreByKey.snapshot,
1255
+ [Symbol.dispose]: semaphoreByKey[Symbol.dispose],
1256
+ };
1257
+ }
1258
+ /**
1259
+ * Creates a {@link MutexRef} with the given initial immutable value.
1260
+ *
1261
+ * @group Concurrency primitives
1262
+ */
1263
+ export const createMutexRef = (initialValue) => {
1264
+ const ref = createRef(initialValue);
1265
+ const mutex = createMutex();
406
1266
  return {
407
- withLock: mutex.withPermit,
1267
+ get: mutex.withLock(() => ok(ref.get())),
1268
+ set: (value) => mutex.withLock(() => {
1269
+ ref.set(value);
1270
+ return ok();
1271
+ }),
1272
+ getAndSet: (value) => mutex.withLock(() => ok(ref.getAndSet(value))),
1273
+ setAndGet: (value) => mutex.withLock(() => ok(ref.setAndGet(value))),
1274
+ update: (updater) => mutex.withLock(async (run) => {
1275
+ const nextValue = await run(updater(ref.get()));
1276
+ if (!nextValue.ok)
1277
+ return nextValue;
1278
+ ref.set(nextValue.value);
1279
+ return ok();
1280
+ }),
1281
+ getAndUpdate: (updater) => mutex.withLock(async (run) => {
1282
+ const previousValue = ref.get();
1283
+ const nextValue = await run(updater(previousValue));
1284
+ if (!nextValue.ok)
1285
+ return nextValue;
1286
+ ref.set(nextValue.value);
1287
+ return ok(previousValue);
1288
+ }),
1289
+ updateAndGet: (updater) => mutex.withLock(async (run) => {
1290
+ const currentValue = ref.get();
1291
+ const nextValue = await run(updater(currentValue));
1292
+ if (!nextValue.ok)
1293
+ return nextValue;
1294
+ ref.set(nextValue.value);
1295
+ return ok(nextValue.value);
1296
+ }),
1297
+ modify: (modifier) => mutex.withLock(async (run) => {
1298
+ const nextValue = await run(modifier(ref.get()));
1299
+ if (!nextValue.ok)
1300
+ return nextValue;
1301
+ const [result, updatedValue] = nextValue.value;
1302
+ ref.set(updatedValue);
1303
+ return ok(result);
1304
+ }),
408
1305
  [Symbol.dispose]: mutex[Symbol.dispose],
409
1306
  };
410
1307
  };
411
1308
  /**
412
- * Schedule a task to run after all interactions (animations, gestures,
413
- * navigation) have completed.
1309
+ * Creates an in-process {@link LeaderLock}.
414
1310
  *
415
- * This uses `requestIdleCallback` when available, otherwise falls back to
416
- * `setTimeout(0)` for cross-platform compatibility.
1311
+ * Uses one {@link Mutex} per {@link Name}. Suitable for runtimes without a
1312
+ * cross-process lock manager (for example in-memory worker tests or React
1313
+ * Native).
417
1314
  *
418
- * ### Example
1315
+ * @group Concurrency primitives
1316
+ */
1317
+ export const createInMemoryLeaderLock = () => {
1318
+ const mutexByName = createMutexByKey();
1319
+ return {
1320
+ lock: (name) => async (run) => {
1321
+ const leaseRun = run.create();
1322
+ const released = createDeferred();
1323
+ const acquired = createDeferred();
1324
+ void leaseRun(mutexByName.withLock(name, async (run) => {
1325
+ acquired.resolve(ok());
1326
+ await run(released.task);
1327
+ return ok();
1328
+ }));
1329
+ const acquiredResult = await run(acquired.task);
1330
+ if (!acquiredResult.ok) {
1331
+ assert(AbortError.is(acquiredResult.error), "Leader lock acquisition deferred must not be disposed.");
1332
+ void leaseRun[Symbol.asyncDispose]();
1333
+ return err(acquiredResult.error);
1334
+ }
1335
+ return ok({
1336
+ [Symbol.asyncDispose]: leaseRun[Symbol.asyncDispose],
1337
+ });
1338
+ },
1339
+ };
1340
+ };
1341
+ export function all(input, options) {
1342
+ return collect("all", input, options);
1343
+ }
1344
+ /**
1345
+ * Abort reason used by {@link all} when aborting remaining tasks.
419
1346
  *
420
- * ```ts
421
- * const processDataTask: Task<void, ProcessError> = toTask(async () => {
422
- * // Heavy processing work
423
- * return ok();
424
- * });
1347
+ * Used when a Task fails and other Tasks need to be aborted.
425
1348
  *
426
- * // Schedule the task to run when idle
427
- * void requestIdleTask(processDataTask)();
428
- * ```
1349
+ * @group Composition
1350
+ */
1351
+ export const AllAbortError = /*#__PURE__*/ typed("AllAbortError");
1352
+ /**
1353
+ * {@link AllAbortError} used as abort reason in {@link all}.
1354
+ *
1355
+ * @group Composition
1356
+ */
1357
+ export const allAbortError = { type: "AllAbortError" };
1358
+ export function allSettled(input, options) {
1359
+ return collect("allSettled", input, options);
1360
+ }
1361
+ /**
1362
+ * Abort reason used by {@link allSettled} when aborted externally.
1363
+ *
1364
+ * @group Composition
429
1365
  */
430
- export const requestIdleTask = (task) => toTask(async (context) => new Promise((resolve) => {
431
- idleCallback(() => {
432
- void task(context).then(resolve);
1366
+ export const AllSettledAbortError = /*#__PURE__*/ typed("AllSettledAbortError");
1367
+ /**
1368
+ * {@link AllSettledAbortError} used as abort reason in {@link allSettled}.
1369
+ *
1370
+ * @group Composition
1371
+ */
1372
+ export const allSettledAbortError = {
1373
+ type: "AllSettledAbortError",
1374
+ };
1375
+ export function map(items, fn, { abortReason = mapAbortError, ...options } = {}) {
1376
+ const mapped = mapInput(items, fn);
1377
+ return all(mapped, {
1378
+ ...options,
1379
+ abortReason,
433
1380
  });
434
- }));
435
- const idleCallback = typeof globalThis.requestIdleCallback === "function"
436
- ? globalThis.requestIdleCallback
437
- : (callback) => setTimeout(callback, 0);
1381
+ }
1382
+ /**
1383
+ * Abort reason used by {@link map} when aborting remaining tasks.
1384
+ *
1385
+ * @group Composition
1386
+ */
1387
+ export const MapAbortError = /*#__PURE__*/ typed("MapAbortError");
1388
+ /**
1389
+ * {@link MapAbortError} used as abort reason in {@link map}.
1390
+ *
1391
+ * @group Composition
1392
+ */
1393
+ export const mapAbortError = {
1394
+ type: "MapAbortError",
1395
+ };
1396
+ export function mapSettled(items, task, options) {
1397
+ const mapped = mapInput(items, task);
1398
+ return allSettled(mapped, options);
1399
+ }
1400
+ export function any(tasks, options) {
1401
+ const { allFailed = "input" } = options ?? {};
1402
+ return pool(tasks, {
1403
+ stopOn: "success",
1404
+ collect: false,
1405
+ abortReason: anyAbortError,
1406
+ allFailed,
1407
+ });
1408
+ }
1409
+ /**
1410
+ * Abort reason used by {@link any} when aborting remaining tasks.
1411
+ *
1412
+ * @group Composition
1413
+ */
1414
+ export const AnyAbortError = /*#__PURE__*/ typed("AnyAbortError");
1415
+ /**
1416
+ * {@link AnyAbortError} used as abort reason in {@link any}.
1417
+ *
1418
+ * @group Composition
1419
+ */
1420
+ export const anyAbortError = { type: "AnyAbortError" };
1421
+ /** Shared implementation for {@link all} and {@link allSettled}. */
1422
+ const collect = (type, input, { collect = true, abortReason = type === "all" ? allAbortError : allSettledAbortError, } = {}) => {
1423
+ const stopOn = type === "all" ? "error" : null;
1424
+ if (isIterable(input)) {
1425
+ const array = arrayFrom(input);
1426
+ if (!isNonEmptyArray(array))
1427
+ return () => ok(collect ? emptyArray : undefined);
1428
+ return pool(array, {
1429
+ stopOn,
1430
+ collect,
1431
+ abortReason,
1432
+ });
1433
+ }
1434
+ const keys = [];
1435
+ const taskArray = [];
1436
+ for (const key in input) {
1437
+ keys.push(key);
1438
+ taskArray.push(input[key]);
1439
+ }
1440
+ if (keys.length === 0)
1441
+ return () => ok(collect ? emptyRecord : undefined);
1442
+ return async (run) => {
1443
+ const result = await run(pool(taskArray, { stopOn, collect, abortReason }));
1444
+ if (!result.ok)
1445
+ return result;
1446
+ if (!collect)
1447
+ return ok();
1448
+ const record = createRecord();
1449
+ for (let i = 0; i < keys.length; i++) {
1450
+ record[keys[i]] = result.value[i];
1451
+ }
1452
+ return ok(record);
1453
+ };
1454
+ };
1455
+ const mapInput = (input, fn) => isIterable(input) ? mapArray(arrayFrom(input), fn) : mapObject(input, fn);
1456
+ function pool(tasksIterable, { stopOn = null, collect, abortReason, allFailed, }) {
1457
+ const tasks = arrayFrom(tasksIterable);
1458
+ const { length } = tasks;
1459
+ if (length === 0)
1460
+ return () => ok(emptyArray);
1461
+ return async (run) => {
1462
+ const env_5 = { stack: [], error: void 0, hasError: false };
1463
+ try {
1464
+ const results = collect ? new Array(length) : null;
1465
+ const aborted = Promise.withResolvers();
1466
+ const stopSignal = stopOn ? Promise.withResolvers() : null;
1467
+ let index = 0;
1468
+ let stopped = null;
1469
+ let lastResult = null;
1470
+ let lastIndexResult = null;
1471
+ const worker = async (run) => {
1472
+ while (index < length && (stopOn ? !stopped : true)) {
1473
+ const i = index++;
1474
+ const result = (await run(tasks[i]));
1475
+ lastResult = result;
1476
+ if (i === length - 1)
1477
+ lastIndexResult = result;
1478
+ if (!stopOn) {
1479
+ if (results)
1480
+ results[i] = result;
1481
+ continue;
1482
+ }
1483
+ const stop = stopOn === "first" ||
1484
+ (stopOn === "error" && !result.ok) ||
1485
+ (stopOn === "success" && result.ok);
1486
+ if (!stop) {
1487
+ if (results)
1488
+ results[i] = result.value;
1489
+ continue;
1490
+ }
1491
+ if (!stopped) {
1492
+ stopped = result;
1493
+ abortWorkers(!result.ok && AbortError.is(result.error)
1494
+ ? result.error.reason
1495
+ : abortReason);
1496
+ stopSignal?.resolve();
1497
+ }
1498
+ break;
1499
+ }
1500
+ return ok();
1501
+ };
1502
+ let workersAborted = false;
1503
+ const abortWorkers = (reason) => {
1504
+ if (workersAborted)
1505
+ return;
1506
+ workersAborted = true;
1507
+ for (const worker of workers)
1508
+ worker.abort(reason);
1509
+ };
1510
+ const workerCount = Math.min(run.concurrency, length);
1511
+ const workers = arrayFrom(workerCount, () => run.daemon(worker));
1512
+ const _ = __addDisposableResource(env_5, new DisposableStack(), false);
1513
+ _.defer(() => {
1514
+ abortWorkers(abortReason);
1515
+ });
1516
+ run.onAbort((reason) => {
1517
+ abortWorkers(reason);
1518
+ aborted.resolve();
1519
+ });
1520
+ const waitFor = [Promise.all(workers), aborted.promise];
1521
+ if (stopSignal)
1522
+ waitFor.push(stopSignal.promise);
1523
+ await Promise.race(waitFor);
1524
+ if (run.signal.aborted) {
1525
+ return err(run.signal.reason);
1526
+ }
1527
+ if (!stopOn)
1528
+ return results ? ok(results) : ok();
1529
+ if (stopped)
1530
+ return stopped;
1531
+ if (results)
1532
+ return ok(results);
1533
+ // For all/allSettled/map/mapSettled with collect: false (no allFailed handler)
1534
+ if (!allFailed)
1535
+ return ok();
1536
+ return allFailed === "completion" ? lastResult : lastIndexResult;
1537
+ }
1538
+ catch (e_5) {
1539
+ env_5.error = e_5;
1540
+ env_5.hasError = true;
1541
+ }
1542
+ finally {
1543
+ __disposeResources(env_5);
1544
+ }
1545
+ };
1546
+ }
1547
+ /**
1548
+ * Error returned when a {@link fetch} {@link Task} fails.
1549
+ *
1550
+ * @group Composition
1551
+ */
1552
+ export const FetchError = /*#__PURE__*/ typed("FetchError", { error: Unknown });
438
1553
  /**
439
- * Type guard to check if a {@link MaybeAsync} value is async (a promise).
1554
+ * Creates a {@link Task} that wraps the native
1555
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API}.
440
1556
  *
441
- * This function narrows the type of a {@link MaybeAsync} value, allowing you to
442
- * conditionally `await` only when necessary.
1557
+ * Handles cross-browser abort behavior WebKit throws a `DOMException` with
1558
+ * message "Fetch is aborted" instead of propagating `signal.reason`. This
1559
+ * helper normalizes the behavior to always return {@link AbortError}.
443
1560
  *
444
1561
  * ### Example
445
1562
  *
446
1563
  * ```ts
447
- * const getData = (id: string): MaybeAsync<Data> => {
448
- * const cached = cache.get(id);
449
- * if (cached) return cached; // Sync path
450
- * return fetchData(id); // Async path
451
- * };
1564
+ * await using run = createRun();
1565
+ *
1566
+ * const result = await run(fetch("https://api.example.com/users"));
452
1567
  *
453
- * const result = getData(id);
454
- * const data = isAsync(result) ? await result : result;
455
- * // No microtask overhead when cached!
1568
+ * if (!result.ok) {
1569
+ * // Handle FetchError or AbortError
1570
+ * }
1571
+ *
1572
+ * // Compose with timeout and retry
1573
+ * const fetchWithRetry = (url: string) =>
1574
+ * retry(timeout(fetch(url), "10s"), retryStrategyAws);
456
1575
  * ```
1576
+ *
1577
+ * @group Composition
457
1578
  */
458
- export const isAsync = (value) =>
459
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
460
- typeof value?.then === "function";
461
- // TODO: Add tracing support
462
- // - Extend TaskContext with optional tracing field
463
- // - Add traced(name, task) helper that wraps Task execution
464
- // - Collect span data (name, timing, parent-child relationships, status)
465
- // - Support OpenTelemetry export format with proper traceId/spanId generation
466
- // - Automatic parent-child span relationships through context propagation
1579
+ export const fetch = (input, init) => ({ signal }) => tryAsync(() => globalThis.fetch(input, { ...init, signal }), (error) => {
1580
+ if (AbortError.is(error))
1581
+ return error;
1582
+ // WebKit throws DOMException with message "Fetch is aborted" instead of
1583
+ // propagating signal.reason. Detect this and create AbortError.
1584
+ if (signal.aborted &&
1585
+ error instanceof Error &&
1586
+ error.message === "Fetch is aborted") {
1587
+ return createAbortError(signal.reason);
1588
+ }
1589
+ return { type: "FetchError", error };
1590
+ });
1591
+ // TODO: Prioritized Task Scheduling API integration
1592
+ // https://developer.mozilla.org/en-US/docs/Web/API/Prioritized_Task_Scheduling_API
1593
+ //
1594
+ // - `run(task, { priority })` - Fiber-level priority via TaskController
1595
+ // (extends AbortController), so the fiber's signal gets priority and all
1596
+ // nested work inherits it.
1597
+ // - `fiber.setPriority(priority)` - Dynamic priority changes mid-flight via
1598
+ // TaskController.setPriority().
1599
+ //
1600
+ // Note: scheduler.yield() inherits priority from enclosing postTask, so
1601
+ // yieldNow doesn't need a priority argument — just run the fiber at priority.
1602
+ //
1603
+ // Safari doesn't support it yet, Node.js probably never will (use setImmediate).
1604
+ // For Safari, scheduler-polyfill can be used.
1605
+ // https://www.npmjs.com/package/scheduler-polyfill
1606
+ // TODO: Do we really need specialized aborts?