From d3960ea4ec6b68dffa0b402f4dcdfc21101344ce Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 3 Feb 2026 15:16:34 -0600 Subject: [PATCH 1/5] Add "experimental" entry point for new apis and publish to "next" --- .github/workflows/publish.yml | 2 +- deno.json | 6 +++++- experimental.ts | 0 tasks/build-npm.ts | 8 ++++++-- 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 experimental.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4372d96b6..4bb5bd636 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -74,7 +74,7 @@ jobs: path: ./build/npm - name: Publish NPM - run: npm publish --access=public --tag=latest + run: npm publish --access=public --tag=next working-directory: ./build/npm publish-jsr: diff --git a/deno.json b/deno.json index 1c913fe6b..cba3abf5e 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,7 @@ "name": "@effection/effection", "exports": "./mod.ts", "license": "ISC", - "publish": { "include": ["lib", "mod.ts", "README.md"] }, + "publish": { "include": ["lib", "mod.ts", "experimental.ts", "README.md"] }, "lock": false, "tasks": { "test": "deno test --allow-run --allow-read --allow-env --allow-ffi", @@ -29,6 +29,10 @@ "tinyexec": "npm:tinyexec@1.0.1", "https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "npm:path-to-regexp@8.2.0" }, + "exports": { + ".": "./mod.ts", + "./experimental": "./experimental.ts" + }, "nodeModulesDir": "auto", "workspace": [ "./www" diff --git a/experimental.ts b/experimental.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index 51ea9acff..a3f180d22 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -1,4 +1,5 @@ -import { build, emptyDir } from "jsr:@deno/dnt@0.41.3"; +import { build, emptyDir } from "jsr:@deno/dnt@0.42.3"; +import denoJSON from "../deno.json" with { type: "json" }; const outDir = "./build/npm"; @@ -10,7 +11,10 @@ if (!version) { } await build({ - entryPoints: ["./mod.ts"], + entryPoints: Object.entries(denoJSON.exports).map(([key, value]) => ({ + name: key, + path: value, + })), outDir, shims: { deno: false, From 87f1fe2eb436e99da70237dfa16288cd1239d83c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 4 Feb 2026 16:40:24 -0600 Subject: [PATCH 2/5] =?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 | 142 ++++++++++++++++++++++++++++ lib/api.ts | 34 +++++++ lib/scope-internal.ts | 122 ++++++++++++++++++------ lib/types.ts | 91 ++++++++++++++++++ test/api.test.ts | 210 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 575 insertions(+), 27 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 cba3abf5e..1f2cd1cde 100644 --- a/deno.json +++ b/deno.json @@ -12,7 +12,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"], "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..7b66c2ee5 --- /dev/null +++ b/lib/api-internal.ts @@ -0,0 +1,142 @@ +// deno-lint-ignore-file ban-types no-explicit-any +import { createContext } from "./context.ts"; +import { useScope } from "./scope.ts"; +import type { + Api, + Around, + Context, + Middleware, + Operation, + Scope, +} from "./types.ts"; +import type { ScopeInternal } from "./scope-internal.ts"; + +export interface ApiInternal extends Api { + context: Context<{ + max: Partial>[]; + min: Partial>[]; + }>; + core: A; +} + +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 api: ApiInternal = { + core, + context, + invoke: (scope, key, args) => { + let handle = createHandle(api, scope); + let member = handle[key]; + if (typeof member === "function") { + return member(...args); + } else { + return member; + } + }, + around: (decorator, options = { at: "max" }) => ({ + *[Symbol.iterator]() { + let scope = yield* useScope(); + 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* useScope(); + let target = api.invoke(scope, field, args); + + return isOperation(target) ? yield* target : target; + }, + }), + }); + } else { + return Object.assign(sum, { + [field]: { + *[Symbol.iterator]() { + let scope = yield* useScope(); + let target = api.invoke(scope, field, [] as any); + return isOperation(target) ? yield* target : target; + }, + }, + }); + } + }, {} as Api["operations"]), + }; + return api; +} + +function createHandle(api: ApiInternal, scope: Scope): A { + let handle = Object.create(api.core); + let $scope = scope as ScopeInternal; + for (let key of Object.keys(api.core) as Array) { + let dispatch = (args: unknown[], next: (...args: unknown[]) => unknown) => { + 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[], + ); + return stack(args, next); + }; + + if (typeof api.core[key] === "function") { + handle[key] = (...args: unknown[]) => + dispatch(args, api.core[key] as (...args: unknown[]) => unknown); + } else { + Object.defineProperty(handle, key, { + enumerable: true, + get() { + return dispatch([], () => 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)) + ); +} 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 0e505f4f6..f781990dc 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,11 +1,34 @@ +import type { ApiInternal } from "./api-internal.ts"; +import { api as effection } from "./api.ts"; import { Children, Priority } from "./contexts.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; import { type WithResolvers, withResolvers } from "./with-resolvers.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>(); @@ -19,7 +42,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); @@ -31,7 +54,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); @@ -51,47 +74,89 @@ 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); + } + }, + 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 = () => api.invoke(scope, "destroy", [scope]); + let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {}; let destruction: WithResolvers | undefined = undefined; - function* destroy(): Operation { - if (destruction) { - return yield* destruction.operation; - } - destruction = withResolvers(); - parent?.expect(Children).delete(scope); - unbind(); - let outcome = Ok(); - try { - for (let destructor of destructors) { - try { - destructors.delete(destructor); - yield* destructor(); - } catch (error) { - outcome = Err(error as Error); - } + scope.around(api, { + *destroy(): Operation { + if (destruction) { + return yield* destruction.operation; } - } finally { - if (outcome.ok) { - destruction.resolve(); - } else { - destruction.reject(outcome.error); + destruction = withResolvers(); + parent?.expect(Children).delete(scope); + unbind(); + let outcome = Ok(); + try { + for (let destructor of destructors) { + try { + destructors.delete(destructor); + yield* destructor(); + } catch (error) { + outcome = Err(error as Error); + } + } + } finally { + if (outcome.ok) { + destruction.resolve(); + } else { + destruction.reject(outcome.error); + } } - } - unbox(outcome); - } + unbox(outcome); + }, + }, { at: "min" }); return [scope, destroy]; } @@ -99,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 72ed7cf6c..a1f48b10f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-explicit-any import type { Result } from "./result.ts"; /** @@ -335,8 +336,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 c1672b3eb1986ac02aeec7dbc163082357f94dc8 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 4 Feb 2026 16:40:47 -0600 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20Inspectable=20attributes=20(#10?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/attributes-internal.ts | 67 ++++++++++++++++++++++++++++++++++++++ lib/attributes.ts | 1 + lib/mod.ts | 1 + test/attributes.test.ts | 45 +++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 lib/attributes-internal.ts create mode 100644 lib/attributes.ts create mode 100644 test/attributes.test.ts diff --git a/lib/attributes-internal.ts b/lib/attributes-internal.ts new file mode 100644 index 000000000..495b5a40a --- /dev/null +++ b/lib/attributes-internal.ts @@ -0,0 +1,67 @@ +import { createContext } from "./context.ts"; +import { useScope } from "./scope.ts"; +import type { Operation, Scope } from "./types.ts"; + +/** + * Serializable name/value pairs that can be used for visualizing and + * inpsecting Effection scopes. There will always be at least a name + * in the attributes. + */ +export type Attributes = + & { name: string } + & Record; + +const AttributesContext = createContext( + "@effection/attributes", + { name: "anonymous" }, +); + +/** + * Add metadata to the current {@link Scope} that can be used for + * display and debugging purposes. + * + * Calling `useAttributes()` multiple times will add new attributes + * and overwrite attributes of the same name, but it will not erase + * old ones. + * + * @example + * ```ts + * function useServer(port: number): Operation { + * return resource(function*(provide) { + * yield* useAttributes({ name: "Server", port }); + * let server = createServer(); + * server.listen(); + * try { + * yield* provide(server); + * } finally { + * server.close(); + * } + * }); + * } + * ``` + * + * @param attrs - attributes to add to this {@link Scope} + * @returns an Oeration adding `attrs` to the current scope + * @since 4.1 + */ +export function* useAttributes(attrs: Partial): Operation { + let scope = yield* useScope(); + + let current = scope.hasOwn(AttributesContext) + ? scope.expect(AttributesContext) + : AttributesContext.defaultValue!; + + scope.set(AttributesContext, { ...current, ...attrs }); +} + +/** + * Get the unique attributes of this {@link Scope}. Attributes are not + * inherited and only the attributes explicitly assigned to this scope + * will be returned. + */ +export function getAttributes(scope: Scope) { + if (scope.hasOwn(AttributesContext)) { + return scope.expect(AttributesContext); + } + return AttributesContext.defaultValue as Attributes; +} diff --git a/lib/attributes.ts b/lib/attributes.ts new file mode 100644 index 000000000..f40df47be --- /dev/null +++ b/lib/attributes.ts @@ -0,0 +1 @@ +export { type Attributes, useAttributes } from "./attributes-internal.ts"; diff --git a/lib/mod.ts b/lib/mod.ts index e15e4d3c5..c0d1a2d1f 100644 --- a/lib/mod.ts +++ b/lib/mod.ts @@ -2,6 +2,7 @@ export * from "./types.ts"; export * from "./result.ts"; export * from "./action.ts"; export * from "./context.ts"; +export * from "./attributes.ts"; export * from "./scope.ts"; export * from "./suspend.ts"; export * from "./sleep.ts"; diff --git a/test/attributes.test.ts b/test/attributes.test.ts new file mode 100644 index 000000000..3933865ae --- /dev/null +++ b/test/attributes.test.ts @@ -0,0 +1,45 @@ +import { run, spawn, useAttributes, useScope } from "../mod.ts"; +import { describe, expect, it } from "./suite.ts"; +import { getAttributes } from "../lib/attributes-internal.ts"; + +describe("useAttributes", () => { + it("adds attributes to the current scope", async () => { + let scope = await run(function* main() { + yield* useAttributes({ name: "Main", awesome: true }); + + return yield* useScope(); + }); + + let attrs = getAttributes(scope); + + expect(attrs).toEqual({ name: "Main", awesome: true }); + }); + + it("does not cause any attributes to be inherited from the parent", async () => { + let scope = await run(function* main() { + yield* useAttributes({ awesome: true }); + let child = yield* spawn(function* () { + yield* useAttributes({ name: "Child" }); + return yield* useScope(); + }); + + return yield* child; + }); + + let attrs = getAttributes(scope); + + expect(attrs).toEqual({ name: "Child" }); + }); + + it("adds new attributes to existing ones", async () => { + let scope = await run(function* main() { + yield* useAttributes({ name: "Main" }); + yield* useAttributes({ awesome: true }); + return yield* useScope(); + }); + + let attrs = getAttributes(scope); + + expect(attrs).toEqual({ name: "Main", awesome: true }); + }); +}); From 9e6b35a4ce08fe04f958d065a53259468f0fe614 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 4 Feb 2026 21:07:37 -0600 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20Add=20`Main`=20api=20(#1102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to implement the inspector, we need to be able to wrap code around the main function that will run around the main entry point. This lets you do one time setup and one-time teardown. --- lib/api.ts | 10 +++ lib/main.ts | 185 +++++++++++++++++++++++++++------------------------- 2 files changed, 106 insertions(+), 89 deletions(-) diff --git a/lib/api.ts b/lib/api.ts index 2a716d9d0..3c68b38b1 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -14,8 +14,13 @@ interface ScopeApi { delete(scope: Scope, context: Context): boolean; } +interface MainApi { + main(body: (args: string[]) => Operation): Promise; +} + interface Apis { Scope: Api; + Main: Api; } export const api: Apis = { @@ -31,4 +36,9 @@ export const api: Apis = { return delete (scope as ScopeInternal).contexts[context.name]; }, }), + Main: createApi("Main", { + main() { + throw new TypeError(`missing handler for "main()"`); + }, + }), }; diff --git a/lib/main.ts b/lib/main.ts index 3ec5dfe28..cc648e847 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -2,8 +2,9 @@ import { createContext } from "./context.ts"; import type { Operation } from "./types.ts"; import { callcc } from "./callcc.ts"; import { run } from "./run.ts"; -import { useScope } from "./scope.ts"; +import { global, useScope } from "./scope.ts"; import { call } from "./call.ts"; +import { api } from "./api.ts"; /** * Halt process execution immediately and initiate shutdown. If a message is @@ -60,103 +61,109 @@ export function* exit(status: number, message?: string): Operation { * @since 3.0 */ -export async function main( +export function main( body: (args: string[]) => Operation, ): Promise { - let hardexit = (_status: number) => {}; - - let result = await run(() => - callcc(function* (resolve) { - // action will return shutdown immediately upon resolve, so stash - // this function in the exit context so it can be called anywhere. - yield* ExitContext.set(resolve); - - // this will hold the event loop and prevent runtimes such as - // Node and Deno from exiting prematurely. - let interval = setInterval(() => {}, Math.pow(2, 30)); - - let scope = yield* useScope(); - - try { - let interrupt = { - SIGINT: () => - scope.run(() => resolve({ status: 130, signal: "SIGINT" })), - SIGTERM: () => - scope.run(() => resolve({ status: 143, signal: "SIGTERM" })), - }; - - yield* withHost({ - *deno() { - hardexit = (status) => Deno.exit(status); - try { - Deno.addSignalListener("SIGINT", interrupt.SIGINT); - /** - * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP) - */ - if (Deno.build.os !== "windows") { - Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); - } - yield* body(Deno.args.slice()); - } finally { - Deno.removeSignalListener("SIGINT", interrupt.SIGINT); - if (Deno.build.os !== "windows") { - Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + return api.Main.invoke(global, "main", [body]); +} + +global.around(api.Main, { + async main([body]) { + let hardexit = (_status: number) => {}; + + let result = await run(() => + callcc(function* (resolve) { + // action will return shutdown immediately upon resolve, so stash + // this function in the exit context so it can be called anywhere. + yield* ExitContext.set(resolve); + + // this will hold the event loop and prevent runtimes such as + // Node and Deno from exiting prematurely. + let interval = setInterval(() => {}, Math.pow(2, 30)); + + let scope = yield* useScope(); + + try { + let interrupt = { + SIGINT: () => + scope.run(() => resolve({ status: 130, signal: "SIGINT" })), + SIGTERM: () => + scope.run(() => resolve({ status: 143, signal: "SIGTERM" })), + }; + + yield* withHost({ + *deno() { + hardexit = (status) => Deno.exit(status); + try { + Deno.addSignalListener("SIGINT", interrupt.SIGINT); + /** + * Windows only supports ctrl-c (SIGINT), ctrl-break (SIGBREAK), and ctrl-close (SIGUP) + */ + if (Deno.build.os !== "windows") { + Deno.addSignalListener("SIGTERM", interrupt.SIGTERM); + } + yield* body(Deno.args.slice()); + } finally { + Deno.removeSignalListener("SIGINT", interrupt.SIGINT); + if (Deno.build.os !== "windows") { + Deno.removeSignalListener("SIGTERM", interrupt.SIGTERM); + } } - } - }, - *node() { - // Annotate dynamic import so that webpack ignores it. - // See https://webpack.js.org/api/module-methods/#webpackignore - let { default: process } = yield* call(() => - import(/* webpackIgnore: true */ "node:process") - ); - hardexit = (status) => process.exit(status); - try { - process.on("SIGINT", interrupt.SIGINT); - if (process.platform !== "win32") { - process.on("SIGTERM", interrupt.SIGTERM); + }, + *node() { + // Annotate dynamic import so that webpack ignores it. + // See https://webpack.js.org/api/module-methods/#webpackignore + let { default: process } = yield* call(() => + import(/* webpackIgnore: true */ "node:process") + ); + hardexit = (status) => process.exit(status); + try { + process.on("SIGINT", interrupt.SIGINT); + if (process.platform !== "win32") { + process.on("SIGTERM", interrupt.SIGTERM); + } + yield* body(process.argv.slice(2)); + } finally { + process.off("SIGINT", interrupt.SIGINT); + if (process.platform !== "win32") { + process.off("SIGTERM", interrupt.SIGINT); + } } - yield* body(process.argv.slice(2)); - } finally { - process.off("SIGINT", interrupt.SIGINT); - if (process.platform !== "win32") { - process.off("SIGTERM", interrupt.SIGINT); + }, + *browser() { + try { + self.addEventListener("unload", interrupt.SIGINT); + yield* body([]); + } finally { + self.removeEventListener("unload", interrupt.SIGINT); } - } - }, - *browser() { - try { - self.addEventListener("unload", interrupt.SIGINT); - yield* body([]); - } finally { - self.removeEventListener("unload", interrupt.SIGINT); - } - }, - }); - - yield* exit(0); - } catch (error) { - yield* resolve({ status: 1, error: error as Error }); - } finally { - clearInterval(interval); + }, + }); + + yield* exit(0); + } catch (error) { + yield* resolve({ status: 1, error: error as Error }); + } finally { + clearInterval(interval); + } + }) + ); + + if (result.message) { + if (result.status === 0) { + console.log(result.message); + } else { + console.error(result.message); } - }) - ); - - if (result.message) { - if (result.status === 0) { - console.log(result.message); - } else { - console.error(result.message); } - } - if (result.error) { - console.error(result.error); - } + if (result.error) { + console.error(result.error); + } - hardexit(result.status); -} + hardexit(result.status); + }, +}, { at: "min" }); const ExitContext = createContext<(exit: Exit) => Operation>("exit"); From bc757a0ac6b74b85fa018e41d2e87afff677344d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 24 Feb 2026 20:41:48 -0500 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=A8=20Route=20reduction=20through=20a?= =?UTF-8?q?pi.Reducer=20middleware=20and=20export=20internals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add api.Reducer as a middleware interception point for effect reduction, enabling external packages (e.g., @effectionx/durably) to intercept instruction processing without forking Effection. Changes: - lib/api.ts: Define ReducerApi interface and api.Reducer with createApi - lib/coroutine.ts: Route reduce calls through reducerApi.invoke() instead of directly calling ReducerContext, using routine.scope for correct middleware chain resolution - lib/scope-internal.ts: Install stock Reducer as base-layer api.Reducer middleware on the global scope - experimental.ts: Export InstructionQueue, Instruction type, and DelimiterContext for custom reducer implementations - lib/callcc.ts, lib/delimiter.ts, lib/each.ts, lib/future.ts, lib/scope-internal.ts: Add description labels to withResolvers() calls so custom reducers can distinguish infrastructure effects from user-facing effects --- experimental.ts | 5 +++++ lib/api.ts | 11 +++++++++++ lib/callcc.ts | 2 +- lib/coroutine.ts | 22 +++++++++++----------- lib/delimiter.ts | 2 +- lib/each.ts | 4 ++-- lib/future.ts | 2 +- lib/reducer.ts | 4 ++-- lib/scope-internal.ts | 10 +++++++++- 9 files changed, 43 insertions(+), 19 deletions(-) diff --git a/experimental.ts b/experimental.ts index 7f7317fb3..9cb827705 100644 --- a/experimental.ts +++ b/experimental.ts @@ -1 +1,6 @@ export * from "./lib/api.ts"; +export { + InstructionQueue, + type Instruction, +} from "./lib/reducer.ts"; +export { DelimiterContext } from "./lib/delimiter.ts"; diff --git a/lib/api.ts b/lib/api.ts index 3c68b38b1..4523a4f50 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2,6 +2,7 @@ import type { Api, Context, Operation, Scope } from "./types.ts"; import { createApiInternal } from "./api-internal.ts"; import type { ScopeInternal } from "./scope-internal.ts"; +import type { Instruction } from "./reducer.ts"; export function createApi(name: string, core: A): Api { return createApiInternal(name, core); @@ -18,9 +19,14 @@ interface MainApi { main(body: (args: string[]) => Operation): Promise; } +interface ReducerApi { + reduce(instruction: Instruction): void; +} + interface Apis { Scope: Api; Main: Api; + Reducer: Api; } export const api: Apis = { @@ -41,4 +47,9 @@ export const api: Apis = { throw new TypeError(`missing handler for "main()"`); }, }), + Reducer: createApi("Reducer", { + reduce() { + throw new TypeError(`no handler for Reducer.reduce()`); + }, + }), }; diff --git a/lib/callcc.ts b/lib/callcc.ts index 5d969c95a..e5d8ff9f6 100644 --- a/lib/callcc.ts +++ b/lib/callcc.ts @@ -11,7 +11,7 @@ export function* callcc( reject: (error: Error) => Operation, ) => Operation, ): Operation { - let result = withResolvers>(); + let result = withResolvers>("await callcc"); let resolve = lift((value: T) => result.resolve(Ok(value))); diff --git a/lib/coroutine.ts b/lib/coroutine.ts index f0c6a1bfa..23a8758eb 100644 --- a/lib/coroutine.ts +++ b/lib/coroutine.ts @@ -1,9 +1,11 @@ +import { api as effection } from "./api.ts"; import { Priority } from "./contexts.ts"; import { DelimiterContext } from "./delimiter.ts"; -import { ReducerContext } from "./reducer.ts"; import { Ok } from "./result.ts"; import type { Coroutine, Operation, Scope } from "./types.ts"; +const reducerApi = effection.Reducer; + export interface CoroutineOptions { scope: Scope; operation(): Operation; @@ -12,8 +14,6 @@ export interface CoroutineOptions { export function createCoroutine( { operation, scope }: CoroutineOptions, ): Coroutine { - let reducer = scope.expect(ReducerContext); - let iterator: Coroutine["data"]["iterator"] | undefined = undefined; let routine = { @@ -31,25 +31,25 @@ export function createCoroutine( next(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); - reducer.reduce([ - scope.expect(Priority), + reducerApi.invoke(routine.scope, "reduce", [[ + routine.scope.expect(Priority), routine, exitResult.ok ? result : exitResult, - scope.expect(DelimiterContext).validator, + routine.scope.expect(DelimiterContext).validator, "next", - ]); + ]]); }); }, return(result) { routine.data.exit((exitResult) => { routine.data.exit = (didExit) => didExit(Ok()); - reducer.reduce([ - scope.expect(Priority), + reducerApi.invoke(routine.scope, "reduce", [[ + routine.scope.expect(Priority), routine, exitResult.ok ? result : exitResult, - scope.expect(DelimiterContext).validator, + routine.scope.expect(DelimiterContext).validator, "return", - ]); + ]]); }); }, } as Coroutine; diff --git a/lib/delimiter.ts b/lib/delimiter.ts index a262961e8..05b67ff92 100644 --- a/lib/delimiter.ts +++ b/lib/delimiter.ts @@ -10,7 +10,7 @@ export class Delimiter implements Operation>>, ErrorBoundary { level = 0; finalized = false; - future = withResolvers>>(); + future = withResolvers>>("await delimiter"); computed = false; routine?: Coroutine; outcome?: Maybe>; diff --git a/lib/each.ts b/lib/each.ts index 320c0088a..d1865c006 100644 --- a/lib/each.ts +++ b/lib/each.ts @@ -37,8 +37,8 @@ export function each(stream: Stream): Operation> { scope.set(EachStack, []); } - let done = withResolvers(); - let cxt = withResolvers>(); + let done = withResolvers("await each done"); + let cxt = withResolvers>("await each context"); yield* spawn(function* () { let subscription = yield* stream; diff --git a/lib/future.ts b/lib/future.ts index b61517651..fff2a300a 100644 --- a/lib/future.ts +++ b/lib/future.ts @@ -9,7 +9,7 @@ export interface FutureWithResolvers { } export function createFuture(): FutureWithResolvers { let promise = lazyPromiseWithResolvers(); - let operation = withResolvers(); + let operation = withResolvers("await future"); let resolve = (value: T) => { promise.resolve(value); diff --git a/lib/reducer.ts b/lib/reducer.ts index 45822a468..8e7d74dd1 100644 --- a/lib/reducer.ts +++ b/lib/reducer.ts @@ -58,7 +58,7 @@ export class Reducer { }; } -type Instruction = [ +export type Instruction = [ number, Coroutine, Result, @@ -66,7 +66,7 @@ type Instruction = [ "return" | "next", ]; -class InstructionQueue extends PriorityQueue { +export class InstructionQueue extends PriorityQueue { enqueue(instruction: Instruction): void { let [priority] = instruction; this.push(priority, instruction); diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index f781990dc..cc4ce0293 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,12 +1,14 @@ import type { ApiInternal } from "./api-internal.ts"; import { api as effection } from "./api.ts"; import { Children, Priority } from "./contexts.ts"; +import { Reducer } from "./reducer.ts"; import { Err, Ok, unbox } from "./result.ts"; import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; import { type WithResolvers, withResolvers } from "./with-resolvers.ts"; const api = effection.Scope; +const reducerApi = effection.Reducer; export function createScopeInternal( parent?: Scope, @@ -18,6 +20,12 @@ export function createScopeInternal( return buildScopeInternal(parent); }, }, { at: "min" }); + let defaultReducer = new Reducer(); + global.around(reducerApi, { + reduce([instruction]) { + defaultReducer.reduce(instruction); + }, + }, { at: "min" }); return [global, destroy] as const; } else { return api.invoke(parent, "create", [parent]) as [ @@ -133,7 +141,7 @@ export function buildScopeInternal( if (destruction) { return yield* destruction.operation; } - destruction = withResolvers(); + destruction = withResolvers("await destruction"); parent?.expect(Children).delete(scope); unbind(); let outcome = Ok();