better-race 0.1.0 → 0.2.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 CHANGED
@@ -5,10 +5,10 @@
5
5
  [![CI](https://github.com/lumberjacque/better-race/actions/workflows/ci.yml/badge.svg)](https://github.com/lumberjacque/better-race/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/better-race?logo=npm&label=npm&color=cb3837)](https://www.npmjs.com/package/better-race)
7
7
  [![npm downloads](https://img.shields.io/npm/dw/better-race?logo=npm&label=downloads)](https://www.npmjs.com/package/better-race)
8
- [![coverage: 100%](https://img.shields.io/badge/coverage-100%25-21c55d?logo=vitest&logoColor=white)](#quality-checks)
8
+ [![coverage: 100%](https://img.shields.io/badge/coverage-100%25-21c55d?logo=vitest&logoColor=white)](https://github.com/lumberjacque/better-race/actions/workflows/ci.yml)
9
9
  [![TypeScript ≥5.4](https://img.shields.io/badge/TypeScript-%E2%89%A55.4-3178c6?logo=typescript&logoColor=white)](#public-types)
10
10
  [![Node ≥22](https://img.shields.io/badge/Node-%E2%89%A522-339933?logo=nodedotjs&logoColor=white)](#installation)
11
- [![minzipped size](https://img.shields.io/bundlephobia/minzip/better-race?label=minzipped)](https://bundlephobia.com/package/better-race)
11
+ [![tree-shaken bundle size](https://deno.bundlejs.com/badge?q=better-race&treeshake=%5B%2A%5D)](https://deno.bundlejs.com/?q=better-race&treeshake=%5B%2A%5D)
12
12
  [![license: MIT](https://img.shields.io/github/license/lumberjacque/better-race)](LICENSE)
13
13
 
14
14
  `ESM-only` · `zero runtime dependencies` · `tree-shakeable` · `AbortSignal`-native
@@ -18,13 +18,16 @@
18
18
  ```ts
19
19
  import { race } from "better-race";
20
20
 
21
- const winner = await race({
22
- cache: () => readCache(),
23
- api: ({ signal }) => fetchUser({ signal }),
24
- });
21
+ const winner = await race(
22
+ {
23
+ eu: ({ signal }) => fetch("https://eu.example.com/user/42", { signal }),
24
+ us: ({ signal }) => fetch("https://us.example.com/user/42", { signal }),
25
+ },
26
+ { abortLosers: true },
27
+ );
25
28
 
26
- if (winner.key === "api") {
27
- winner.value; // exactly the return type of fetchUser
29
+ if (winner.key === "eu") {
30
+ winner.value; // the Response from the EU replica
28
31
  }
29
32
  ```
30
33
 
@@ -33,8 +36,8 @@ if (winner.key === "api") {
33
36
  Native `Promise.race()` returns a value union but drops its source:
34
37
 
35
38
  ```ts
36
- const value = await Promise.race([readCache(), fetchUser()]);
37
- // CachedUser | ApiUser
39
+ const value = await Promise.race([readEuReplica(), readUsReplica()]);
40
+ // EuUser | UsUser
38
41
  ```
39
42
 
40
43
  You can restore the source by manually wrapping every promise. `better-race` makes that wrapper the default, preserves the relation as a discriminated union, and can abort cooperative losers when that saves work.
@@ -54,30 +57,33 @@ Pass an object whose string keys name tasks. A task can return a value, a promis
54
57
  ```ts
55
58
  import { race } from "better-race";
56
59
 
57
- const winner = await race({
58
- memory: () => memoryCache.get("user-42"),
59
- replica: ({ signal }) => replicaClient.get("user-42", { signal }),
60
- primary: ({ signal }) => primaryClient.get("user-42", { signal }),
61
- });
60
+ const winner = await race(
61
+ {
62
+ eu: ({ signal }) => euReplica.get("user-42", { signal }),
63
+ us: ({ signal }) => usReplica.get("user-42", { signal }),
64
+ apac: ({ signal }) => apacReplica.get("user-42", { signal }),
65
+ },
66
+ { abortLosers: true },
67
+ );
62
68
  ```
63
69
 
64
70
  The result is inferred as:
65
71
 
66
72
  ```ts
67
- { key: "memory"; value: MemoryUser }
68
- | { key: "replica"; value: ReplicaUser }
69
- | { key: "primary"; value: PrimaryUser }
73
+ { key: "eu"; value: EuUser }
74
+ | { key: "us"; value: UsUser }
75
+ | { key: "apac"; value: ApacUser }
70
76
  ```
71
77
 
72
78
  Narrow the key and TypeScript narrows the value:
73
79
 
74
80
  ```ts
75
- if (winner.key === "memory") {
76
- winner.value; // MemoryUser
77
- } else if (winner.key === "replica") {
78
- winner.value; // ReplicaUser
81
+ if (winner.key === "eu") {
82
+ winner.value; // EuUser
83
+ } else if (winner.key === "us") {
84
+ winner.value; // UsUser
79
85
  } else {
80
- winner.value; // PrimaryUser
86
+ winner.value; // ApacUser
81
87
  }
82
88
  ```
83
89
 
@@ -100,8 +106,8 @@ By default, losers keep running, exactly like `Promise.race()`:
100
106
 
101
107
  ```ts
102
108
  const winner = await race({
103
- cache: readCache,
104
- network: ({ signal }) => fetch("/user", { signal }),
109
+ eu: ({ signal }) => fetch("https://eu.example.com/user/42", { signal }),
110
+ us: ({ signal }) => fetch("https://us.example.com/user/42", { signal }),
105
111
  });
106
112
  ```
107
113
 
@@ -160,13 +166,107 @@ import type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks } from "
160
166
 
161
167
  `RaceContext` deliberately contains only `signal`. There are no framework adapters, schedulers, retries, timeouts, hooks, or hidden global state.
162
168
 
169
+ ## `raceUntil(tasks, options)`
170
+
171
+ `raceUntil()` starts every task concurrently, but settles only when a fulfilled value passes `accept`. It is for “first usable result” cases where an early `null`, stale response, or unsuitable value should not end the race. It is not a replacement for a normal cache-first lookup: use it only when starting every candidate is an intentional latency or resilience trade-off.
172
+
173
+ ### Keyed, cancellable `Promise.any()`
174
+
175
+ Use an always-accepting predicate when the first fulfilled result should win. In
176
+ that mode, `raceUntil()` is a keyed, cancellable alternative to `Promise.any()`:
177
+ rejections are skipped while another task can still fulfil, and the winner keeps
178
+ its task key.
179
+
180
+ ```ts
181
+ const winner = await raceUntil(
182
+ {
183
+ cache: () => readCache(),
184
+ api: ({ signal }) => fetchUser({ signal }),
185
+ },
186
+ { accept: () => true, abortLosers: true },
187
+ );
188
+
189
+ if (winner.key === "api") {
190
+ winner.value; // User
191
+ }
192
+ ```
193
+
194
+ If every task rejects, `raceUntil()` rejects with `NoAcceptedResultError`, which
195
+ extends `AggregateError`. Unlike native `Promise.any()`, its `rejections`
196
+ property retains each task key alongside the original rejection reason.
197
+
198
+ ```ts
199
+ import { raceUntil } from "better-race";
200
+
201
+ type User = { id: string };
202
+
203
+ const winner = await raceUntil(
204
+ {
205
+ memory: () => memoryCache.get("user-42"), // User | null
206
+ redis: () => redisCache.get("user-42"), // User | null
207
+ database: ({ signal }) => fetchUser("user-42", { signal }), // User
208
+ },
209
+ {
210
+ accept: (value): value is User => value !== null,
211
+ abortLosers: true,
212
+ },
213
+ );
214
+
215
+ winner.key; // "memory" | "redis" | "database"
216
+ winner.value; // User
217
+ ```
218
+
219
+ ### Semantics
220
+
221
+ - All tasks start in object property order before any fulfilment, rejection, or acceptance is processed.
222
+ - A fulfilled value for which `accept(value)` is `false` is declined; pending tasks keep racing.
223
+ - A task rejection is recorded and ignored while another task could still provide an accepted value.
224
+ - The first accepted value wins. With `abortLosers: true`, every pending loser receives an abort signal; otherwise it keeps running.
225
+ - If every task settles without an accepted value, `raceUntil()` rejects with `NoAcceptedResultError`.
226
+ - If `accept` throws, `raceUntil()` rejects with that exact error. External abort behaves exactly like `race()` and aborts all pending tasks with the caller’s original `signal.reason`.
227
+
228
+ `NoAcceptedResultError` extends `AggregateError`. Its `errors` array retains original rejection reasons, and its `rejections` property keeps each reason coupled to the task key:
229
+
230
+ ```ts
231
+ try {
232
+ await raceUntil(tasks, { accept: isUsable });
233
+ } catch (error) {
234
+ if (error instanceof NoAcceptedResultError) {
235
+ error.rejections; // readonly { key: string; reason: unknown }[]
236
+ }
237
+ }
238
+ ```
239
+
240
+ ### Type narrowing
241
+
242
+ Use an explicit type predicate when the result must be narrowed on TypeScript 5.4 and newer:
243
+
244
+ ```ts
245
+ accept: (value): value is User => value !== null;
246
+ ```
247
+
248
+ A plain boolean callback is always valid, but preserves each task’s original value type. The keyed relationship remains intact in both forms.
249
+
250
+ ### Public types
251
+
252
+ ```ts
253
+ import {
254
+ NoAcceptedResultError,
255
+ raceUntil,
256
+ type RaceUntilOptions,
257
+ type RaceUntilRejection,
258
+ type RaceUntilResult,
259
+ } from "better-race";
260
+ ```
261
+
163
262
  ## Use cases
164
263
 
165
- - Read from a memory cache, distributed cache, and primary store simultaneously.
264
+ - Query independent read replicas when lower tail latency is worth redundant read work.
166
265
  - Query independent replicas and return the fastest response.
167
266
  - Race a preferred endpoint against a fallback endpoint, then abort the fallback.
168
267
  - Preserve source information for metrics, tracing, or structured logging.
169
268
  - Write compact TypeScript that narrows the result without hand-written wrapper objects.
269
+ - Query several stores in parallel until one returns a usable, non-null record.
170
270
 
171
271
  ## Examples
172
272
 
@@ -176,6 +276,7 @@ The executable examples are in [`examples/`](./examples):
176
276
  - [`abort-losers.ts`](./examples/abort-losers.ts)
177
277
  - [`external-abort.ts`](./examples/external-abort.ts)
178
278
  - [`rejection-semantics.ts`](./examples/rejection-semantics.ts)
279
+ - [`until-accepted-result.ts`](./examples/until-accepted-result.ts)
179
280
 
180
281
  CI compiles and executes these examples against the packed package, not the source tree.
181
282
 
@@ -184,20 +285,39 @@ CI compiles and executes these examples against the packed package, not the sour
184
285
  A race is not a dependency graph: every task starts immediately. The important moment is the first settlement.
185
286
 
186
287
  ```text
187
- Race Timeline — cache wins with abortLosers: true
288
+ Race Timeline — EU replica wins with abortLosers: true
188
289
 
189
290
  Task │ 0ms 72ms 400ms
190
291
  ───────────┼─────────────────────┼──────────────────────────────────────────
191
- cache │ ███████████████████ ● fulfilled winner
192
- api │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
193
- replica │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
292
+ eu │ ███████████████████ ● fulfilled winner
293
+ us │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
294
+ apac │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abort signal received
194
295
 
195
296
  first settlement wins
196
297
 
197
298
  Legend: █ active fulfilled work · ▓ active rejected work · ▒ active work aborted by the race
198
299
  ```
199
300
 
200
- Open [`playground/race-lab.html`](./playground/race-lab.html) directly in a browser to explore an animated version. It contains four concrete scenarios: fulfilment, first rejection, continuing losers, and external abort. The page is a visual prototype, not a shipped package artifact; Vitest and the packed-consumer test verify the runtime contract.
301
+ `raceUntil()` keeps the same concurrent start, but the line marks the first **accepted** value rather than the first settlement:
302
+
303
+ ```text
304
+ RaceUntil Timeline — wait for an accepted result
305
+
306
+ Task │ Outcome │ Decision │ Timeline
307
+ ───────────┼──────────────────┼─────────────────────────────┼────────────────────────────────────
308
+ memory │ null @ 24ms │ accept → false · decline │ ███ ▧ declined
309
+ redis │ Error @ 68ms │ record rejection · continue │ ████████ ▓ recorded
310
+ database │ User @ 176ms │ accept → true · winner │ ██████████████████████ ● accepted
311
+ backup │ pending @ 176ms │ abortLosers → abort │ ██████████████████████ ▒ cancelled
312
+
313
+ Legend: █ active task work · ▧ fulfilled candidate declined by accept · ▓ recorded rejection · ● accepted winner · ▒ cancelled loser
314
+ ```
315
+
316
+ Open [`playground/race-lab.html`](./playground/race-lab.html) directly in a browser to explore an animated version. It contains concrete `race()` and `raceUntil()` scenarios; the page is a visual prototype, not a shipped package artifact. Vitest and the packed-consumer test verify the runtime contract.
317
+
318
+ ## Quality checks
319
+
320
+ The [CI workflow](https://github.com/lumberjacque/better-race/actions/workflows/ci.yml) runs the V8 coverage report on Node 22, 24, and 26. The configured threshold is exactly 100% for statements, branches, functions, and lines, so a green CI run is a verifiable guarantee rather than a decorative badge. Open the latest `Node 22` job to inspect its full report.
201
321
 
202
322
  ## Development
203
323
 
package/dist/index.d.mts CHANGED
@@ -1,3 +1,21 @@
1
+ //#region src/errors.d.ts
2
+ /** A task rejection recorded while a `raceUntil()` call kept looking for an accepted value. */
3
+ interface RaceUntilRejection {
4
+ /** The task whose promise rejected. */
5
+ readonly key: string;
6
+ /** The original rejection reason from that task. */
7
+ readonly reason: unknown;
8
+ }
9
+ /**
10
+ * Thrown when every `raceUntil()` task has settled but no fulfilled value was
11
+ * accepted. Rejections are preserved in both `errors` and `rejections`.
12
+ */
13
+ export declare class NoAcceptedResultError extends AggregateError {
14
+ /** The rejected tasks, including their keys and original reasons. */
15
+ readonly rejections: readonly RaceUntilRejection[];
16
+ constructor(rejections: readonly RaceUntilRejection[]);
17
+ }
18
+ //#endregion
1
19
  //#region src/types.d.ts
2
20
  /** Context supplied to every task in a race. */
3
21
  interface RaceContext {
@@ -25,6 +43,11 @@ interface RaceOptions {
25
43
  */
26
44
  readonly abortLosers?: boolean;
27
45
  }
46
+ /** Options that control which fulfilled values may win a {@link raceUntil}. */
47
+ interface RaceUntilOptions<TValue = unknown> extends RaceOptions {
48
+ /** Returns `true` when this fulfilled value should win the race. */
49
+ readonly accept: (value: TValue) => boolean;
50
+ }
28
51
  type StringKeyOf<T> = Extract<keyof T, string>;
29
52
  /**
30
53
  * The discriminated union returned by {@link race}. Narrowing `key` narrows
@@ -36,6 +59,16 @@ type RaceResult<T extends RaceTasks> = { [K in StringKeyOf<T>]: {
36
59
  }; }[StringKeyOf<T>];
37
60
  /** @internal Rejects an empty object at compile time. */
38
61
  type NonEmptyTasks<T extends RaceTasks> = keyof T extends never ? never : T;
62
+ /** The union of all values that tasks in a race can fulfil with. */
63
+ type RaceValue<T extends RaceTasks> = Awaited<ReturnType<T[StringKeyOf<T>]>>;
64
+ /**
65
+ * The keyed result returned by {@link raceUntil} when `accept` is a type
66
+ * predicate. Each task value is narrowed independently while keeping its key.
67
+ */
68
+ type RaceUntilResult<T extends RaceTasks, Accepted> = { [K in StringKeyOf<T>]: {
69
+ readonly key: K;
70
+ readonly value: Extract<Awaited<ReturnType<T[K]>>, Accepted>;
71
+ }; }[StringKeyOf<T>];
39
72
  //#endregion
40
73
  //#region src/race.d.ts
41
74
  /**
@@ -66,5 +99,28 @@ type NonEmptyTasks<T extends RaceTasks> = keyof T extends never ? never : T;
66
99
  */
67
100
  export declare function race<const T extends RaceTasks>(tasks: NonEmptyTasks<T>, options?: RaceOptions): Promise<RaceResult<T>>;
68
101
  //#endregion
69
- export type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks };
102
+ //#region src/race-until.d.ts
103
+ type TypeGuardOptions<TValue, Accepted extends TValue> = RaceUntilOptions<TValue> & {
104
+ readonly accept: (value: TValue) => value is Accepted;
105
+ };
106
+ /**
107
+ * Races named tasks until a fulfilled value is accepted.
108
+ *
109
+ * Every task starts in object property order before any settlement is
110
+ * processed. Fulfilled values that `accept` rejects and task rejections are
111
+ * ignored while pending tasks remain. The first accepted value resolves with
112
+ * its keyed result. When every task settles without an accepted value, the
113
+ * promise rejects with {@link NoAcceptedResultError}.
114
+ *
115
+ * A type-predicate `accept` function narrows each keyed value in the result.
116
+ * Set `abortLosers` to abort cooperative pending tasks after acceptance; pass
117
+ * `signal` to cancel the whole pending race externally.
118
+ *
119
+ * @throws {TypeError} When `tasks` is empty or contains a non-function value.
120
+ * @throws {NoAcceptedResultError} When no fulfilled value is accepted.
121
+ */
122
+ export declare function raceUntil<const T extends RaceTasks, Accepted extends RaceValue<T>>(tasks: NonEmptyTasks<T>, options: TypeGuardOptions<RaceValue<T>, Accepted>): Promise<RaceUntilResult<T, Accepted>>;
123
+ export declare function raceUntil<const T extends RaceTasks>(tasks: NonEmptyTasks<T>, options: RaceUntilOptions<RaceValue<T>>): Promise<RaceResult<T>>;
124
+ //#endregion
125
+ export type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks, RaceUntilOptions, RaceUntilRejection, RaceUntilResult, RaceValue };
70
126
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/race.ts"],"mappings":";;UACiB;;;;;WAKN,QAAQ;;;KAIP,SAAS,MAAM,SAAS,gBAAgB,IAAI,YAAY;;KAGxD,YAAY,SAAS,eAAe;;UAG/B;;;;;;WAMN,SAAS;;;;;WAMT;;KAGN,YAAY,KAAK,cAAc;;;;;KAMxB,WAAW,UAAU,gBAC9B,KAAK,YAAY;WACP,KAAK;WACL,OAAO,QAAQ,WAAW,EAAE;KAEvC,YAAY;;KAGF,cAAc,UAAU,mBAAmB,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBCRjE,WAAW,UAAU,WACnC,OAAO,cAAc,IACrB,UAAS,cACR,QAAQ,WAAW"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/errors.ts","../src/types.ts","../src/race.ts","../src/race-until.ts"],"mappings":";;UACiB;;WAEN;;WAGA;;;;;;qBAOE,8BAA8B;;WAEhC,qBAAqB;EAElB,YAAA,qBAAqB;;;;;UChBlB;;;;;WAKN,QAAQ;;;KAIP,SAAS,MAAM,SAAS,gBAAgB,IAAI,YAAY;;KAGxD,YAAY,SAAS,eAAe;;UAG/B;;;;;;WAMN,SAAS;;;;;WAMT;;;UAGM,iBAAiB,0BAA0B;;WAEjD,SAAS,OAAO;;KAGtB,YAAY,KAAK,cAAc;;;;;KAMxB,WAAW,UAAU,gBAC9B,KAAK,YAAY;WACP,KAAK;WACL,OAAO,QAAQ,WAAW,EAAE;KAEvC,YAAY;;KAGF,cAAc,UAAU,mBAAmB,0BAA0B;;KAErE,UAAU,UAAU,aAAa,QAAQ,WAAW,EAAE,YAAY;;;;;KAMlE,gBAAgB,UAAU,WAAW,eAC9C,KAAK,YAAY;WACP,KAAK;WACL,OAAO,QAAQ,QAAQ,WAAW,EAAE,MAAM;KAErD,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBC1BE,WAAW,UAAU,WACnC,OAAO,cAAc,IACrB,UAAS,cACR,QAAQ,WAAW;;;KC1BjB,iBAAiB,QAAQ,iBAAiB,UAAU,iBAAiB;WAC/D,SAAS,OAAO,WAAW,SAAS;;;;;;;;;;;;;;;;;;wBAmB/B,gBAAgB,UAAU,WAAW,iBAAiB,UAAU,IAC9E,OAAO,cAAc,IACrB,SAAS,iBAAiB,UAAU,IAAI,YACvC,QAAQ,gBAAgB,GAAG;wBACd,gBAAgB,UAAU,WACxC,OAAO,cAAc,IACrB,SAAS,iBAAiB,UAAU,MACnC,QAAQ,WAAW"}
package/dist/index.mjs CHANGED
@@ -1,3 +1,18 @@
1
+ //#region src/errors.ts
2
+ /**
3
+ * Thrown when every `raceUntil()` task has settled but no fulfilled value was
4
+ * accepted. Rejections are preserved in both `errors` and `rejections`.
5
+ */
6
+ var NoAcceptedResultError = class extends AggregateError {
7
+ /** The rejected tasks, including their keys and original reasons. */
8
+ rejections;
9
+ constructor(rejections) {
10
+ super(rejections.map(({ reason }) => reason), "raceUntil() completed without an accepted result.");
11
+ this.name = "NoAcceptedResultError";
12
+ this.rejections = rejections;
13
+ }
14
+ };
15
+ //#endregion
1
16
  //#region src/race.ts
2
17
  /**
3
18
  * Races named tasks and returns both the first settled task's key and value.
@@ -70,6 +85,77 @@ function race(tasks, options = {}) {
70
85
  });
71
86
  }
72
87
  //#endregion
73
- export { race };
88
+ //#region src/race-until.ts
89
+ function raceUntil(tasks, options) {
90
+ const entries = Object.entries(tasks);
91
+ if (entries.length === 0) throw new TypeError("raceUntil() requires at least one task.");
92
+ for (const [key, task] of entries) if (typeof task !== "function") throw new TypeError(`raceUntil() task "${key}" must be a function.`);
93
+ if (options.signal?.aborted) return Promise.reject(options.signal.reason);
94
+ const controllers = new Map(entries.map(([key]) => [key, new AbortController()]));
95
+ return new Promise((resolve, reject) => {
96
+ let settled = false;
97
+ let remaining = entries.length;
98
+ const rejections = [];
99
+ const cleanup = () => {
100
+ options.signal?.removeEventListener("abort", onExternalAbort);
101
+ };
102
+ const abortTasks = (winnerKey) => {
103
+ for (const [key, controller] of controllers) if (key !== winnerKey) controller.abort();
104
+ };
105
+ const settleAccepted = (key, value) => {
106
+ settled = true;
107
+ cleanup();
108
+ if (options.abortLosers) abortTasks(key);
109
+ resolve({
110
+ key,
111
+ value
112
+ });
113
+ };
114
+ const settleFailure = (reason) => {
115
+ settled = true;
116
+ cleanup();
117
+ reject(reason);
118
+ };
119
+ const settleWithoutAcceptedValue = () => {
120
+ settled = true;
121
+ cleanup();
122
+ reject(new NoAcceptedResultError(rejections));
123
+ };
124
+ const onExternalAbort = () => {
125
+ settled = true;
126
+ cleanup();
127
+ abortTasks();
128
+ reject(options.signal?.reason);
129
+ };
130
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
131
+ for (const [key, task] of entries) {
132
+ const context = { signal: controllers.get(key).signal };
133
+ Promise.resolve().then(() => task(context)).then((value) => {
134
+ if (settled) return;
135
+ try {
136
+ if (options.accept(value)) {
137
+ settleAccepted(key, value);
138
+ return;
139
+ }
140
+ } catch (reason) {
141
+ settleFailure(reason);
142
+ return;
143
+ }
144
+ remaining -= 1;
145
+ if (remaining === 0) settleWithoutAcceptedValue();
146
+ }, (reason) => {
147
+ if (settled) return;
148
+ rejections.push({
149
+ key,
150
+ reason
151
+ });
152
+ remaining -= 1;
153
+ if (remaining === 0) settleWithoutAcceptedValue();
154
+ });
155
+ }
156
+ });
157
+ }
158
+ //#endregion
159
+ export { NoAcceptedResultError, race, raceUntil };
74
160
 
75
161
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/race.ts"],"sourcesContent":["import type {\n NonEmptyTasks,\n RaceContext,\n RaceOptions,\n RaceResult,\n RaceTask,\n RaceTasks,\n} from \"./types.js\";\n\ntype TaskEntry<T extends RaceTasks> = readonly [Extract<keyof T, string>, RaceTask<unknown>];\n\n/**\n * Races named tasks and returns both the first settled task's key and value.\n *\n * All tasks are started in object property order before any settlement is\n * processed. The first task to fulfil resolves with its keyed result; the\n * first task to reject rejects with its original reason, just like\n * `Promise.race()`.\n *\n * Set `abortLosers` to abort cooperative losers after the race settles. Pass\n * `signal` to cancel a still-pending race externally. A task must use the\n * supplied `AbortSignal` for cancellation to stop its underlying work.\n *\n * @example\n * ```ts\n * const winner = await race({\n * cache: () => readCache(),\n * api: ({ signal }) => fetchUser({ signal }),\n * });\n *\n * if (winner.key === \"api\") {\n * winner.value; // inferred from fetchUser\n * }\n * ```\n *\n * @throws {TypeError} When `tasks` is empty or contains a non-function value.\n */\nexport function race<const T extends RaceTasks>(\n tasks: NonEmptyTasks<T>,\n options: RaceOptions = {},\n): Promise<RaceResult<T>> {\n const entries = Object.entries(tasks) as unknown as TaskEntry<T>[];\n\n if (entries.length === 0) {\n throw new TypeError(\"race() requires at least one task.\");\n }\n\n for (const [key, task] of entries) {\n if (typeof task !== \"function\") {\n throw new TypeError(`race() task \"${key}\" must be a function.`);\n }\n }\n\n if (options.signal?.aborted) {\n return Promise.reject(options.signal.reason);\n }\n\n const controllers = new Map<string, AbortController>(\n entries.map(([key]) => [key, new AbortController()]),\n );\n\n return new Promise<RaceResult<T>>((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n options.signal?.removeEventListener(\"abort\", onExternalAbort);\n };\n\n const abortTasks = (winnerKey?: string): void => {\n for (const [key, controller] of controllers) {\n if (key !== winnerKey) {\n controller.abort();\n }\n }\n };\n\n const settleFulfilled = (key: string, value: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n resolve({ key, value } as RaceResult<T>);\n };\n\n const settleRejected = (key: string, reason: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n reject(reason);\n };\n\n const onExternalAbort = (): void => {\n settled = true;\n cleanup();\n abortTasks();\n reject(options.signal?.reason);\n };\n\n options.signal?.addEventListener(\"abort\", onExternalAbort, { once: true });\n\n for (const [key, task] of entries) {\n const controller = controllers.get(key)!;\n\n const context: RaceContext = { signal: controller.signal };\n Promise.resolve()\n .then(() => task(context))\n .then(\n (value) => settleFulfilled(key, value),\n (reason: unknown) => settleRejected(key, reason),\n );\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,KACd,OACA,UAAuB,CAAC,GACA;CACxB,MAAM,UAAU,OAAO,QAAQ,KAAK;CAEpC,IAAI,QAAQ,WAAW,GACrB,MAAM,IAAI,UAAU,oCAAoC;CAG1D,KAAK,MAAM,CAAC,KAAK,SAAS,SACxB,IAAI,OAAO,SAAS,YAClB,MAAM,IAAI,UAAU,gBAAgB,IAAI,sBAAsB;CAIlE,IAAI,QAAQ,QAAQ,SAClB,OAAO,QAAQ,OAAO,QAAQ,OAAO,MAAM;CAG7C,MAAM,cAAc,IAAI,IACtB,QAAQ,KAAK,CAAC,SAAS,CAAC,KAAK,IAAI,gBAAgB,CAAC,CAAC,CACrD;CAEA,OAAO,IAAI,SAAwB,SAAS,WAAW;EACrD,IAAI,UAAU;EAEd,MAAM,gBAAsB;GAC1B,QAAQ,QAAQ,oBAAoB,SAAS,eAAe;EAC9D;EAEA,MAAM,cAAc,cAA6B;GAC/C,KAAK,MAAM,CAAC,KAAK,eAAe,aAC9B,IAAI,QAAQ,WACV,WAAW,MAAM;EAGvB;EAEA,MAAM,mBAAmB,KAAa,UAAyB;GAC7D,IAAI,SAAS;GAEb,UAAU;GACV,QAAQ;GACR,IAAI,QAAQ,aAAa,WAAW,GAAG;GACvC,QAAQ;IAAE;IAAK;GAAM,CAAkB;EACzC;EAEA,MAAM,kBAAkB,KAAa,WAA0B;GAC7D,IAAI,SAAS;GAEb,UAAU;GACV,QAAQ;GACR,IAAI,QAAQ,aAAa,WAAW,GAAG;GACvC,OAAO,MAAM;EACf;EAEA,MAAM,wBAA8B;GAClC,UAAU;GACV,QAAQ;GACR,WAAW;GACX,OAAO,QAAQ,QAAQ,MAAM;EAC/B;EAEA,QAAQ,QAAQ,iBAAiB,SAAS,iBAAiB,EAAE,MAAM,KAAK,CAAC;EAEzE,KAAK,MAAM,CAAC,KAAK,SAAS,SAAS;GAGjC,MAAM,UAAuB,EAAE,QAFZ,YAAY,IAAI,GAEa,CAAC,CAAC,OAAO;GACzD,QAAQ,QAAQ,CAAC,CACd,WAAW,KAAK,OAAO,CAAC,CAAC,CACzB,MACE,UAAU,gBAAgB,KAAK,KAAK,IACpC,WAAoB,eAAe,KAAK,MAAM,CACjD;EACJ;CACF,CAAC;AACH"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/errors.ts","../src/race.ts","../src/race-until.ts"],"sourcesContent":["/** A task rejection recorded while a `raceUntil()` call kept looking for an accepted value. */\nexport interface RaceUntilRejection {\n /** The task whose promise rejected. */\n readonly key: string;\n\n /** The original rejection reason from that task. */\n readonly reason: unknown;\n}\n\n/**\n * Thrown when every `raceUntil()` task has settled but no fulfilled value was\n * accepted. Rejections are preserved in both `errors` and `rejections`.\n */\nexport class NoAcceptedResultError extends AggregateError {\n /** The rejected tasks, including their keys and original reasons. */\n readonly rejections: readonly RaceUntilRejection[];\n\n constructor(rejections: readonly RaceUntilRejection[]) {\n super(\n rejections.map(({ reason }) => reason),\n \"raceUntil() completed without an accepted result.\",\n );\n this.name = \"NoAcceptedResultError\";\n this.rejections = rejections;\n }\n}\n","import type {\n NonEmptyTasks,\n RaceContext,\n RaceOptions,\n RaceResult,\n RaceTask,\n RaceTasks,\n} from \"./types.js\";\n\ntype TaskEntry<T extends RaceTasks> = readonly [Extract<keyof T, string>, RaceTask<unknown>];\n\n/**\n * Races named tasks and returns both the first settled task's key and value.\n *\n * All tasks are started in object property order before any settlement is\n * processed. The first task to fulfil resolves with its keyed result; the\n * first task to reject rejects with its original reason, just like\n * `Promise.race()`.\n *\n * Set `abortLosers` to abort cooperative losers after the race settles. Pass\n * `signal` to cancel a still-pending race externally. A task must use the\n * supplied `AbortSignal` for cancellation to stop its underlying work.\n *\n * @example\n * ```ts\n * const winner = await race({\n * cache: () => readCache(),\n * api: ({ signal }) => fetchUser({ signal }),\n * });\n *\n * if (winner.key === \"api\") {\n * winner.value; // inferred from fetchUser\n * }\n * ```\n *\n * @throws {TypeError} When `tasks` is empty or contains a non-function value.\n */\nexport function race<const T extends RaceTasks>(\n tasks: NonEmptyTasks<T>,\n options: RaceOptions = {},\n): Promise<RaceResult<T>> {\n const entries = Object.entries(tasks) as unknown as TaskEntry<T>[];\n\n if (entries.length === 0) {\n throw new TypeError(\"race() requires at least one task.\");\n }\n\n for (const [key, task] of entries) {\n if (typeof task !== \"function\") {\n throw new TypeError(`race() task \"${key}\" must be a function.`);\n }\n }\n\n if (options.signal?.aborted) {\n return Promise.reject(options.signal.reason);\n }\n\n const controllers = new Map<string, AbortController>(\n entries.map(([key]) => [key, new AbortController()]),\n );\n\n return new Promise<RaceResult<T>>((resolve, reject) => {\n let settled = false;\n\n const cleanup = (): void => {\n options.signal?.removeEventListener(\"abort\", onExternalAbort);\n };\n\n const abortTasks = (winnerKey?: string): void => {\n for (const [key, controller] of controllers) {\n if (key !== winnerKey) {\n controller.abort();\n }\n }\n };\n\n const settleFulfilled = (key: string, value: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n resolve({ key, value } as RaceResult<T>);\n };\n\n const settleRejected = (key: string, reason: unknown): void => {\n if (settled) return;\n\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n reject(reason);\n };\n\n const onExternalAbort = (): void => {\n settled = true;\n cleanup();\n abortTasks();\n reject(options.signal?.reason);\n };\n\n options.signal?.addEventListener(\"abort\", onExternalAbort, { once: true });\n\n for (const [key, task] of entries) {\n const controller = controllers.get(key)!;\n\n const context: RaceContext = { signal: controller.signal };\n Promise.resolve()\n .then(() => task(context))\n .then(\n (value) => settleFulfilled(key, value),\n (reason: unknown) => settleRejected(key, reason),\n );\n }\n });\n}\n","import { NoAcceptedResultError, type RaceUntilRejection } from \"./errors.js\";\nimport type {\n NonEmptyTasks,\n RaceContext,\n RaceResult,\n RaceTask,\n RaceTasks,\n RaceUntilOptions,\n RaceUntilResult,\n RaceValue,\n} from \"./types.js\";\n\ntype TaskEntry<T extends RaceTasks> = readonly [Extract<keyof T, string>, RaceTask<unknown>];\n\ntype TypeGuardOptions<TValue, Accepted extends TValue> = RaceUntilOptions<TValue> & {\n readonly accept: (value: TValue) => value is Accepted;\n};\n\n/**\n * Races named tasks until a fulfilled value is accepted.\n *\n * Every task starts in object property order before any settlement is\n * processed. Fulfilled values that `accept` rejects and task rejections are\n * ignored while pending tasks remain. The first accepted value resolves with\n * its keyed result. When every task settles without an accepted value, the\n * promise rejects with {@link NoAcceptedResultError}.\n *\n * A type-predicate `accept` function narrows each keyed value in the result.\n * Set `abortLosers` to abort cooperative pending tasks after acceptance; pass\n * `signal` to cancel the whole pending race externally.\n *\n * @throws {TypeError} When `tasks` is empty or contains a non-function value.\n * @throws {NoAcceptedResultError} When no fulfilled value is accepted.\n */\nexport function raceUntil<const T extends RaceTasks, Accepted extends RaceValue<T>>(\n tasks: NonEmptyTasks<T>,\n options: TypeGuardOptions<RaceValue<T>, Accepted>,\n): Promise<RaceUntilResult<T, Accepted>>;\nexport function raceUntil<const T extends RaceTasks>(\n tasks: NonEmptyTasks<T>,\n options: RaceUntilOptions<RaceValue<T>>,\n): Promise<RaceResult<T>>;\nexport function raceUntil<const T extends RaceTasks>(\n tasks: NonEmptyTasks<T>,\n options: RaceUntilOptions<RaceValue<T>>,\n): Promise<RaceResult<T>> {\n const entries = Object.entries(tasks) as unknown as TaskEntry<T>[];\n\n if (entries.length === 0) {\n throw new TypeError(\"raceUntil() requires at least one task.\");\n }\n\n for (const [key, task] of entries) {\n if (typeof task !== \"function\") {\n throw new TypeError(`raceUntil() task \"${key}\" must be a function.`);\n }\n }\n\n if (options.signal?.aborted) {\n return Promise.reject(options.signal.reason);\n }\n\n const controllers = new Map<string, AbortController>(\n entries.map(([key]) => [key, new AbortController()]),\n );\n\n return new Promise<RaceResult<T>>((resolve, reject) => {\n let settled = false;\n let remaining = entries.length;\n const rejections: RaceUntilRejection[] = [];\n\n const cleanup = (): void => {\n options.signal?.removeEventListener(\"abort\", onExternalAbort);\n };\n\n const abortTasks = (winnerKey?: string): void => {\n for (const [key, controller] of controllers) {\n if (key !== winnerKey) controller.abort();\n }\n };\n\n const settleAccepted = (key: string, value: unknown): void => {\n settled = true;\n cleanup();\n if (options.abortLosers) abortTasks(key);\n resolve({ key, value } as RaceResult<T>);\n };\n\n const settleFailure = (reason: unknown): void => {\n settled = true;\n cleanup();\n reject(reason);\n };\n\n const settleWithoutAcceptedValue = (): void => {\n settled = true;\n cleanup();\n reject(new NoAcceptedResultError(rejections));\n };\n\n const onExternalAbort = (): void => {\n settled = true;\n cleanup();\n abortTasks();\n reject(options.signal?.reason);\n };\n\n options.signal?.addEventListener(\"abort\", onExternalAbort, { once: true });\n\n for (const [key, task] of entries) {\n const controller = controllers.get(key)!;\n const context: RaceContext = { signal: controller.signal };\n\n Promise.resolve()\n .then(() => task(context))\n .then(\n (value) => {\n if (settled) return;\n\n try {\n if (options.accept(value as RaceValue<T>)) {\n settleAccepted(key, value);\n return;\n }\n } catch (reason) {\n settleFailure(reason);\n return;\n }\n\n remaining -= 1;\n if (remaining === 0) settleWithoutAcceptedValue();\n },\n (reason: unknown) => {\n if (settled) return;\n\n rejections.push({ key, reason });\n remaining -= 1;\n if (remaining === 0) settleWithoutAcceptedValue();\n },\n );\n }\n });\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-race",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A better Promise.race() for TypeScript: keyed results and optional cancellation.",
5
5
  "keywords": [
6
6
  "abortsignal",