From 985cf746a3eae49419e213f17eeecc7cdd47f977 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 20 Jan 2025 09:57:00 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Backfill=20the=20`scoped()`=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow v3 users to "upgrade" to the "scoped" API. `call()` and `action()` are the ways to establish concurrency boundaries and error handling boundaries in v3, but they are getting simplified and losing those capabilities (it's a good thing!) However, in order to make the upgrade seemless, we want to have a backfill that let's you use the scoped API _before_ you switch the version of Effection to 4. This wraps `call()` which has the same function signature, and copies over its jsdoc so that the `scoped()` intellisense will be available in IDEs --- lib/mod.ts | 1 + lib/scoped.ts | 28 ++++++++ test/scoped.test.ts | 158 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 lib/scoped.ts create mode 100644 test/scoped.test.ts diff --git a/lib/mod.ts b/lib/mod.ts index 70f260b3..496ba273 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -18,3 +18,4 @@ export * from "./signal.ts"; export * from "./ensure.ts"; export * from "./race.ts"; export * from "./with-resolvers.ts"; +export * from "./scoped.ts"; diff --git a/lib/scoped.ts b/lib/scoped.ts new file mode 100644 index 00000000..f1825ed5 --- /dev/null +++ b/lib/scoped.ts @@ -0,0 +1,28 @@ +import type { Operation } from "./types.ts"; +import { call } from "./call.ts"; + +/** + * Encapsulate an operation so that no effects will persist outside of + * it. All active effects such as concurrent tasks and resources will be + * shut down, and all contexts will be restored to their values outside + * of the scope. + * + * @example + * ```js + * import { useAbortSignal } from "effection"; + + * function* example() { + * let signal = yield* scoped(function*() { + * return yield* useAbortSignal(); + * }); + * return signal.aborted; //=> true + * } + * ``` + * + * @param operation - the operation to be encapsulated + * + * @returns the scoped operation + */ +export function scoped(operation: () => Operation): Operation { + return call(operation); +} diff --git a/test/scoped.test.ts b/test/scoped.test.ts new file mode 100644 index 00000000..88a03c6f --- /dev/null +++ b/test/scoped.test.ts @@ -0,0 +1,158 @@ +import { + createContext, + resource, + run, + scoped, + sleep, + spawn, + suspend, +} from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("scoped", () => { + describe("task", () => { + it("shuts down after completion", () => + run(function* () { + let didEnter = false; + let didExit = false; + + yield* scoped(function* () { + yield* spawn(function* () { + try { + didEnter = true; + yield* suspend(); + } finally { + didExit = true; + } + }); + yield* sleep(0); + }); + + expect(didEnter).toBe(true); + expect(didExit).toBe(true); + })); + + it("shuts down after error", () => + run(function* () { + let didEnter = false; + let didExit = false; + + try { + yield* scoped(function* () { + yield* spawn(function* () { + try { + didEnter = true; + yield* suspend(); + } finally { + didExit = true; + } + }); + yield* sleep(0); + throw new Error("boom!"); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + expect(didEnter).toBe(true); + expect(didExit).toBe(true); + } + })); + + it("delimits error boundaries", () => + run(function* () { + try { + yield* scoped(function* () { + yield* spawn(function* () { + throw new Error("boom!"); + }); + yield* suspend(); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } + })); + }); + describe("resource", () => { + it("shuts down after completion", () => + run(function* () { + let status = "pending"; + yield* scoped(function* () { + yield* resource(function* (provide) { + try { + status = "open"; + yield* provide(); + } finally { + status = "closed"; + } + }); + yield* sleep(0); + expect(status).toEqual("open"); + }); + expect(status).toEqual("closed"); + })); + + it("shuts down after error", () => + run(function* () { + let status = "pending"; + try { + yield* scoped(function* () { + yield* resource(function* (provide) { + try { + status = "open"; + yield* provide(); + } finally { + status = "closed"; + } + }); + yield* sleep(0); + expect(status).toEqual("open"); + throw new Error("boom!"); + }); + } catch (error) { + expect((error as Error).message).toEqual("boom!"); + expect(status).toEqual("closed"); + } + })); + + it("delimits error boundaries", () => + run(function* () { + try { + yield* scoped(function* () { + yield* resource(function* (provide) { + yield* spawn(function* () { + yield* sleep(0); + throw new Error("boom!"); + }); + yield* provide(); + }); + yield* suspend(); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } + })); + }); + describe("context", () => { + let context = createContext("greetting", "hi"); + it("is restored after exiting scope", () => + run(function* () { + yield* scoped(function* () { + yield* context.set("hola"); + }); + expect(yield* context.get()).toEqual("hi"); + })); + + it("is restored after erroring", () => + run(function* () { + try { + yield* scoped(function* () { + yield* context.set("hola"); + throw new Error("boom!"); + }); + } catch (error) { + expect(error).toMatchObject({ message: "boom!" }); + } finally { + expect(yield* context.get()).toEqual("hi"); + } + })); + }); +});