diff --git a/context-api/README.md b/context-api/README.md index 14ec4e58..4b56bdcc 100644 --- a/context-api/README.md +++ b/context-api/README.md @@ -1,14 +1,17 @@ # Context APIs -Algebraic effects pattern for context-dependent operations with middleware +Algebraic effects pattern for context-dependent operations with ordered middleware groups --- 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. +[`@effectionx/middleware`](../middleware/README.md) and is organized into +ordered groups — each API declares the lanes it needs, and middleware within a +group follows an `append` or `prepend` rule. The default configuration provides +`max`/`min` lanes, matching the common outer-wrapper / inner-implementation +split. ## Quick Start @@ -61,8 +64,17 @@ 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, +By default, `createApi()` configures two middleware groups: + +```ts +[ + { name: "max", mode: "append" }, + { name: "min", mode: "prepend" }, +] +``` + +`around()` registers into `"max"` priority (outermost, closest to the caller) +when no `at` is passed. You can also register at `"min"` priority (innermost, closest to the core handler) by passing an options argument: ```ts @@ -133,6 +145,51 @@ The execution order with max middlewares `[M1, M2]` and min middlewares M1 → M2 → m1 → m2 → core ``` +## Custom groups + +The two-lane default is the right shape for most APIs, but an API can declare +its own ordered list of middleware groups when more than two structural lanes +are needed. Each group has a `name` and a `mode`: + +- **`"append"`** — earlier registrations run outer. Matches the default + `"max"` behavior. Across scopes: parent-outer / child-inner. +- **`"prepend"`** — later registrations run outer. Matches the default + `"min"` behavior. Across scopes: child-outer / parent-inner. + +For example, a replay system may need a third lane that sits structurally +between general wrappers and core-providing middleware: + +```ts +const effects = createApi("effects", handler, { + groups: [ + { name: "max", mode: "append" }, + { name: "replay", mode: "append" }, + { name: "min", mode: "prepend" }, + ] as const, +}); + +yield* effects.around(loggingAndOtherWrappers, { at: "max" }); +yield* effects.around(replayRestore, { at: "replay" }); +yield* effects.around(defaultImplementations, { at: "min" }); +yield* effects.around(dispatchOverrides, { at: "min" }); +``` + +Execution order follows the declared group order, group by group, then the +core handler: + +```text +max → replay → min → core +``` + +Passing `groups: [...] as const` lets TypeScript infer the literal union of +group names, so `around(..., { at })` is type-checked against the declared +set. `createApi()` throws at call time if `groups` is empty or has duplicate +names; `around()` throws at runtime if `at` names a group that was not +declared. + +Default `at` is the first declared group. For the built-in configuration that +is `"max"`, so existing callers keep their behavior. + ## Instrumentation Middleware can be useful for automatic instrumentation: @@ -199,11 +256,16 @@ function* example() { ## API -### `createApi(name, handler)` +### `createApi(name, handler, options?)` Create a context API from a name and an object of handler functions or operations. Returns an object with `operations` and `around`. +The optional `options.groups` argument declares the middleware lanes this API +exposes. Each group has a `name` and a `mode` of `"append"` or `"prepend"`. +When omitted, it defaults to +`[{ name: "max", mode: "append" }, { name: "min", mode: "prepend" }]`. + ```ts import { createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; @@ -223,12 +285,18 @@ function* example(): Operation { ### `around(middlewares, options?)` -Register middleware around one or more operations. The second argument controls -priority: +Register middleware around one or more operations. The second argument chooses +which declared group to register into. + +For the default configuration: - **`{ at: "max" }`** (default) — outermost, closest to the caller - **`{ at: "min" }`** — innermost, closest to the core handler +For APIs that declare custom groups, `at` accepts any declared group name and +defaults to the first declared group. Passing an unknown name throws at +runtime. + ```ts function* example() { // Wrapping middleware (max, default) diff --git a/context-api/mod.ts b/context-api/mod.ts index 5b3bab5d..043ac746 100644 --- a/context-api/mod.ts +++ b/context-api/mod.ts @@ -26,11 +26,56 @@ export type Around = { [K in keyof A]: PropertyMiddleware; }; -export interface Api { +/** + * How repeated registrations inside a group are ordered. + * + * - `"append"` — earlier registrations run outer (like the default `"max"` lane). + * Across scopes, this means parent-outer / child-inner. + * - `"prepend"` — later registrations run outer (like the default `"min"` lane). + * Across scopes, this means child-outer / parent-inner. + */ +export type GroupMode = "append" | "prepend"; + +/** + * A user-declared middleware group for a {@link createApi} instance. + * + * `mode` defaults to `"append"` when omitted. + */ +export type MiddlewareGroup = { + name: Name; + mode?: GroupMode; +}; + +/** + * Options accepted by {@link createApi}. + * + * `groups` defaults to the backward-compatible two-lane configuration: + * `[{ name: "max", mode: "append" }, { name: "min", mode: "prepend" }]`. + * + * Pass `groups: [...] as const` so TypeScript infers the literal union of + * group names into the `Group` type parameter. Without `as const`, names + * widen to `string` and `around({ at })` loses its typed group check. + * + * @example + * ```ts + * const api = createApi("effects", handler, { + * groups: [ + * { name: "max", mode: "append" }, + * { name: "replay", mode: "append" }, + * { name: "min", mode: "prepend" }, + * ] as const, + * }); + * ``` + */ +export type CreateApiOptions = { + groups?: readonly MiddlewareGroup[]; +}; + +export interface Api { operations: Operations; around: ( around: Partial>, - options?: { at: "min" | "max" }, + options?: { at: Group }, ) => Operation; } @@ -55,22 +100,83 @@ export type Operations = { : Operation; }; -type ScopeMiddleware = { - max: Partial>[]; - min: Partial>[]; +type GroupDefinition = { + name: Group; + mode: GroupMode; }; -type MiddlewareStack = { - max: Middleware[]; - min: Middleware[]; -}; +type ScopeMiddleware = Record< + Group, + Partial>[] +>; + +const DEFAULT_GROUPS: readonly MiddlewareGroup<"max" | "min">[] = [ + { name: "max", mode: "append" }, + { name: "min", mode: "prepend" }, +]; -export function createApi(name: string, handler: A): Api { +export function createApi( + name: string, + handler: A, + options?: CreateApiOptions, +): Api { let fields = Object.keys(handler) as (keyof A)[]; - let context = createContext>(`$api:${name}`, { - max: [], - min: [], - }); + + let rawGroups = (options?.groups ?? + DEFAULT_GROUPS) as readonly MiddlewareGroup[]; + + if (rawGroups.length === 0) { + throw new Error(`context-api "${name}": \`groups\` must not be empty`); + } + + let seen = new Set(); + let duplicates: string[] = []; + let groups: readonly GroupDefinition[] = Object.freeze( + rawGroups.map((g) => { + if (seen.has(g.name)) { + duplicates.push(g.name); + } + seen.add(g.name); + return Object.freeze({ + name: g.name, + mode: g.mode ?? "append", + }) as GroupDefinition; + }), + ); + + if (duplicates.length > 0) { + let unique = Array.from(new Set(duplicates)); + throw new Error( + `context-api "${name}": duplicate group name${unique.length === 1 ? "" : "s"}: ${unique.join(", ")}`, + ); + } + + let groupByName = new Map>( + groups.map((g) => [g.name, g]), + ); + + function emptyState(): ScopeMiddleware { + let state = {} as ScopeMiddleware; + for (let g of groups) { + state[g.name] = []; + } + return state; + } + + function cloneState( + current: ScopeMiddleware, + ): ScopeMiddleware { + let next = {} as ScopeMiddleware; + for (let g of groups) { + next[g.name] = [...(current[g.name] ?? [])]; + } + return next; + } + + let context = createContext>( + `$api:${name}`, + emptyState(), + ); let operations = fields.reduce( (api, field) => { @@ -81,8 +187,13 @@ export function createApi(name: string, handler: A): Api { [field]: (...args: any[]) => ({ *[Symbol.iterator]() { let scope = yield* useScope(); - let { max, min } = collectMiddleware(scope, context, field); - let stack = combine([...max, ...min]); + let middlewares = collectMiddleware( + scope, + context, + field, + groups, + ); + let stack = combine(middlewares); let result = stack(args, fn); return isOperation(result) ? yield* result : result; }, @@ -93,8 +204,8 @@ export function createApi(name: string, handler: A): Api { [field]: { *[Symbol.iterator]() { let scope = yield* useScope(); - let { max, min } = collectMiddleware(scope, context, field); - let stack = combine([...max, ...min]); + let middlewares = collectMiddleware(scope, context, field, groups); + let stack = combine(middlewares); let result = stack([], () => handle); return isOperation(result) ? yield* result : result; }, @@ -106,27 +217,31 @@ export function createApi(name: string, handler: A): Api { function* around( middlewares: Partial>, - options: { at: "min" | "max" } = { at: "max" }, + options?: { at: Group }, ): Operation { let hasAny = fields.some((field) => Boolean((middlewares as any)[field])); if (!hasAny) { return; } + let at = options?.at ?? groups[0].name; + let group = groupByName.get(at); + if (!group) { + let known = Array.from(groupByName.keys()).join(", "); + throw new Error( + `context-api "${name}": unknown group "${at}". Known groups: ${known}`, + ); + } + let scope = yield* useScope(); - let current = scope.hasOwn(context) - ? scope.expect(context) - : { max: [], min: [] }; + let current = scope.hasOwn(context) ? scope.expect(context) : emptyState(); - let next: ScopeMiddleware = { - max: [...current.max], - min: [...current.min], - }; + let next = cloneState(current); - if (options.at === "min") { - next.min = [middlewares, ...next.min]; + if (group.mode === "prepend") { + next[group.name] = [middlewares, ...next[group.name]]; } else { - next.max.push(middlewares); + next[group.name] = [...next[group.name], middlewares]; } scope.set(context, next); @@ -135,42 +250,59 @@ export function createApi(name: string, handler: A): Api { return { operations, around }; } -function collectMiddleware( +function collectMiddleware( scope: Scope, context: { name?: string; key?: string }, field: keyof A, -): MiddlewareStack { + groups: readonly GroupDefinition[], +): Middleware[] { let key = contextName(context); let window = contextWindow(scope); - return reducePrototypeChain( + let lanes: Record[]> = {}; + for (let g of groups) { + lanes[g.name] = []; + } + + reducePrototypeChain( window, - (sum, current) => { + (_, current) => { if (!Object.prototype.hasOwnProperty.call(current, key)) { - return sum; + return null; } - let state = current[key] as ScopeMiddleware; + let state = current[key] as ScopeMiddleware; - let max = state.max.flatMap((around) => { - let middleware = (around as any)[field] as - | Middleware - | undefined; - return middleware ? [middleware] : []; - }); - let min = state.min.flatMap((around) => { - let middleware = (around as any)[field] as - | Middleware - | undefined; - return middleware ? [middleware] : []; - }); + for (let g of groups) { + let fromScope = (state[g.name] ?? []).flatMap((around) => { + let middleware = (around as any)[field] as + | Middleware + | undefined; + return middleware ? [middleware] : []; + }); + if (g.mode === "append") { + // parent outer / child inner — walking child→parent, + // parent scopes are encountered last so unshift accumulates + // in parent-outer-first order. + lanes[g.name].unshift(...fromScope); + } else { + // child outer / parent inner — walking child→parent, child + // scopes are encountered first so push accumulates in + // child-outer-first order. + lanes[g.name].push(...fromScope); + } + } - sum.max.unshift(...max); - sum.min.push(...min); - return sum; + return null; }, - { max: [], min: [] } as MiddlewareStack, + null, ); + + let out: Middleware[] = []; + for (let g of groups) { + out.push(...lanes[g.name]); + } + return out; } function reducePrototypeChain( diff --git a/context-api/package.json b/context-api/package.json index 286338da..96b2219b 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.6.0", + "version": "0.7.0", "keywords": ["concurrency", "interop"], "type": "module", "main": "./dist/mod.js", diff --git a/context-api/scope-middleware.test.ts b/context-api/scope-middleware.test.ts index 35855d7a..287809eb 100644 --- a/context-api/scope-middleware.test.ts +++ b/context-api/scope-middleware.test.ts @@ -895,4 +895,722 @@ describe("scope middleware", () => { expect(yield* api.operations.syncFn()).toEqual(20); }); }); + + describe("custom groups", () => { + const THREE_LANE = [ + { name: "max", mode: "append" }, + { name: "replay", mode: "append" }, + { name: "min", mode: "prepend" }, + ] as const; + + it("executes middleware in declared group order", function* () { + const api = createApi( + "groups.declared-order", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + yield* api.around( + { + *value(args, next) { + log.push("max:enter"); + const result = yield* next(...args); + log.push("max:exit"); + return result; + }, + }, + { at: "max" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("replay:enter"); + const result = yield* next(...args); + log.push("replay:exit"); + return result; + }, + }, + { at: "replay" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("min:enter"); + const result = yield* next(...args); + log.push("min:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.operations.value(); + expect(log).toEqual([ + "max:enter", + "replay:enter", + "min:enter", + "min:exit", + "replay:exit", + "max:exit", + ]); + }); + + it("append preserves install order within a lane", function* () { + const api = createApi( + "groups.append-order", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + yield* api.around( + { + *value(args, next) { + log.push("max-a:enter"); + const result = yield* next(...args); + log.push("max-a:exit"); + return result; + }, + }, + { at: "max" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("max-b:enter"); + const result = yield* next(...args); + log.push("max-b:exit"); + return result; + }, + }, + { at: "max" }, + ); + + yield* api.operations.value(); + expect(log).toEqual([ + "max-a:enter", + "max-b:enter", + "max-b:exit", + "max-a:exit", + ]); + }); + + it("prepend reverses install order within a lane", function* () { + const api = createApi( + "groups.prepend-order", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + yield* api.around( + { + *value(args, next) { + log.push("min-a:enter"); + const result = yield* next(...args); + log.push("min-a:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("min-b:enter"); + const result = yield* next(...args); + log.push("min-b:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.operations.value(); + expect(log).toEqual([ + "min-b:enter", + "min-a:enter", + "min-a:exit", + "min-b:exit", + ]); + }); + + it("middle lane stays between outer and inner regardless of install order", function* () { + const api = createApi( + "groups.middle-stable", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + // Install in mixed order: min, max, replay, min + yield* api.around( + { + *value(args, next) { + log.push("min-1:enter"); + const result = yield* next(...args); + log.push("min-1:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("max-1:enter"); + const result = yield* next(...args); + log.push("max-1:exit"); + return result; + }, + }, + { at: "max" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("replay-1:enter"); + const result = yield* next(...args); + log.push("replay-1:exit"); + return result; + }, + }, + { at: "replay" }, + ); + + yield* api.around( + { + *value(args, next) { + log.push("min-2:enter"); + const result = yield* next(...args); + log.push("min-2:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.operations.value(); + // max → replay → min (with min prepend so min-2 before min-1) + expect(log).toEqual([ + "max-1:enter", + "replay-1:enter", + "min-2:enter", + "min-1:enter", + "min-1:exit", + "min-2:exit", + "replay-1:exit", + "max-1:exit", + ]); + }); + + it("child extends parent per-group across all lanes", function* () { + const api = createApi( + "groups.child-extends-parent", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + yield* api.around( + { + *value(args, next) { + log.push("max-a:enter"); + const result = yield* next(...args); + log.push("max-a:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-a:enter"); + const result = yield* next(...args); + log.push("replay-a:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-a:enter"); + const result = yield* next(...args); + log.push("min-a:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* scoped(function* () { + yield* api.around( + { + *value(args, next) { + log.push("max-b:enter"); + const result = yield* next(...args); + log.push("max-b:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-b:enter"); + const result = yield* next(...args); + log.push("replay-b:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-b:enter"); + const result = yield* next(...args); + log.push("min-b:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.operations.value(); + }); + + // append lanes: parent outer / child inner (a outer, b inner) + // prepend lane: child outer / parent inner (b outer, a inner) + expect(log).toEqual([ + "max-a:enter", + "max-b:enter", + "replay-a:enter", + "replay-b:enter", + "min-b:enter", + "min-a:enter", + "min-a:exit", + "min-b:exit", + "replay-b:exit", + "replay-a:exit", + "max-b:exit", + "max-a:exit", + ]); + }); + + it("child custom-group middleware does not leak back to parent", function* () { + const api = createApi( + "groups.no-leak", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + + yield* api.around( + { + *value(args, next) { + log.push("max-a:enter"); + const result = yield* next(...args); + log.push("max-a:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-a:enter"); + const result = yield* next(...args); + log.push("replay-a:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-a:enter"); + const result = yield* next(...args); + log.push("min-a:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* scoped(function* () { + yield* api.around( + { + *value(args, next) { + log.push("max-b:enter"); + const result = yield* next(...args); + log.push("max-b:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-b:enter"); + const result = yield* next(...args); + log.push("replay-b:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-b:enter"); + const result = yield* next(...args); + log.push("min-b:exit"); + return result; + }, + }, + { at: "min" }, + ); + + yield* api.operations.value(); + }); + + log.length = 0; + yield* api.operations.value(); + expect(log).toEqual([ + "max-a:enter", + "replay-a:enter", + "min-a:enter", + "min-a:exit", + "replay-a:exit", + "max-a:exit", + ]); + }); + + it("spawned child sees later parent replay middleware", function* () { + const api = createApi( + "groups.spawn-replay", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + const gate = withResolvers(); + + yield* api.around( + { + *value(args, next) { + log.push("replay-a:enter"); + const result = yield* next(...args); + log.push("replay-a:exit"); + return result; + }, + }, + { at: "replay" }, + ); + + const task = yield* spawn(function* () { + yield* gate.operation; + return yield* api.operations.value(); + }); + + yield* api.around( + { + *value(args, next) { + log.push("replay-b:enter"); + const result = yield* next(...args); + log.push("replay-b:exit"); + return result; + }, + }, + { at: "replay" }, + ); + + gate.resolve(); + + const result = yield* task; + expect(result).toEqual("core"); + // Spawned child reads live context: sees both earlier and later replay. + // replay is append, so replay-a runs outer. + expect(log).toEqual([ + "replay-a:enter", + "replay-b:enter", + "replay-b:exit", + "replay-a:exit", + ]); + }); + + it("mixed later parent updates across all three lanes remain deterministic", function* () { + const api = createApi( + "groups.mixed-later-updates", + { + *value(): Operation { + return "core"; + }, + }, + { groups: THREE_LANE }, + ); + + const log: string[] = []; + const childReady = withResolvers(); + const parentUpdated = withResolvers(); + + yield* api.around( + { + *value(args, next) { + log.push("max-a:enter"); + const result = yield* next(...args); + log.push("max-a:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-a:enter"); + const result = yield* next(...args); + log.push("replay-a:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-a:enter"); + const result = yield* next(...args); + log.push("min-a:exit"); + return result; + }, + }, + { at: "min" }, + ); + + const task = yield* spawn(function* () { + yield* api.around( + { + *value(args, next) { + log.push("max-b:enter"); + const result = yield* next(...args); + log.push("max-b:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-b:enter"); + const result = yield* next(...args); + log.push("replay-b:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-b:enter"); + const result = yield* next(...args); + log.push("min-b:exit"); + return result; + }, + }, + { at: "min" }, + ); + + childReady.resolve(); + yield* parentUpdated.operation; + return yield* api.operations.value(); + }); + + yield* childReady.operation; + + yield* api.around( + { + *value(args, next) { + log.push("max-c:enter"); + const result = yield* next(...args); + log.push("max-c:exit"); + return result; + }, + }, + { at: "max" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("replay-c:enter"); + const result = yield* next(...args); + log.push("replay-c:exit"); + return result; + }, + }, + { at: "replay" }, + ); + yield* api.around( + { + *value(args, next) { + log.push("min-c:enter"); + const result = yield* next(...args); + log.push("min-c:exit"); + return result; + }, + }, + { at: "min" }, + ); + + parentUpdated.resolve(); + + const childResult = yield* task; + expect(childResult).toEqual("core"); + // append lanes (max, replay): parent outer / child inner; + // parent install order preserved (a then c), child appended inner. + // prepend lane (min): child outer / parent inner, with later installs outermost. + expect(log).toEqual([ + "max-a:enter", + "max-c:enter", + "max-b:enter", + "replay-a:enter", + "replay-c:enter", + "replay-b:enter", + "min-b:enter", + "min-c:enter", + "min-a:enter", + "min-a:exit", + "min-c:exit", + "min-b:exit", + "replay-b:exit", + "replay-c:exit", + "replay-a:exit", + "max-b:exit", + "max-c:exit", + "max-a:exit", + ]); + }); + + describe("validation", () => { + it("createApi throws on duplicate group names", function* () { + expect(() => + createApi( + "groups.dup", + { *v(): Operation {} }, + { + groups: [ + { name: "max", mode: "append" }, + { name: "replay", mode: "append" }, + { name: "max", mode: "prepend" }, + ] as const, + }, + ), + ).toThrow(/duplicate group name/); + + expect(() => + createApi( + "groups.dup", + { *v(): Operation {} }, + { + groups: [ + { name: "max", mode: "append" }, + { name: "max", mode: "prepend" }, + ] as const, + }, + ), + ).toThrow(/duplicate group names?:[^:]*\bmax\b/); + }); + + it("createApi throws on an empty groups array", function* () { + expect(() => + createApi( + "groups.empty", + { *v(): Operation {} }, + { groups: [] as const }, + ), + ).toThrow(/must not be empty/); + }); + + it("around throws on unknown `at` with known names in the error message", function* () { + const api = createApi( + "groups.unknown-at", + { *v(): Operation {} }, + { groups: THREE_LANE }, + ); + + let error: unknown; + try { + yield* api.around( + { + *v(args, next) { + yield* next(...args); + }, + }, + // deliberately cast to bypass the compile-time guard + { at: "nope" as unknown as "max" | "replay" | "min" }, + ); + } catch (e) { + error = e; + } + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toMatch(/unknown group "nope"/); + expect(message).toMatch(/max/); + expect(message).toMatch(/replay/); + expect(message).toMatch(/min/); + }); + }); + }); });