better-race 0.1.0 → 0.2.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 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
@@ -160,6 +160,74 @@ import type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks } from "
160
160
 
161
161
  `RaceContext` deliberately contains only `signal`. There are no framework adapters, schedulers, retries, timeouts, hooks, or hidden global state.
162
162
 
163
+ ## `raceUntil(tasks, options)`
164
+
165
+ `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.
166
+
167
+ ```ts
168
+ import { raceUntil } from "better-race";
169
+
170
+ type User = { id: string };
171
+
172
+ const winner = await raceUntil(
173
+ {
174
+ memory: () => memoryCache.get("user-42"), // User | null
175
+ redis: () => redisCache.get("user-42"), // User | null
176
+ database: ({ signal }) => fetchUser("user-42", { signal }), // User
177
+ },
178
+ {
179
+ accept: (value): value is User => value !== null,
180
+ abortLosers: true,
181
+ },
182
+ );
183
+
184
+ winner.key; // "memory" | "redis" | "database"
185
+ winner.value; // User
186
+ ```
187
+
188
+ ### Semantics
189
+
190
+ - All tasks start in object property order before any fulfilment, rejection, or acceptance is processed.
191
+ - A fulfilled value for which `accept(value)` is `false` is declined; pending tasks keep racing.
192
+ - A task rejection is recorded and ignored while another task could still provide an accepted value.
193
+ - The first accepted value wins. With `abortLosers: true`, every pending loser receives an abort signal; otherwise it keeps running.
194
+ - If every task settles without an accepted value, `raceUntil()` rejects with `NoAcceptedResultError`.
195
+ - 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`.
196
+
197
+ `NoAcceptedResultError` extends `AggregateError`. Its `errors` array retains original rejection reasons, and its `rejections` property keeps each reason coupled to the task key:
198
+
199
+ ```ts
200
+ try {
201
+ await raceUntil(tasks, { accept: isUsable });
202
+ } catch (error) {
203
+ if (error instanceof NoAcceptedResultError) {
204
+ error.rejections; // readonly { key: string; reason: unknown }[]
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### Type narrowing
210
+
211
+ Use an explicit type predicate when the result must be narrowed on TypeScript 5.4 and newer:
212
+
213
+ ```ts
214
+ accept: (value): value is User => value !== null;
215
+ ```
216
+
217
+ A plain boolean callback is always valid, but preserves each task’s original value type. The keyed relationship remains intact in both forms.
218
+
219
+ ### Public types
220
+
221
+ ```ts
222
+ import {
223
+ NoAcceptedResultError,
224
+ raceUntil,
225
+ type RaceUntilOptions,
226
+ type RaceUntilRejection,
227
+ type RaceUntilResult,
228
+ } from "better-race";
229
+ ```
230
+
163
231
  ## Use cases
164
232
 
165
233
  - Read from a memory cache, distributed cache, and primary store simultaneously.
@@ -167,6 +235,7 @@ import type { RaceContext, RaceOptions, RaceResult, RaceTask, RaceTasks } from "
167
235
  - Race a preferred endpoint against a fallback endpoint, then abort the fallback.
168
236
  - Preserve source information for metrics, tracing, or structured logging.
169
237
  - Write compact TypeScript that narrows the result without hand-written wrapper objects.
238
+ - Query several stores in parallel until one returns a usable, non-null record.
170
239
 
171
240
  ## Examples
172
241
 
@@ -176,6 +245,7 @@ The executable examples are in [`examples/`](./examples):
176
245
  - [`abort-losers.ts`](./examples/abort-losers.ts)
177
246
  - [`external-abort.ts`](./examples/external-abort.ts)
178
247
  - [`rejection-semantics.ts`](./examples/rejection-semantics.ts)
248
+ - [`until-accepted-result.ts`](./examples/until-accepted-result.ts)
179
249
 
180
250
  CI compiles and executes these examples against the packed package, not the source tree.
181
251
 
@@ -197,7 +267,26 @@ replica │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ × abor
197
267
  Legend: █ active fulfilled work · ▓ active rejected work · ▒ active work aborted by the race
198
268
  ```
199
269
 
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.
270
+ `raceUntil()` keeps the same concurrent start, but the line marks the first **accepted** value rather than the first settlement:
271
+
272
+ ```text
273
+ RaceUntil Timeline — wait for an accepted result
274
+
275
+ Task │ Outcome │ Decision │ Timeline
276
+ ───────────┼──────────────────┼─────────────────────────────┼────────────────────────────────────
277
+ memory │ null @ 24ms │ accept → false · decline │ ███ ▧ declined
278
+ redis │ Error @ 68ms │ record rejection · continue │ ████████ ▓ recorded
279
+ database │ User @ 176ms │ accept → true · winner │ ██████████████████████ ● accepted
280
+ backup │ pending @ 176ms │ abortLosers → abort │ ██████████████████████ ▒ cancelled
281
+
282
+ Legend: █ active task work · ▧ fulfilled candidate declined by accept · ▓ recorded rejection · ● accepted winner · ▒ cancelled loser
283
+ ```
284
+
285
+ 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.
286
+
287
+ ## Quality checks
288
+
289
+ 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
290
 
202
291
  ## Development
203
292
 
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-next.0",
4
4
  "description": "A better Promise.race() for TypeScript: keyed results and optional cancellation.",
5
5
  "keywords": [
6
6
  "abortsignal",