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