From e10cd3afc0547639f2ede84fe9c04e4e3d54b59a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 4 Feb 2026 16:40:24 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Introduce=20context=20APIs=20fo?= =?UTF-8?q?r=20algebraic=20effects=20(#1101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One of the most powerful patterns that we've uncovered in the past couple of years of writing Effection code in production is the ability to contextualize an API so that they can be decorated in order to alter its behavior at any point. In other circles, this capability is known as "Algebraic Effects" and "Contextual Effects". With them, we can build all manner of constructs with a single primitive that would otherwise require many unique mechanisms. These include things like: - dependency injection - mocking inside tests with test doubles - adding instrumentation such as OTEL spans and metrics colleciton to - existing interfaces - wrapping stuff in database transactions This functionality was available as an external extension (https://frontside.com/effection/x/context-api), but the pattern has proven so powerful that we're bringing it directly into Effection core. Among other things, this will allow us to provide the type of orthogonal observability that we need to build the Effection inspector without having to change the library itself in order to accomodate it. This change brings the context API functionality directly into Effection. To create an API, call `createApi()` with the "core" functionality, where the "core" is how it will behave without any modification. ```ts // logging.ts export const Logging = createApi("Logging", { *log(...values: unknown[]) { console.log(...values); } }) // export member operations so they can be use standalone export const { log } = Logging.operations; ``` ```ts import { log } from "./logging.ts" export function* example() { // do stuff yield* log("just did stuff"); } ``` ```ts // Override it contextually only inside this scope yield* logging.around({ *log(args, next) { yield* next(...args.map(v => `[CUSTOM] ${v}`)); } }); ``` Apis can be enhanced directly from a `Scope` as well: ```ts import { Logging } from "./logging.ts"; function enhanceLogging(scope: Scope) { scope.around(Logging, { *log(args, next) { yield* next(...args.map(v => `[CUSTOM] ${v}`)); } }); } ``` As an example, and as the api necessary to implement the inspector, this provides a Scope api which provides instrumentation for core Effection operations. Such as create(), destroy(), set(), and delete() for scopes. --- deno.json | 2 +- experimental.ts | 1 + lib/api-internal.ts | 174 ++++++++++++++++++++++++++++++++++ lib/api.ts | 34 +++++++ lib/scope-internal.ts | 131 ++++++++++++++++++++------ lib/types.ts | 91 ++++++++++++++++++ test/api.test.ts | 210 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 612 insertions(+), 31 deletions(-) create mode 100644 lib/api-internal.ts create mode 100644 lib/api.ts create mode 100644 test/api.test.ts diff --git a/deno.json b/deno.json index aee886609..afa23d842 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,7 @@ "build:jsr": "deno run -A tasks/build-jsr.ts" }, "lint": { - "rules": { "exclude": ["prefer-const", "require-yield"] }, + "rules": { "exclude": ["prefer-const", "require-yield", "ban-types"] }, "exclude": ["build", "docs", "tasks", "bench"], "plugins": ["lint/prefer-let.ts"] }, diff --git a/experimental.ts b/experimental.ts index e69de29bb..7f7317fb3 100644 --- a/experimental.ts +++ b/experimental.ts @@ -0,0 +1 @@ +export * from "./lib/api.ts"; diff --git a/lib/api-internal.ts b/lib/api-internal.ts new file mode 100644 index 000000000..c33d6712f --- /dev/null +++ b/lib/api-internal.ts @@ -0,0 +1,174 @@ +// deno-lint-ignore-file ban-types no-explicit-any +import { createContext } from "./context.ts"; +import type { + Api, + Around, + Context, + Effect, + Middleware, + Operation, + Scope, +} from "./types.ts"; +import type { ScopeInternal } from "./scope-internal.ts"; +import { Children } from "./contexts.ts"; +import { Ok } from "./result.ts"; + +export interface ApiInternal extends Api { + context: Context<{ + max: Partial>[]; + min: Partial>[]; + }>; + core: A; + cache: WeakMap; + invalidate(scope: Scope): void; +} + +export function createApiInternal( + name: string, + core: A, +): ApiInternal { + let fields = Object.keys(core) as (keyof A)[]; + + let context = createContext(`api::${name}`) as ApiInternal["context"]; + + let cache = new WeakMap(); + + let api: ApiInternal = { + core, + context, + cache, + invalidate(scope: Scope) { + cache.delete(scope); + let children = scope.get(Children); + if (children) { + for (let child of children) { + api.invalidate(child); + } + } + }, + invoke: (scope, key, args) => { + let handle = cache.get(scope); + if (!handle) { + handle = createHandle(api, scope as ScopeInternal); + cache.set(scope, handle); + } + let member = handle[key]; + if (typeof member === "function") { + return member(...args); + } else { + return member; + } + }, + around: (decorator, options = { at: "max" }) => ({ + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + scope.around(api, decorator, options); + }, + }), + operations: fields.reduce((sum, field) => { + if (typeof core[field] === "function") { + return Object.assign(sum, { + [field]: (...args: any) => ({ + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + let target = api.invoke(scope, field, args); + + return isOperation(target) ? yield* target : target; + }, + }), + }); + } else { + return Object.assign(sum, { + [field]: { + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + let target = api.invoke(scope, field, [] as any); + return isOperation(target) ? yield* target : target; + }, + }, + }); + } + }, {} as Api["operations"]), + }; + return api; +} + +function createHandle( + api: ApiInternal, + scope: ScopeInternal, +): A { + // there is no middleware at all for this api, so the handle _is_ the core. + if (!scope.get(api.context)) { + return api.core; + } + + let handle = Object.create(api.core); + + for (let key of Object.keys(api.core) as Array) { + let { min, max } = scope.reduce(api.context, (sum, current) => { + let min = current.min.flatMap((around) => + around[key] ? [around[key]] : [] + ); + let max = current.max.flatMap((around) => + around[key] ? [around[key]] : [] + ); + + sum.min.push(...min); + sum.max.unshift(...max); + + return sum; + }, { + min: [] as Around[typeof key][], + max: [] as Around[typeof key][], + }); + + let stack = combine(max.concat(min) as Middleware[]); + + if (typeof api.core[key] === "function") { + handle[key] = (...args: unknown[]) => + stack(args, api.core[key] as (...args: unknown[]) => unknown); + } else { + Object.defineProperty(handle, key, { + enumerable: true, + get() { + return stack([], () => api.core[key]); + }, + }); + } + } + + return handle; +} + +function isOperation( + target: Operation | T, +): target is Operation { + return target && !isNativeIterable(target) && + typeof (target as Operation)[Symbol.iterator] === "function"; +} + +function isNativeIterable(target: unknown): boolean { + return ( + typeof target === "string" || Array.isArray(target) || + target instanceof Map || target instanceof Set + ); +} + +function combine( + middlewares: Middleware[], +): Middleware { + if (middlewares.length === 0) { + return (args, next) => next(...args); + } + return middlewares.reduceRight((sum, middleware) => (args, next) => + middleware(args, (...args) => sum(args, next)) + ); +} + +const GetScope: Effect = { + description: "Fast, non-typesafe lookup of co-routine scope", + enter: (resolve, routine) => { + resolve(Ok(routine.scope)); + return (didExit) => didExit(Ok()); + }, +}; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 000000000..2a716d9d0 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,34 @@ +// deno-lint-ignore-file ban-types +import type { Api, Context, Operation, Scope } from "./types.ts"; +import { createApiInternal } from "./api-internal.ts"; +import type { ScopeInternal } from "./scope-internal.ts"; + +export function createApi(name: string, core: A): Api { + return createApiInternal(name, core); +} + +interface ScopeApi { + create(parent: Scope): [Scope, () => Operation]; + destroy(scope: Scope): Operation; + set(scope: Scope, context: Context, value: T): T; + delete(scope: Scope, context: Context): boolean; +} + +interface Apis { + Scope: Api; +} + +export const api: Apis = { + Scope: createApi("Scope", { + create() { + throw new TypeError(`no handler for Scope.create()`); + }, + *destroy() {}, + set(scope, context, value) { + return (scope as ScopeInternal).contexts[context.name] = value; + }, + delete(scope, context) { + return delete (scope as ScopeInternal).contexts[context.name]; + }, + }), +}; diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 9351d0a81..cc60c2164 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,3 +1,5 @@ +import type { ApiInternal } from "./api-internal.ts"; +import { api as effection } from "./api.ts"; import { Children, Priority } from "./contexts.ts"; import { createFuture } from "./future.ts"; import { Err, Ok, unbox } from "./result.ts"; @@ -5,8 +7,29 @@ import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; +const api = effection.Scope; + export function createScopeInternal( parent?: Scope, +): [ScopeInternal, () => Operation] { + if (!parent) { + let [global, destroy] = buildScopeInternal(); + global.around(api, { + create([parent]) { + return buildScopeInternal(parent); + }, + }, { at: "min" }); + return [global, destroy] as const; + } else { + return api.invoke(parent, "create", [parent]) as [ + ScopeInternal, + () => Operation, + ]; + } +} + +export function buildScopeInternal( + parent?: Scope, ): [ScopeInternal, () => Operation] { let destructors = new Set<() => Operation>(); let destruction = createFuture(); @@ -21,7 +44,7 @@ export function createScopeInternal( return (contexts[context.name] ?? context.defaultValue) as T | undefined; }, set(context: Context, value: T): T { - return contexts[context.name] = value; + return api.invoke(scope, "set", [scope, context, value]) as T; }, expect(context: Context): T { let value = scope.get(context); @@ -33,7 +56,7 @@ export function createScopeInternal( return value; }, delete(context: Context): boolean { - return delete contexts[context.name]; + return api.invoke(scope, "delete", [scope, context]); }, hasOwn(context: Context): boolean { return !!Reflect.getOwnPropertyDescriptor(contexts, context.name); @@ -49,48 +72,91 @@ export function createScopeInternal( }; }, + around( + api: ApiInternal, + ...params: Parameters["around"]> + ) { + let [around, options] = params; + if (!scope.hasOwn(api.context)) { + scope.set(api.context, { min: [], max: [] }); + } + + let { min, max } = scope.expect(api.context); + + if (options?.at === "min") { + min.push(around); + } else { + max.push(around); + } + + api.invalidate(scope); + }, + ensure(op: () => Operation): () => void { destructors.add(op); return () => destructors.delete(op); }, + + reduce( + context: Context, + fn: (sum: S, item: T) => S, + initial: S, + ): S { + let sum = initial; + let current = contexts; + while (current) { + if (Object.hasOwn(current, context.name)) { + let item = current[context.name] as T; + if (item) { + sum = fn(sum, item); + } + } + + current = Object.getPrototypeOf(current); + } + return sum; + }, }); scope.set(Priority, scope.expect(Priority) + 1); scope.set(Children, new Set()); parent?.expect(Children).add(scope); - let destroy = function* (): Operation { - destroy = () => destruction.future; - - parent?.expect(Children).delete(scope); - unbind(); - let outcome = Ok(); - try { - while (destructors.size > 0) { - let current = [...destructors]; - destructors.clear(); - for (let destructor of current) { - try { - yield* destructor(); - } catch (error) { - outcome = Err(error as Error); + let destroy = () => api.invoke(scope, "destroy", [scope]); + let unbind = parent + ? (parent as ScopeInternal).ensure(() => destroy()) + : () => {}; + + scope.around(api, { + *destroy(): Operation { + destroy = () => destruction.future; + + parent?.expect(Children).delete(scope); + unbind(); + let outcome = Ok(); + try { + while (destructors.size > 0) { + let current = [...destructors]; + destructors.clear(); + for (let destructor of current) { + try { + yield* destructor(); + } catch (error) { + outcome = Err(error); + } } } + } finally { + if (outcome.ok) { + destruction.resolve(); + } else { + destruction.reject(outcome.error); + } } - } finally { - if (outcome.ok) { - destruction.resolve(); - } else { - destruction.reject(outcome.error); - } - } - - unbox(outcome); - }; - let unbind = parent - ? (parent as ScopeInternal).ensure(() => destroy()) - : () => {}; + unbox(outcome); + }, + }, { at: "min" }); return [scope, () => destroy()]; } @@ -98,4 +164,9 @@ export function createScopeInternal( export interface ScopeInternal extends Scope, AsyncDisposable { contexts: Record; ensure(op: () => Operation): () => void; + reduce( + context: Context, + fn: (sum: TSum, item: T) => TSum, + initial: TSum, + ): TSum; } diff --git a/lib/types.ts b/lib/types.ts index 816ac7adf..434a4e36d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-explicit-any import type { Maybe } from "./maybe.ts"; import type { Result } from "./result.ts"; @@ -336,8 +337,98 @@ export interface Scope { * @returns `true` if scope has its own context, `false` if context is not present, or inherited from its parent. */ hasOwn(context: Context): boolean; + + /** + * Enhance an {@link Api} within this scope by surrounding it with + * middleware. + * + * @param api - the api being enhanced + * @param middlewares - collection of {@link Middleware} to be added to this {@link Api} + * @param options - specifies which layer of dispatch `middlewares` will be applied + * @see {@link Api.around} + * @since 4.1 + */ + around(api: Api, ...options: Parameters["around"]>): void; } +/** + * A set of methods and values that can be decorated on a per-scope + * basis. Apis are ideal for situations that require context + * sensitivity such as dependency injection, test mocking, and + * instrumentation. + * + * @template A - core shape of the Api + * @see {@link createApi} + * @since 4.1 + */ +export interface Api { + /** + * Every member of `A` "lifted" into an operation that invokes that + * member on the current {Scope} + */ + operations: { + [K in keyof A]: A[K] extends Operation ? A[K] + : A[K] extends (...args: infer TArgs) => infer TReturn + ? TReturn extends Operation ? A[K] + : (...args: TArgs) => Operation + : Operation; + }; + /** + * Enhance an {@link Api} within this scope by surrounding it with + * middleware. + * + * @param middlewares - a set of decorators that will surround the api core + * @param options - specify at which layer of dispatch, `middleware` will apply + * @returns an {Operation} that installs the middleware in the current {Scope} + */ + around: ( + middlewares: Partial>, + options?: { + at: "min" | "max"; + }, + ) => Operation; + + /** + * Call an API as it exists on `scope`. + */ + invoke: ( + scope: Scope, + key: K, + args: A[K] extends (...args: any) => unknown ? Parameters : [], + ) => A[K] extends (...args: any) => unknown ? ReturnType : A[K]; +} + +/** + * An general function that can be used to surround any other function + * or value. + * + * @since 4.1 + */ +export interface Middleware { + /** + * Execute a single link in the middleware stack by doing whatever + * computation is necessary and then optionally delegating to the + * next link. + * + * @param args - the arguments to the value being surrounded. + * @param next - the next function in the change. It will accept the + * arguments contained in `args` + * @returns a value with the same shape as `next()`'s return value + */ + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +/** + * The shape of middlewares can surround a particular {Api} + * + * Members of an Api that are values are surrounded by no-arg functions. + */ +export type Around = { + [K in keyof Api]: Api[K] extends (...args: infer TArgs) => infer TReturn + ? Middleware + : Middleware<[], Api[K]>; +}; + /** * Unwrap the type of an `Operation`. * Analogous to the built in [`Awaited`](https://www.typescriptlang.org/docs/handbook/utility-types.html#awaitedtype) type. diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 000000000..7c4be2a43 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,210 @@ +import { run } from "../mod.ts"; +import { createApi } from "../experimental.ts"; +import { constant } from "../lib/constant.ts"; +import { type Operation, spawn } from "../lib/mod.ts"; +import { describe, expect, it } from "./suite.ts"; + +describe("api", () => { + it("invokes operation functions as operations", async () => { + let api = createApi("test", { + *test() { + return 5; + }, + }); + + await run(function* () { + expect(yield* api.operations.test()).toEqual(5); + }); + }); + + it("invokes synchronous functions as operations", async () => { + let api = createApi("test", { + five: () => 5, + }); + + await run(function* () { + expect(yield* api.operations.five()).toEqual(5); + }); + }); + + it("invokes operations as operations", async () => { + let api = createApi("test", { + five: { + *[Symbol.iterator]() { + return 5; + }, + } as Operation, + }); + + await run(function* () { + expect(yield* api.operations.five).toEqual(5); + }); + }); + + it("invokes constants as operations", async () => { + let api = createApi("test", { + five: 5, + }); + + await run(function* () { + expect(yield* api.operations.five).toEqual(5); + }); + }); + + it("can have middleware installed", async () => { + let api = createApi("test", { + constFive: 5, + *operationFnFive() { + return 5; + }, + operationFive: constant(5), + syncFive: () => 5 as number, + }); + + await run(function* () { + yield* api.around({ + constFive(args, next) { + return next(...args) * 2; + }, + *operationFnFive(args, next) { + return (yield* next(...args)) * 2; + }, + *operationFive(args, next) { + return (yield* next(...args)) * 2; + }, + syncFive: (args, next) => next(...args) * 2, + }); + + expect(yield* api.operations.constFive).toEqual(10); + expect(yield* api.operations.operationFnFive()).toEqual(10); + expect(yield* api.operations.operationFive).toEqual(10); + expect(yield* api.operations.syncFive()).toEqual(10); + }); + }); + + it("inherits middleware from scope", async () => { + let api = createApi("test", { + *num(value: number): Operation { + return value; + }, + }); + + await run(function* () { + yield* api.around({ + *num(args, next) { + return (yield* next(...args)) * 2; + }, + }); + let task = yield* spawn(function* () { + return yield* api.operations.num(5); + }); + + expect(yield* task).toEqual(10); + }); + }); + + it("applies maximal middleware before minimal middleware", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + await run(function* () { + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("max1")); + return output.concat("/max1"); + }, + }); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("max2")); + return output.concat("/max2"); + }, + }); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("min1")); + return output.concat("/min1"); + }, + }); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("min2")); + return output.concat("/min2"); + }, + }); + + expect(yield* api.operations.test([])).toEqual([ + "max1", + "max2", + "min1", + "min2", + "/min2", + "/min1", + "/max2", + "/max1", + ]); + }); + }); + + it("applies outer scope maxima more maximally than inner scopes maxima", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + await run(function* outer() { + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("outermax")); + return output.concat("/outermax"); + }, + }); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("outermin")); + return output.concat("/outermin"); + }, + }, { at: "min" }); + + let task = yield* spawn(function* inner() { + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("innermax")); + return output.concat("/innermax"); + }, + }); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("innermin")); + return output.concat("/innermin"); + }, + }, { at: "min" }); + + return yield* api.operations.test([]); + }); + + expect(yield* task).toEqual([ + "outermax", + "innermax", + "innermin", + "outermin", + "/outermin", + "/innermin", + "/innermax", + "/outermax", + ]); + }); + }); +}); From 14e9ea6a382e2ae6992f5c373f5125d8925a3ffa Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 9 Jun 2026 13:17:25 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20statically=20compute?= =?UTF-8?q?=20api=20tree=20at=20decorate=20time=20(#1183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The computational complexity of reifying an api handle was being deferred until the moment the api was being called, and then every invocation paid the cost of calculating the _entire_ middleware stack from the root all they way back to the decorate scope. The handle was cached for subsequent invocations, but any new `around()` invalidated that scope and every descendant, which meant that every single one had to walk the entire scope chain on the next invocation. The handle is now materialized at decoration time. Each api context carries `local` (this scope's contributions), `total` (the inherited stack), and `handle` (the assembled api). `around()` appends the new decorator onto `local` and then walks the subtree in `install()`, rebuilding each descendant's handle in place. Critically, the inherited middleware stack is cached at every scope that has middleware, so any subsequent calls to around() can begin from that total, and do not need to walk back up the entire scope chain. In all cases, because the api handle is computed eagerly, `invoke()` is single context lookup and dispatch. --- lib/api-internal.ts | 158 ++++++++++++++++++----------- lib/scope-internal.ts | 38 +------ test/api.test.ts | 227 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 325 insertions(+), 98 deletions(-) diff --git a/lib/api-internal.ts b/lib/api-internal.ts index c33d6712f..b49d5e808 100644 --- a/lib/api-internal.ts +++ b/lib/api-internal.ts @@ -9,18 +9,16 @@ import type { Operation, Scope, } from "./types.ts"; -import type { ScopeInternal } from "./scope-internal.ts"; import { Children } from "./contexts.ts"; import { Ok } from "./result.ts"; export interface ApiInternal extends Api { context: Context<{ - max: Partial>[]; - min: Partial>[]; + local: Decorator; + total: Decorator; + handle: A; }>; core: A; - cache: WeakMap; - invalidate(scope: Scope): void; } export function createApiInternal( @@ -31,27 +29,13 @@ export function createApiInternal( let context = createContext(`api::${name}`) as ApiInternal["context"]; - let cache = new WeakMap(); - let api: ApiInternal = { core, context, - cache, - invalidate(scope: Scope) { - cache.delete(scope); - let children = scope.get(Children); - if (children) { - for (let child of children) { - api.invalidate(child); - } - } - }, + invoke: (scope, key, args) => { - let handle = cache.get(scope); - if (!handle) { - handle = createHandle(api, scope as ScopeInternal); - cache.set(scope, handle); - } + let handle = scope.get(api.context)?.handle ?? core; + let member = handle[key]; if (typeof member === "function") { return member(...args); @@ -62,7 +46,7 @@ export function createApiInternal( around: (decorator, options = { at: "max" }) => ({ *[Symbol.iterator]() { let scope = (yield GetScope) as Scope; - scope.around(api, decorator, options); + decorateApi(scope, api, decorator, options); }, }), operations: fields.reduce((sum, field) => { @@ -93,50 +77,107 @@ export function createApiInternal( return api; } -function createHandle( +export function decorateApi( + scope: Scope, api: ApiInternal, - scope: ScopeInternal, -): A { - // there is no middleware at all for this api, so the handle _is_ the core. - if (!scope.get(api.context)) { - return api.core; + decorator: Partial>, + options?: { + at: "min" | "max"; + }, +) { + // read existing total and local + let current = scope.get(api.context) ?? { total: {}, local: {} }; + + let local = decorate(current.local, { + [options?.at ?? "max"]: decorator, + }); + + if (!scope.hasOwn(api.context)) { + scope.set(api.context, { + local, + total: current.total, + handle: api.core, + }); + } else { + current.local = local; } - let handle = Object.create(api.core); + install(scope, api, current.total); +} - for (let key of Object.keys(api.core) as Array) { - let { min, max } = scope.reduce(api.context, (sum, current) => { - let min = current.min.flatMap((around) => - around[key] ? [around[key]] : [] - ); - let max = current.max.flatMap((around) => - around[key] ? [around[key]] : [] - ); +function decorate(base: Decorator, next: Decorator): Decorator { + return { + max: base.max ? next.max ? append(base.max, next.max) : base.max : next.max, + min: next.min ? base.min ? append(next.min, base.min) : next.min : base.min, + }; +} - sum.min.push(...min); - sum.max.unshift(...max); +function append( + outer: Partial>, + inner: Partial>, +): Partial> { + let result: Partial> = { ...outer }; + for (let key of Object.keys(inner) as (keyof A)[]) { + let current = outer[key]; + let decoration = inner[key]; + if (!current) { + result[key] = decoration; + } else { + let pair = [current, decoration] as Middleware[]; + result[key] = combine(pair) as Around[keyof A]; + } + } + return result; +} - return sum; - }, { - min: [] as Around[typeof key][], - max: [] as Around[typeof key][], - }); +function install( + scope: Scope, + api: ApiInternal, + total: Decorator, +): void { + if (scope.hasOwn(api.context)) { + let context = scope.expect(api.context); + + context.total = total; + + total = decorate(context.total, context.local); + + context.handle = createApiHandle(total, api.core); + } - let stack = combine(max.concat(min) as Middleware[]); + for (let child of scope.expect(Children)) { + install(child, api, total); + } +} - if (typeof api.core[key] === "function") { - handle[key] = (...args: unknown[]) => - stack(args, api.core[key] as (...args: unknown[]) => unknown); +function createApiHandle(decoration: Decorator, core: A): A { + let around = decoration.max + ? decoration.min ? append(decoration.max, decoration.min) : decoration.max + : decoration.min; + if (!around) { + return core; + } + let handle: A = {} as A; + for (let key of Object.keys(core as object) as Array) { + let middleware = around[key] as Middleware | undefined; + if (middleware) { + if (typeof core[key] === "function") { + handle[key] = ((...args: unknown[]) => + middleware(args, core[key] as (...args: unknown[]) => unknown)) as A[ + keyof A + ]; + } else { + Object.defineProperty(handle, key, { + enumerable: true, + get() { + return middleware([], () => core[key]); + }, + }); + } } else { - Object.defineProperty(handle, key, { - enumerable: true, - get() { - return stack([], () => api.core[key]); - }, - }); + handle[key] = core[key]; } } - return handle; } @@ -165,6 +206,11 @@ function combine( ); } +interface Decorator { + min?: Partial>; + max?: Partial>; +} + const GetScope: Effect = { description: "Fast, non-typesafe lookup of co-routine scope", enter: (resolve, routine) => { diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index cc60c2164..3e7ddd1c9 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,4 +1,4 @@ -import type { ApiInternal } from "./api-internal.ts"; +import { type ApiInternal, decorateApi } from "./api-internal.ts"; import { api as effection } from "./api.ts"; import { Children, Priority } from "./contexts.ts"; import { createFuture } from "./future.ts"; @@ -71,51 +71,17 @@ export function buildScopeInternal( }, }; }, - around( api: ApiInternal, ...params: Parameters["around"]> ) { - let [around, options] = params; - if (!scope.hasOwn(api.context)) { - scope.set(api.context, { min: [], max: [] }); - } - - let { min, max } = scope.expect(api.context); - - if (options?.at === "min") { - min.push(around); - } else { - max.push(around); - } - - api.invalidate(scope); + decorateApi(scope, api, ...params); }, ensure(op: () => Operation): () => void { destructors.add(op); return () => destructors.delete(op); }, - - reduce( - context: Context, - fn: (sum: S, item: T) => S, - initial: S, - ): S { - let sum = initial; - let current = contexts; - while (current) { - if (Object.hasOwn(current, context.name)) { - let item = current[context.name] as T; - if (item) { - sum = fn(sum, item); - } - } - - current = Object.getPrototypeOf(current); - } - return sum; - }, }); scope.set(Priority, scope.expect(Priority) + 1); diff --git a/test/api.test.ts b/test/api.test.ts index 7c4be2a43..6e9083445 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,7 +1,13 @@ import { run } from "../mod.ts"; import { createApi } from "../experimental.ts"; import { constant } from "../lib/constant.ts"; -import { type Operation, spawn } from "../lib/mod.ts"; +import { + type Operation, + resource, + scoped, + spawn, + useScope, +} from "../lib/mod.ts"; import { describe, expect, it } from "./suite.ts"; describe("api", () => { @@ -154,11 +160,14 @@ describe("api", () => { }); it("applies outer scope maxima more maximally than inner scopes maxima", async () => { - let api = createApi("test", { - *test(order: string[]): Operation { - return order; - }, - }); + let api = createApi( + "test", + { + *test(order: string[]): Operation { + return order; + }, + } as const, + ); await run(function* outer() { yield* api.around({ @@ -207,4 +216,210 @@ describe("api", () => { ]); }); }); + + it("propagates new ancestor middleware to existing child scopes", async () => { + let api = createApi("test", { + *num(value: number): Operation { + return value; + }, + }); + + await run(function* () { + let nummer = yield* resource<{ num(): Operation }>( + function* (provide) { + let scope = yield* useScope(); + yield* provide({ + *num() { + return yield* scope.run(() => api.operations.num(5)); + }, + }); + }, + ); + + yield* api.around({ + *num(args, next) { + return (yield* next(...args)) * 2; + }, + }); + + expect(yield* nummer.num()).toEqual(10); + }); + }); + + it("propagates new ancestor middleware to existing child scopes that also have middleware", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + await run(function* () { + let tester = yield* resource<{ test(): Operation }>( + function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("child")); + return output.concat("/child"); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }, + ); + + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("parent")); + return output.concat("/parent"); + }, + }); + + expect(yield* tester.test()).toEqual([ + "parent", + "child", + "/child", + "/parent", + ]); + }); + }); + + it("isolates sibling scopes from each other's middleware", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + function sibling(label: string) { + return resource<{ test(): Operation }>(function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat(label))).concat(`/${label}`); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }); + } + + await run(function* () { + let a = yield* sibling("a"); + let b = yield* sibling("b"); + + expect(yield* a.test()).toEqual(["a", "/a"]); + expect(yield* b.test()).toEqual(["b", "/b"]); + expect(yield* api.operations.test([])).toEqual([]); + }); + }); + + it("composes middleware across a three-level scope tree", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + function layer(label: string) { + return resource<{ test(): Operation }>(function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat(label))).concat(`/${label}`); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }); + } + + await run(function* () { + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat("grand"))).concat("/grand"); + }, + }); + + let leaf = yield* resource<{ test(): Operation }>( + function* (provide) { + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat("parent"))).concat("/parent"); + }, + }); + let child = yield* layer("child"); + yield* provide(child); + }, + ); + + expect(yield* leaf.test()).toEqual([ + "grand", + "parent", + "child", + "/child", + "/parent", + "/grand", + ]); + }); + }); + + it("does not affect un-aroundified keys when middleware is installed for a different key", async () => { + let api = createApi("test", { + *foo(): Operation { + return 1; + }, + *bar(): Operation { + return 1; + }, + }); + + await run(function* () { + yield* api.around({ + *foo(args, next) { + return (yield* next(...args)) * 100; + }, + }); + + expect(yield* api.operations.foo()).toEqual(100); + expect(yield* api.operations.bar()).toEqual(1); + }); + }); + + it("does not leak middleware from a finished child scope back to its parent", async () => { + let api = createApi("test", { + *num(value: number): Operation { + return value; + }, + }); + + await run(function* () { + let result = yield* scoped(function* () { + yield* api.around({ + *num(args, next) { + return (yield* next(...args)) * 2; + }, + }); + return yield* api.operations.num(5); + }); + + expect(result).toEqual(10); + expect(yield* api.operations.num(5)).toEqual(5); + }); + }); }); From 4ce8b339a6a2b0a94e9a4db7cbacc3e9cde1bd18 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 9 Jun 2026 15:51:24 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Don't=20use=20api=20de?= =?UTF-8?q?corator=20for=20scope.destroy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were installing an api decorator as the method for implementing the destroy method on scope, but this required recomputing the middleware stack for every scope creation which is totally unecessary work. This just puts it on a prototype method of ScopeInternal. That way, there is one version of the function, and the default api handle, just invokes it straight away. --- lib/api.ts | 4 +++- lib/scope-internal.ts | 37 +++++++++++++++++-------------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/api.ts b/lib/api.ts index 2a716d9d0..3e3f98a6a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -23,7 +23,9 @@ export const api: Apis = { create() { throw new TypeError(`no handler for Scope.create()`); }, - *destroy() {}, + destroy(scope) { + return (scope as ScopeInternal).destroy(); + }, set(scope, context, value) { return (scope as ScopeInternal).contexts[context.name] = value; }, diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 3e7ddd1c9..72d760d0b 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -33,6 +33,10 @@ export function buildScopeInternal( ): [ScopeInternal, () => Operation] { let destructors = new Set<() => Operation>(); let destruction = createFuture(); + let signaled = false; + let unbind = parent + ? (parent as ScopeInternal).ensure(() => destroy()) + : () => {}; let contexts: Record = Object.create( parent ? (parent as ScopeInternal).contexts : null, @@ -82,21 +86,12 @@ export function buildScopeInternal( destructors.add(op); return () => destructors.delete(op); }, - }); - - scope.set(Priority, scope.expect(Priority) + 1); - scope.set(Children, new Set()); - parent?.expect(Children).add(scope); - - let destroy = () => api.invoke(scope, "destroy", [scope]); - let unbind = parent - ? (parent as ScopeInternal).ensure(() => destroy()) - : () => {}; - scope.around(api, { *destroy(): Operation { - destroy = () => destruction.future; - + if (signaled) { + return yield* destruction.future; + } + signaled = true; parent?.expect(Children).delete(scope); unbind(); let outcome = Ok(); @@ -122,17 +117,19 @@ export function buildScopeInternal( unbox(outcome); }, - }, { at: "min" }); + }); + + scope.set(Priority, scope.expect(Priority) + 1); + scope.set(Children, new Set()); + parent?.expect(Children).add(scope); + + let destroy = () => api.invoke(scope, "destroy", [scope]); - return [scope, () => destroy()]; + return [scope, destroy]; } export interface ScopeInternal extends Scope, AsyncDisposable { contexts: Record; ensure(op: () => Operation): () => void; - reduce( - context: Context, - fn: (sum: TSum, item: T) => TSum, - initial: TSum, - ): TSum; + destroy(): Operation; }