From a819973f82d73ad8aa1be085f4e1bd4192a2f8d1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 10 Mar 2026 10:48:39 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20context-api?= =?UTF-8?q?=20to=20use=20@effectionx/middleware=20with=20min/max=20priorit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-rolled middleware composition in context-api with the shared @effectionx/middleware package. Internal state changes from a single composed function per field to immutable {max, min, composed} arrays that support priority ordering while preserving scope isolation. - Import Middleware type and combine() from @effectionx/middleware - Add { at: "min" | "max" } option to around() (defaults to "max") - Store per-field max/min arrays in context; recompute on each around() call - Add 5 new tests covering min/max ordering, scope isolation, implementation replacement, mixed insertion order, and default behavior - Rewrite README with Quick Start, Min/Max Priority, Instrumentation, Test Mocking, Scope Isolation, and API reference sections - Bump version to 0.4.0 (new feature, non-breaking) --- context-api/README.md | 235 ++++++++++++++++++++++++++------ context-api/context-api.test.ts | 178 +++++++++++++++++++++++- context-api/mod.ts | 119 ++++++++++------ context-api/package.json | 5 +- context-api/tsconfig.json | 3 + pnpm-lock.yaml | 4 + 6 files changed, 460 insertions(+), 84 deletions(-) diff --git a/context-api/README.md b/context-api/README.md index ffad1bef..23286a98 100644 --- a/context-api/README.md +++ b/context-api/README.md @@ -1,94 +1,251 @@ -# Context Apis +# Context APIs -Often called "Algebraic Effects" or "Contextual Effects", Context apis let you -access an operation via the context in a way that it can be easily (and -contextually) wrapped with middleware. +Algebraic effects pattern for context-dependent operations with middleware --- +Often called "Algebraic Effects" or "Contextual Effects", Context APIs let you +access an operation via the context in a way that it can be easily (and +contextually) wrapped with middleware. Middleware is powered by +[`@effectionx/middleware`](../middleware/README.md) and supports min/max priority +ordering. + +## Quick Start + Let's say that you want to define a log operation that behaves differently in -different context. The basic form will just log values to the console. +different contexts. The basic form will just log values to the console. ```ts -// file logging.ts import { createApi } from "@effectionx/context-api"; -// create the `logging` api. By default, it just logs to the console. -const logging = createApi( - "logging", - function* log(...values: unknown[]) { +const logging = createApi("logging", { + *log(...values: unknown[]) { console.log(...values); }, -); +}); -// export the logging operations. export const { log } = logging.operations; ``` -Now you can use the logging api wherever you want: +Now you can use the logging API wherever you want: ```ts import { log } from "./logging.ts"; export function* op() { - yield* log(`I am in an operation`); + yield* log("I am in an operation"); } ``` -However, use can use the `around` function to wrap middleware around your -logging operation. This lets you do stuff like silence logging, or even to -re-route it somewhere else than from the `console` completely. +## Wrapping with Middleware + +Use the `around` function to wrap middleware around your operations. This lets +you intercept calls, transform arguments, modify return values, or replace +the implementation entirely. ```ts import { logging } from "./logging.ts"; -function* initCustomLogging(externallogger) { +function* initCustomLogging(externalLogger) { yield* logging.around({ - *log(...values, next) { + *log([...values], next) { externalLogger.log(...values); - // since we override the logger entirely, we do not invoke next. + // since we override the logger entirely, we do not invoke next }, }); } ``` -The best part is that the middleware is only in effect inside the scope in which -it is installed. +Middleware is only in effect inside the scope in which it is installed — when +the scope exits, the middleware is removed. + +## Min/Max Priority + +By default, `around()` registers middleware at `"max"` priority (outermost, +closest to the caller). You can also register at `"min"` priority (innermost, +closest to the core handler) by passing an options argument: + +```ts +import { createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +const files = createApi("files", { + *readFile(path: string): Operation { + throw new Error(`readFile("${path}") is not implemented`); + }, +}); + +export const { readFile } = files.operations; +``` + +In your runtime setup, provide the implementation via `min`: + +```ts +import { files } from "./files.ts"; + +function* initNodeRuntime() { + yield* files.around( + { + *readFile([path], _next) { + return yield* nodeReadFile(path); + }, + }, + { at: "min" }, + ); +} +``` + +`max` middlewares wrap the outside as usual — they don't care which `min` is +providing the actual implementation: + +```ts +import { files } from "./files.ts"; + +function* withLogging() { + yield* files.around({ + *readFile([path], next) { + console.log(`reading ${path}`); + return yield* next(path); + }, + }); +} +``` + +In tests, swap the implementation by registering a different `min`: + +```ts +function* useTestFixtures(fixtures: Map) { + yield* files.around( + { + *readFile([path], _next) { + return fixtures.get(path) ?? ""; + }, + }, + { at: "min" }, + ); +} +``` + +The execution order with max middlewares `[M1, M2]` and min middlewares +`[m1, m2]` is: + +```text +M1 → M2 → m1 → m2 → core +``` + +## Instrumentation -Middleware can be useful for automatic instrumentation. For example, let's -assume that `fetch` was a an api called `fetching`: +Middleware can be useful for automatic instrumentation: ```ts -import { fetch, fetching } from "./fetching.ts"; +import { fetching } from "./fetching.ts"; function* instrumentFetch(tracer) { yield* fetching.around({ - *fetch(...args, next) { - try { - tracer.begin("fetch", args), - return yield* next(...args); - } finally { - tracer.end("fetch", args); - } - } - }) + *fetch(args, next) { + try { + tracer.begin("fetch", args); + return yield* next(...args); + } finally { + tracer.end("fetch", args); + } + }, + }); } ``` -or mocking inside test cases: +## Test Mocking + +Mock operations in test cases without changing the call site: ```ts -import { fetch, fetching } from "./fetching.ts"; +import { fetching } from "./fetching.ts"; function* useMocks() { yield* fetching.around({ - *fetch(...args, next) { - if (args[0] === "/my-path") { + *fetch([url, ...rest], next) { + if (url === "/my-path") { return new MockResponse("my-path"); } else { - return yield* next(...args); + return yield* next(url, ...rest); } }, }); } ``` + +## Scope Isolation + +Middleware installed in a child scope does not affect the parent: + +```ts +import { scoped } from "effection"; + +yield* scoped(function* () { + yield* logging.around({ + *log([...values], next) { + // only active inside this scope + return yield* next(...values); + }, + }); + yield* log("intercepted"); // middleware runs +}); + +yield* log("not intercepted"); // middleware does not run +``` + +## API + +### `createApi(name, handler)` + +Create a context API from a name and an object of handler functions or +operations. Returns an object with `operations` and `around`. + +```ts +import { createApi } from "@effectionx/context-api"; + +const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, +}); + +const { add } = math.operations; +const result = yield* add(1, 2); // => 3 +``` + +### `around(middlewares, options?)` + +Register middleware around one or more operations. The second argument controls +priority: + +- **`{ at: "max" }`** (default) — outermost, closest to the caller +- **`{ at: "min" }`** — innermost, closest to the core handler + +```ts +// Wrapping middleware (max, default) +yield* math.around({ + *add(args, next) { + console.log("adding", args); + return yield* next(...args); + }, +}); + +// Implementation middleware (min) +yield* math.around( + { + *add([left, right], _next) { + return left * right; // replace the core implementation + }, + }, + { at: "min" }, +); +``` + +Each middleware receives the arguments as a tuple and a `next` function to +delegate to the next middleware (or the core handler). A middleware can: + +- **Pass through**: call `next(...args)` and return its result +- **Transform arguments**: call `next()` with different arguments +- **Transform the return value**: modify what `next()` returns +- **Short-circuit**: return a value without calling `next()` at all diff --git a/context-api/context-api.test.ts b/context-api/context-api.test.ts index 204b04ac..444fcad7 100644 --- a/context-api/context-api.test.ts +++ b/context-api/context-api.test.ts @@ -1,7 +1,7 @@ -import { expect } from "expect"; -import { type Operation, scoped } from "effection"; import { describe, it } from "@effectionx/bdd"; import { createApi } from "@effectionx/context-api"; +import { type Operation, scoped } from "effection"; +import { expect } from "expect"; describe("context api", () => { it("can invoke a handler from anywhere", function* () { @@ -129,4 +129,178 @@ describe("context api", () => { expect(yield* math.operations.add(5, 15)).toEqual(150); }); + + it("places min middleware closest to the core", function* () { + const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, + }); + + const log: string[] = []; + + // max middleware wraps outermost + yield* math.around({ + *add(args, next) { + log.push("max"); + return yield* next(...args); + }, + }); + + // min middleware runs just before core + yield* math.around( + { + *add(args, next) { + log.push("min"); + return yield* next(...args); + }, + }, + { at: "min" }, + ); + + expect(yield* math.operations.add(1, 2)).toEqual(3); + expect(log).toEqual(["max", "min"]); + }); + + it("min middleware can provide an implementation that replaces the core", function* () { + const math = createApi("math", { + *add(_left: number, _right: number): Operation { + throw new Error("not implemented"); + }, + }); + + // Provide the real implementation via min + yield* math.around( + { + *add([left, right], _next) { + return left * right; // multiply instead of add + }, + }, + { at: "min" }, + ); + + // Wrapping middleware at max still works + yield* math.around({ + *add(args, next) { + return 1 + (yield* next(...args)); + }, + }); + + expect(yield* math.operations.add(3, 4)).toEqual(13); // 1 + (3 * 4) + }); + + it("preserves min/max ordering regardless of insertion order", function* () { + const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, + }); + + const log: string[] = []; + + // Register in mixed order: max, min, max, min + yield* math.around({ + *add(args, next) { + log.push("max-1"); + return yield* next(...args); + }, + }); + + yield* math.around( + { + *add(args, next) { + log.push("min-1"); + return yield* next(...args); + }, + }, + { at: "min" }, + ); + + yield* math.around({ + *add(args, next) { + log.push("max-2"); + return yield* next(...args); + }, + }); + + yield* math.around( + { + *add(args, next) { + log.push("min-2"); + return yield* next(...args); + }, + }, + { at: "min" }, + ); + + yield* math.operations.add(1, 1); + expect(log).toEqual(["max-1", "max-2", "min-1", "min-2"]); + }); + + it("min/max middleware respects scope isolation", function* () { + const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, + }); + + const log: string[] = []; + + // Install max middleware in parent scope + yield* math.around({ + *add(args, next) { + log.push("parent-max"); + return yield* next(...args); + }, + }); + + yield* scoped(function* () { + // Child scope adds its own max middleware + yield* math.around({ + *add(args, next) { + log.push("child-max"); + return yield* next(...args); + }, + }); + yield* math.operations.add(1, 1); + expect(log).toEqual(["parent-max", "child-max"]); + }); + + // Parent scope doesn't see child's middleware + log.length = 0; + yield* math.operations.add(1, 1); + expect(log).toEqual(["parent-max"]); + }); + + it("defaults to max when no option is provided", function* () { + const math = createApi("math", { + *add(left: number, right: number): Operation { + return left + right; + }, + }); + + const log: string[] = []; + + // No option — should be max (outermost) + yield* math.around({ + *add(args, next) { + log.push("default"); + return yield* next(...args); + }, + }); + + // Explicit min + yield* math.around( + { + *add(args, next) { + log.push("min"); + return yield* next(...args); + }, + }, + { at: "min" }, + ); + + yield* math.operations.add(1, 1); + expect(log).toEqual(["default", "min"]); + }); }); diff --git a/context-api/mod.ts b/context-api/mod.ts index d9a4284a..bc9c1633 100644 --- a/context-api/mod.ts +++ b/context-api/mod.ts @@ -1,5 +1,8 @@ +import { type Middleware, combine } from "@effectionx/middleware"; import { type Operation, createContext } from "effection"; +export type { Middleware }; + export type Around = { [K in keyof Operations]: A[K] extends ( ...args: infer TArgs @@ -8,14 +11,12 @@ export type Around = { : Middleware<[], A[K]>; }; -export type Middleware = ( - args: TArgs, - next: (...args: TArgs) => TReturn, -) => TReturn; - export interface Api { operations: Operations; - around: (around: Partial>) => Operation; + around: ( + around: Partial>, + options?: { at: "min" | "max" }, + ) => Operation; } export type Operations = { @@ -26,42 +27,64 @@ export type Operations = { : never; }; +/** + * Internal per-field state: two immutable arrays for priority ordering + * plus a pre-composed middleware function. + */ +type FieldState = { + // biome-ignore lint/suspicious/noExplicitAny: Middleware arrays store heterogeneous field types + max: Middleware[]; + // biome-ignore lint/suspicious/noExplicitAny: Middleware arrays store heterogeneous field types + min: Middleware[]; + // biome-ignore lint/suspicious/noExplicitAny: Pre-composed middleware for dynamic dispatch + composed: Middleware; +}; + +/** + * The context stores a FieldState for each field in the API. + */ +type ContextState = Record, FieldState>; + export function createApi(name: string, handler: A): Api { - let fields = Object.keys(handler) as (keyof A)[]; + let fields = Object.keys(handler) as (keyof A & string)[]; - let middleware: Around = fields.reduce( + let initial = fields.reduce( (sum, field) => { return Object.assign(sum, { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware composition - [field]: (args: any, next: any) => next(...args), + [field]: { + max: [], + min: [], + // biome-ignore lint/suspicious/noExplicitAny: Passthrough middleware for initial state + composed: (args: any, next: any) => next(...args), + } satisfies FieldState, }); }, - {} as Around, + {} as ContextState, ); - let context = createContext>(`$api:${name}`, middleware); + let context = createContext>(`$api:${name}`, initial); let operations = fields.reduce( (api, field) => { let handle = handler[field]; if (typeof handle === "function") { + // biome-ignore lint/suspicious/noExplicitAny: Handler is dynamically typed per field + let fn = handle as (...args: any[]) => any; return Object.assign(api, { // biome-ignore lint/suspicious/noExplicitAny: Dynamic field types [field]: function* (...args: any[]) { - let around = yield* context.expect(); - // biome-ignore lint/complexity/noBannedTypes: Dynamic middleware call - let middleware = around[field] as Function; - return yield* middleware(args, handle); + let state = yield* context.expect(); + let { composed } = state[field as keyof Operations]; + return yield* composed(args, fn); }, }); } return Object.assign(api, { [field]: { *[Symbol.iterator]() { - let around = yield* context.expect(); - // biome-ignore lint/complexity/noBannedTypes: Dynamic middleware call - let middleware = around[field] as Function; - return yield* middleware([], () => handle); + let state = yield* context.expect(); + let { composed } = state[field as keyof Operations]; + return yield* composed([], () => handle); }, }, }); @@ -69,33 +92,45 @@ export function createApi(name: string, handler: A): Api { {} as Operations, ); - function* around(around: Partial>): Operation { + function* around( + middlewares: Partial>, + options: { at: "min" | "max" } = { at: "max" }, + ): Operation { let current = yield* context.expect(); - yield* context.set( - fields.reduce( - (sum, field) => { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types - let prior = current[field] as Middleware; - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types - let middleware = around[field] as Middleware; + + let next = fields.reduce( + (sum, field) => { + // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types across fields + let middleware = (middlewares as any)[field] as + // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types across fields + Middleware | undefined; + let fieldState = current[field as keyof Operations]; + + if (middleware) { + // Clone arrays — never mutate in place (scope isolation) + let max = [...fieldState.max]; + let min = [...fieldState.min]; + + if (options.at === "min") { + min = [...min, middleware]; + } else { + max = [...max, middleware]; + } + + let composed = combine([...max, ...min]); + return Object.assign(sum, { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware composition - [field]: (args: any, next: any) => - middleware(args, (...args) => prior(args, next)), + [field]: { max, min, composed }, }); - }, - Object.assign({}, current), - ), + } + + return Object.assign(sum, { [field]: fieldState }); + }, + {} as ContextState, ); + + yield* context.set(next); } return { operations, around }; } - -type A = Around<{ - add: (left: number) => Operation; -}>; - -type O = Operations<{ - add: (left: number) => Operation; -}>; diff --git a/context-api/package.json b/context-api/package.json index d0c49d98..dbb1dba4 100644 --- a/context-api/package.json +++ b/context-api/package.json @@ -1,7 +1,7 @@ { "name": "@effectionx/context-api", "description": "Algebraic effects pattern for context-dependent operations with middleware", - "version": "0.3.2", + "version": "0.4.0", "keywords": [ "effection", "effectionx", @@ -21,6 +21,9 @@ "default": "./dist/mod.js" } }, + "dependencies": { + "@effectionx/middleware": "workspace:*" + }, "peerDependencies": { "effection": "^3 || ^4" }, diff --git a/context-api/tsconfig.json b/context-api/tsconfig.json index 20877bcf..6bc1f947 100644 --- a/context-api/tsconfig.json +++ b/context-api/tsconfig.json @@ -9,6 +9,9 @@ "references": [ { "path": "../bdd" + }, + { + "path": "../middleware" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bdbce8e..b8045bfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,10 @@ importers: version: 4.0.0 context-api: + dependencies: + '@effectionx/middleware': + specifier: workspace:* + version: link:../middleware devDependencies: '@effectionx/bdd': specifier: workspace:* From 50933b24d561ad84f194742598a83695f8543da5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 11 Mar 2026 13:46:29 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Address=20review=20fee?= =?UTF-8?q?dback:=20clean=20up=20noExplicitAny,=20rename=20types,=20remove?= =?UTF-8?q?=20passthrough=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disable noExplicitAny for context-api/mod.ts via biome.json override instead of 7 scattered inline biome-ignore comments - Rename FieldState → FieldMiddleware, ContextState → MiddlewareRegistry for clarity - Skip middleware wrapping when no middleware is registered — call handlers directly instead of going through an identity passthrough --- biome.json | 12 ++++++++++++ context-api/mod.ts | 37 ++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/biome.json b/biome.json index 1d89d0ee..dcafd23b 100644 --- a/biome.json +++ b/biome.json @@ -31,6 +31,18 @@ "indentStyle": "space", "indentWidth": 2 }, + "overrides": [ + { + "include": ["context-api/mod.ts"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ], "files": { "ignore": ["**/dist", "**/node_modules", "**/build"] } diff --git a/context-api/mod.ts b/context-api/mod.ts index bc9c1633..b9ae9260 100644 --- a/context-api/mod.ts +++ b/context-api/mod.ts @@ -28,22 +28,19 @@ export type Operations = { }; /** - * Internal per-field state: two immutable arrays for priority ordering + * Per-field middleware layers: two immutable arrays for priority ordering * plus a pre-composed middleware function. */ -type FieldState = { - // biome-ignore lint/suspicious/noExplicitAny: Middleware arrays store heterogeneous field types +type FieldMiddleware = { max: Middleware[]; - // biome-ignore lint/suspicious/noExplicitAny: Middleware arrays store heterogeneous field types min: Middleware[]; - // biome-ignore lint/suspicious/noExplicitAny: Pre-composed middleware for dynamic dispatch - composed: Middleware; + composed: Middleware | undefined; }; /** - * The context stores a FieldState for each field in the API. + * Maps each API field to its middleware layers. */ -type ContextState = Record, FieldState>; +type MiddlewareRegistry = Record, FieldMiddleware>; export function createApi(name: string, handler: A): Api { let fields = Object.keys(handler) as (keyof A & string)[]; @@ -54,28 +51,25 @@ export function createApi(name: string, handler: A): Api { [field]: { max: [], min: [], - // biome-ignore lint/suspicious/noExplicitAny: Passthrough middleware for initial state - composed: (args: any, next: any) => next(...args), - } satisfies FieldState, + composed: undefined, + } satisfies FieldMiddleware, }); }, - {} as ContextState, + {} as MiddlewareRegistry, ); - let context = createContext>(`$api:${name}`, initial); + let context = createContext>(`$api:${name}`, initial); let operations = fields.reduce( (api, field) => { let handle = handler[field]; if (typeof handle === "function") { - // biome-ignore lint/suspicious/noExplicitAny: Handler is dynamically typed per field let fn = handle as (...args: any[]) => any; return Object.assign(api, { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic field types [field]: function* (...args: any[]) { let state = yield* context.expect(); let { composed } = state[field as keyof Operations]; - return yield* composed(args, fn); + return yield* composed ? composed(args, fn) : fn(...args); }, }); } @@ -84,7 +78,9 @@ export function createApi(name: string, handler: A): Api { *[Symbol.iterator]() { let state = yield* context.expect(); let { composed } = state[field as keyof Operations]; - return yield* composed([], () => handle); + return composed + ? yield* composed([], () => handle) + : yield* handle as Operation; }, }, }); @@ -100,10 +96,9 @@ export function createApi(name: string, handler: A): Api { let next = fields.reduce( (sum, field) => { - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types across fields let middleware = (middlewares as any)[field] as - // biome-ignore lint/suspicious/noExplicitAny: Dynamic middleware types across fields - Middleware | undefined; + | Middleware + | undefined; let fieldState = current[field as keyof Operations]; if (middleware) { @@ -126,7 +121,7 @@ export function createApi(name: string, handler: A): Api { return Object.assign(sum, { [field]: fieldState }); }, - {} as ContextState, + {} as MiddlewareRegistry, ); yield* context.set(next); From cd8fdec2e1cde6af3cb665083b1888c7e804b688 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 11 Mar 2026 14:17:47 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20type=20sign?= =?UTF-8?q?atures:=20use=20keyof=20A=20instead=20of=20keyof=20Operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- context-api/mod.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/context-api/mod.ts b/context-api/mod.ts index b9ae9260..67fe7136 100644 --- a/context-api/mod.ts +++ b/context-api/mod.ts @@ -40,10 +40,10 @@ type FieldMiddleware = { /** * Maps each API field to its middleware layers. */ -type MiddlewareRegistry = Record, FieldMiddleware>; +type MiddlewareRegistry = Record; export function createApi(name: string, handler: A): Api { - let fields = Object.keys(handler) as (keyof A & string)[]; + let fields = Object.keys(handler) as (keyof A)[]; let initial = fields.reduce( (sum, field) => { @@ -68,7 +68,7 @@ export function createApi(name: string, handler: A): Api { return Object.assign(api, { [field]: function* (...args: any[]) { let state = yield* context.expect(); - let { composed } = state[field as keyof Operations]; + let { composed } = state[field as keyof A]; return yield* composed ? composed(args, fn) : fn(...args); }, }); @@ -77,7 +77,7 @@ export function createApi(name: string, handler: A): Api { [field]: { *[Symbol.iterator]() { let state = yield* context.expect(); - let { composed } = state[field as keyof Operations]; + let { composed } = state[field as keyof A]; return composed ? yield* composed([], () => handle) : yield* handle as Operation; @@ -99,7 +99,7 @@ export function createApi(name: string, handler: A): Api { let middleware = (middlewares as any)[field] as | Middleware | undefined; - let fieldState = current[field as keyof Operations]; + let fieldState = current[field as keyof A]; if (middleware) { // Clone arrays — never mutate in place (scope isolation)