diff --git a/AGENTS.md b/AGENTS.md index 1c8a0ddea..4a80ea2bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,9 +156,19 @@ const task = yield * op; // returns a TASK (Future) and starts it - `all()` accepts an array of operations and evaluates them concurrently. - It returns an array of results in input order. - If any member errors, `all()` errors and halts the other members. -- If you need "all operations either complete or error" (no fail-fast), wrap - each member to return a railway-style result (e.g. `{ ok: true, value }` / - `{ ok: false, error }`) instead of letting errors escape. +- If you need all results regardless of success or failure, use `allSettled()` + instead of wrapping each member in railway-style results. + +## `allSettled()` + +**Rules** + +- `allSettled()` accepts an array of operations and evaluates them concurrently. +- It returns an array of `Result` objects in input order + (`{ ok: true, value }` or `{ ok: false, error }`). +- It never short-circuits on error — all operations run to completion. +- It is analogous to `Promise.allSettled()`, but uses Effection's `Result` + shape. ## `call()` diff --git a/docs/async-rosetta-stone.mdx b/docs/async-rosetta-stone.mdx index 3784f2325..d29a1c43c 100644 --- a/docs/async-rosetta-stone.mdx +++ b/docs/async-rosetta-stone.mdx @@ -15,6 +15,7 @@ counterparts is reflected in the “Async Rosetta Stone.” | `Promise` | `Operation` | | `new Promise()` | `action()` | | `Promise.withResolvers()` | `withResolvers()` | +| `Promise.allSettled()` | `allSettled()` | | `for await` | `for yield* each` | | `AsyncIterable` | `Stream` | | `AsyncIterator` | `Subscription` | @@ -201,6 +202,38 @@ function* main() { }; ``` +## `Promise.allSettled()` \<=> `allSettled()` + +Wait for all operations to settle regardless of whether they succeed or fail. +Unlike [`all()`][all], [`allSettled()`][allSettled] never short-circuits — +every result is represented as either `{ ok: true, value }` or +`{ ok: false, error }`. + +Wait for all promises to settle with `Promise.allSettled()`: + +```js +let [user, comments] = await Promise.allSettled([ + fetchUser(id), + fetchComments(id), +]); +``` + +Wait for all operations to settle with `allSettled()`: + +```js +import { allSettled } from 'effection'; + +let [user, comments] = yield* allSettled([ + fetchUser(id), + fetchComments(id), +]); +``` + +The shape of the results is slightly different from `Promise.allSettled()`. +Effection uses its [`Result`][result] type so that settled results compose +the same way they do elsewhere in the library. You can construct these values +with [`Ok()`][ok] and [`Err()`][err]. + ## `for await` \<=> `for yield* each` Loop over an AsyncIterable with `for await`: @@ -284,6 +317,11 @@ let subscription = subscribe(asyncIterator); ``` [call]: /api/v4/call +[all]: /api/v4/all +[allSettled]: /api/v4/allSettled +[result]: /api/v4/Result +[ok]: /api/v4/Ok +[err]: /api/v4/Err [until]: /api/v4/until [run]: /api/v4/run [scope-run]: /api/v4/Scope#interface_Scope-methods diff --git a/docs/spawn.mdx b/docs/spawn.mdx index fabf72106..19c319d37 100644 --- a/docs/spawn.mdx +++ b/docs/spawn.mdx @@ -163,6 +163,19 @@ main(function *() { }); ``` +If you need to wait for all operations to settle regardless of whether they +succeed or fail, use [`allSettled()`][allSettled] instead. Unlike `all()`, +`allSettled()` never short-circuits on error — each result is represented as +[`Result`][result]: `{ ok: true, value }` or `{ ok: false, error }`. + +``` javascript +import { allSettled, main } from 'effection'; + +main(function *() { + let [dayUS, daySweden] = yield* allSettled([fetchWeekDay('est'), fetchWeekDay('cet')]); +}); +``` + ## Spawning in a Scope The `spawn()` operation always runs its operation as a child of the current @@ -207,4 +220,6 @@ You can learn more about this in the [scope guide](./scope). [sc-for-js]: /blog/2026-02-06-structured-concurrency-for-javascript [express]: https://expressjs.org [scope.run]: /api/v4/Scope +[allSettled]: /api/v4/allSettled +[result]: /api/v4/Result [Operation]: /api/v4/Operation diff --git a/lib/all-settled.ts b/lib/all-settled.ts new file mode 100644 index 000000000..d1cd74b51 --- /dev/null +++ b/lib/all-settled.ts @@ -0,0 +1,53 @@ +import { all } from "./all.ts"; +import { box } from "./box.ts"; +import type { Result } from "./result.ts"; +import type { Operation, Yielded } from "./types.ts"; + +/** + * Block and wait for all of the given operations to settle. Returns + * an array of results indicating whether each operation succeeded or + * errored. This has the same purpose as + * [Promise.allSettled](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled). + * + * Unlike {@link all}, `allSettled` never rejects — every operation + * result is represented as either `{ ok: true, value }` or + * `{ ok: false, error }`. + * + * ### Example + * + * ``` javascript + * import { allSettled, expect, main } from 'effection'; + * + * await main(function*() { + * let [google, notASite] = yield* allSettled([ + * expect(fetch('http://google.com')), + * expect(fetch('http://nope.example')), + * ]); + * // google => { ok: true, value: Response } + * // notASite => { ok: false, error: Error } + * }); + * ``` + * + * @param ops a list of operations to wait for + * @returns the list of settled results, in the order they were given + * @since 4.1 + */ +export function* allSettled[] | []>( + ops: T, +): Operation> { + let results = yield* all( + ops.map((operation) => box(() => operation)) as { + [P in keyof T]: Operation>>; + }, + ); + return results as AllSettled; +} + +/** + * This type allows you to infer heterogenous operation types. + * e.g. `allSettled([sleep(0), expect(fetch("https://google.com"))])` + * will have a type of `Operation<[Result, Result]>` + */ +type AllSettled[] | []> = { + -readonly [P in keyof T]: Result>; +}; diff --git a/lib/mod.ts b/lib/mod.ts index e15e4d3c5..f230e5ab9 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -12,6 +12,7 @@ export * from "./resource.ts"; export * from "./call.ts"; export * from "./race.ts"; export * from "./all.ts"; +export * from "./all-settled.ts"; export * from "./lift.ts"; export * from "./queue.ts"; export * from "./signal.ts"; diff --git a/lib/result.ts b/lib/result.ts index 8649a2468..77431f5ee 100644 --- a/lib/result.ts +++ b/lib/result.ts @@ -1,5 +1,13 @@ /** - * @ignore + * A value representing either a successful outcome or an error. + * + * `Result` is used in APIs when you want to preserve both successes and + * failures instead of short-circuiting on the first error. + * + * A successful result has the shape `{ ok: true, value }` and a failed result + * has the shape `{ ok: false, error }`. + * + * @since 4.1 */ export type Result = { readonly ok: true; @@ -10,7 +18,18 @@ export type Result = { }; /** - * @ignore + * Construct a successful {@link Result}. + * + * ### Example + * + * ```javascript + * import { Ok } from 'effection'; + * + * let result = Ok("hello"); + * // { ok: true, value: "hello" } + * ``` + * + * @since 4.1 */ export function Ok(): Result; export function Ok(value: T): Result; @@ -22,7 +41,18 @@ export function Ok(value?: T): Result { } /** - * @ignore + * Construct a failed {@link Result}. + * + * ### Example + * + * ```javascript + * import { Err } from 'effection'; + * + * let result = Err(new Error("oh no")); + * // { ok: false, error: Error("oh no") } + * ``` + * + * @since 4.1 */ export const Err = (error: Error): Result => ({ ok: false, error }); diff --git a/test/all-settled.test.ts b/test/all-settled.test.ts new file mode 100644 index 000000000..82807a882 --- /dev/null +++ b/test/all-settled.test.ts @@ -0,0 +1,174 @@ +import { + asyncReject, + asyncResolve, + describe, + expect, + expectType, + it, + syncReject, + syncResolve, +} from "./suite.ts"; + +import { + allSettled, + call, + type Operation, + type Result, + run, + sleep, +} from "../mod.ts"; + +describe("allSettled()", () => { + it("resolves when the given list is empty", async () => { + let result = await run(() => allSettled([])); + + expect(result).toEqual([]); + }); + + it("resolves when all of the given operations resolve", async () => { + let result = await run(() => + allSettled([ + syncResolve("quox"), + asyncResolve(10, "foo"), + asyncResolve(5, "bar"), + asyncResolve(15, "baz"), + ]) + ); + + expect(result).toEqual([ + { ok: true, value: "quox" }, + { ok: true, value: "foo" }, + { ok: true, value: "bar" }, + { ok: true, value: "baz" }, + ]); + }); + + it("resolves when all of the given operations resolve synchronously", async () => { + let result = await run(() => + allSettled([ + syncResolve("foo"), + syncResolve("bar"), + syncResolve("baz"), + ]) + ); + + expect(result).toEqual([ + { ok: true, value: "foo" }, + { ok: true, value: "bar" }, + { ok: true, value: "baz" }, + ]); + }); + + it("returns both successes and failures when operations are mixed", async () => { + let result = await run(() => + allSettled([ + syncResolve("foo"), + syncReject("bar"), + asyncResolve(10, "baz"), + asyncReject(5, "qux"), + ]) + ); + + expect(result[0]).toMatchObject({ ok: true, value: "foo" }); + expect(result[1]).toMatchObject({ + ok: false, + error: { message: "boom: bar" }, + }); + expect(result[2]).toMatchObject({ ok: true, value: "baz" }); + expect(result[3]).toMatchObject({ + ok: false, + error: { message: "boom: qux" }, + }); + }); + + it("resolves with all errors when all of the given operations reject", async () => { + let result = await run(() => + allSettled([ + syncReject("foo"), + asyncReject(5, "bar"), + ]) + ); + + expect(result[0]).toMatchObject({ + ok: false, + error: { message: "boom: foo" }, + }); + expect(result[1]).toMatchObject({ + ok: false, + error: { message: "boom: bar" }, + }); + }); + + it("does not reject when one operation rejects asynchronously first", async () => { + let result = await run(() => + allSettled([ + asyncResolve(10, "foo"), + asyncReject(5, "bar"), + asyncResolve(15, "baz"), + ]) + ); + + expect(result.length).toEqual(3); + }); + + it("does not halt sibling operations when one fails", async () => { + let teardown = false; + await run(function* () { + yield* allSettled([ + call(function* () { + try { + yield* sleep(20); + return "foo"; + } finally { + teardown = true; + } + }), + asyncReject(10, "bar"), + ]); + }); + + expect(teardown).toEqual(true); + }); + + it("runs teardown for all operations after allSettled completes", async () => { + let teardowns: string[] = []; + let result = await run(() => + allSettled([ + call(function* () { + try { + return "foo"; + } finally { + teardowns.push("success"); + } + }), + call(function* () { + try { + throw new Error("boom: bar"); + } finally { + teardowns.push("failure"); + } + }), + ]) + ); + + expect(teardowns.sort()).toEqual(["failure", "success"]); + expect(result[0]).toMatchObject({ ok: true, value: "foo" }); + expect(result[1]).toMatchObject({ + ok: false, + error: { message: "boom: bar" }, + }); + }); + + it("has a type signature congruent with Promise.allSettled()", () => { + let resolve = (value: T) => call(() => value); + + expectType< + Operation<[Result, Result, Result]> + >( + allSettled([resolve("hello"), resolve(42), resolve("world")]), + ); + expectType, Result]>>( + allSettled([resolve("hello"), resolve(42)]), + ); + }); +});