diff --git a/deno.json b/deno.json index aee886609..afa23d842 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,7 @@ "build:jsr": "deno run -A tasks/build-jsr.ts" }, "lint": { - "rules": { "exclude": ["prefer-const", "require-yield"] }, + "rules": { "exclude": ["prefer-const", "require-yield", "ban-types"] }, "exclude": ["build", "docs", "tasks", "bench"], "plugins": ["lint/prefer-let.ts"] }, diff --git a/experimental.ts b/experimental.ts index e69de29bb..7f7317fb3 100644 --- a/experimental.ts +++ b/experimental.ts @@ -0,0 +1 @@ +export * from "./lib/api.ts"; diff --git a/lib/api-internal.ts b/lib/api-internal.ts new file mode 100644 index 000000000..b49d5e808 --- /dev/null +++ b/lib/api-internal.ts @@ -0,0 +1,220 @@ +// deno-lint-ignore-file ban-types no-explicit-any +import { createContext } from "./context.ts"; +import type { + Api, + Around, + Context, + Effect, + Middleware, + Operation, + Scope, +} from "./types.ts"; +import { Children } from "./contexts.ts"; +import { Ok } from "./result.ts"; + +export interface ApiInternal extends Api { + context: Context<{ + local: Decorator; + total: Decorator; + handle: A; + }>; + 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 = scope.get(api.context)?.handle ?? core; + + let member = handle[key]; + if (typeof member === "function") { + return member(...args); + } else { + return member; + } + }, + around: (decorator, options = { at: "max" }) => ({ + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + decorateApi(scope, api, decorator, options); + }, + }), + operations: fields.reduce((sum, field) => { + if (typeof core[field] === "function") { + return Object.assign(sum, { + [field]: (...args: any) => ({ + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + let target = api.invoke(scope, field, args); + + return isOperation(target) ? yield* target : target; + }, + }), + }); + } else { + return Object.assign(sum, { + [field]: { + *[Symbol.iterator]() { + let scope = (yield GetScope) as Scope; + let target = api.invoke(scope, field, [] as any); + return isOperation(target) ? yield* target : target; + }, + }, + }); + } + }, {} as Api["operations"]), + }; + return api; +} + +export function decorateApi( + scope: Scope, + api: ApiInternal, + decorator: Partial>, + options?: { + at: "min" | "max"; + }, +) { + // read existing total and local + let current = scope.get(api.context) ?? { total: {}, local: {} }; + + let local = decorate(current.local, { + [options?.at ?? "max"]: decorator, + }); + + if (!scope.hasOwn(api.context)) { + scope.set(api.context, { + local, + total: current.total, + handle: api.core, + }); + } else { + current.local = local; + } + + install(scope, api, current.total); +} + +function decorate(base: Decorator, next: Decorator): Decorator { + return { + max: base.max ? next.max ? append(base.max, next.max) : base.max : next.max, + min: next.min ? base.min ? append(next.min, base.min) : next.min : base.min, + }; +} + +function append( + outer: Partial>, + inner: Partial>, +): Partial> { + let result: Partial> = { ...outer }; + for (let key of Object.keys(inner) as (keyof A)[]) { + let current = outer[key]; + let decoration = inner[key]; + if (!current) { + result[key] = decoration; + } else { + let pair = [current, decoration] as Middleware[]; + result[key] = combine(pair) as Around[keyof A]; + } + } + return result; +} + +function install( + scope: Scope, + api: ApiInternal, + total: Decorator, +): void { + if (scope.hasOwn(api.context)) { + let context = scope.expect(api.context); + + context.total = total; + + total = decorate(context.total, context.local); + + context.handle = createApiHandle(total, api.core); + } + + for (let child of scope.expect(Children)) { + install(child, api, total); + } +} + +function createApiHandle(decoration: Decorator, core: A): A { + let around = decoration.max + ? decoration.min ? append(decoration.max, decoration.min) : decoration.max + : decoration.min; + if (!around) { + return core; + } + let handle: A = {} as A; + for (let key of Object.keys(core as object) as Array) { + let middleware = around[key] as Middleware | undefined; + if (middleware) { + if (typeof core[key] === "function") { + handle[key] = ((...args: unknown[]) => + middleware(args, core[key] as (...args: unknown[]) => unknown)) as A[ + keyof A + ]; + } else { + Object.defineProperty(handle, key, { + enumerable: true, + get() { + return middleware([], () => core[key]); + }, + }); + } + } else { + handle[key] = 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)) + ); +} + +interface Decorator { + min?: Partial>; + max?: Partial>; +} + +const GetScope: Effect = { + description: "Fast, non-typesafe lookup of co-routine scope", + enter: (resolve, routine) => { + resolve(Ok(routine.scope)); + return (didExit) => didExit(Ok()); + }, +}; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 000000000..3e3f98a6a --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,36 @@ +// 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(scope) { + return (scope as ScopeInternal).destroy(); + }, + set(scope, context, value) { + return (scope as ScopeInternal).contexts[context.name] = value; + }, + delete(scope, context) { + return delete (scope as ScopeInternal).contexts[context.name]; + }, + }), +}; diff --git a/lib/scope-internal.ts b/lib/scope-internal.ts index 9351d0a81..72d760d0b 100644 --- a/lib/scope-internal.ts +++ b/lib/scope-internal.ts @@ -1,3 +1,5 @@ +import { type ApiInternal, decorateApi } from "./api-internal.ts"; +import { api as effection } from "./api.ts"; import { Children, Priority } from "./contexts.ts"; import { createFuture } from "./future.ts"; import { Err, Ok, unbox } from "./result.ts"; @@ -5,11 +7,36 @@ import { createTask } from "./task.ts"; import type { Context, Operation, Scope, Task } from "./types.ts"; +const api = effection.Scope; + export function createScopeInternal( parent?: Scope, +): [ScopeInternal, () => Operation] { + if (!parent) { + let [global, destroy] = buildScopeInternal(); + global.around(api, { + create([parent]) { + return buildScopeInternal(parent); + }, + }, { at: "min" }); + return [global, destroy] as const; + } else { + return api.invoke(parent, "create", [parent]) as [ + ScopeInternal, + () => Operation, + ]; + } +} + +export function buildScopeInternal( + parent?: Scope, ): [ScopeInternal, () => Operation] { let destructors = new Set<() => Operation>(); let destruction = createFuture(); + let signaled = false; + let unbind = parent + ? (parent as ScopeInternal).ensure(() => destroy()) + : () => {}; let contexts: Record = Object.create( parent ? (parent as ScopeInternal).contexts : null, @@ -21,7 +48,7 @@ export function createScopeInternal( return (contexts[context.name] ?? context.defaultValue) as T | undefined; }, set(context: Context, value: T): T { - return contexts[context.name] = value; + return api.invoke(scope, "set", [scope, context, value]) as T; }, expect(context: Context): T { let value = scope.get(context); @@ -33,7 +60,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); @@ -48,54 +75,61 @@ export function createScopeInternal( }, }; }, + around( + api: ApiInternal, + ...params: Parameters["around"]> + ) { + decorateApi(scope, api, ...params); + }, ensure(op: () => Operation): () => void { destructors.add(op); return () => destructors.delete(op); }, - }); - - scope.set(Priority, scope.expect(Priority) + 1); - scope.set(Children, new Set()); - parent?.expect(Children).add(scope); - let destroy = function* (): Operation { - destroy = () => destruction.future; - - parent?.expect(Children).delete(scope); - unbind(); - let outcome = Ok(); - try { - while (destructors.size > 0) { - let current = [...destructors]; - destructors.clear(); - for (let destructor of current) { - try { - yield* destructor(); - } catch (error) { - outcome = Err(error as Error); + *destroy(): Operation { + if (signaled) { + return yield* destruction.future; + } + signaled = true; + parent?.expect(Children).delete(scope); + unbind(); + let outcome = Ok(); + try { + while (destructors.size > 0) { + let current = [...destructors]; + destructors.clear(); + for (let destructor of current) { + try { + yield* destructor(); + } catch (error) { + outcome = Err(error); + } } } + } finally { + if (outcome.ok) { + destruction.resolve(); + } else { + destruction.reject(outcome.error); + } } - } finally { - if (outcome.ok) { - destruction.resolve(); - } else { - destruction.reject(outcome.error); - } - } - unbox(outcome); - }; + unbox(outcome); + }, + }); - let unbind = parent - ? (parent as ScopeInternal).ensure(() => destroy()) - : () => {}; + scope.set(Priority, scope.expect(Priority) + 1); + scope.set(Children, new Set()); + parent?.expect(Children).add(scope); + + let destroy = () => api.invoke(scope, "destroy", [scope]); - return [scope, () => destroy()]; + return [scope, destroy]; } export interface ScopeInternal extends Scope, AsyncDisposable { contexts: Record; ensure(op: () => Operation): () => void; + destroy(): Operation; } diff --git a/lib/types.ts b/lib/types.ts index 816ac7adf..434a4e36d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-explicit-any import type { Maybe } from "./maybe.ts"; import type { Result } from "./result.ts"; @@ -336,8 +337,98 @@ export interface Scope { * @returns `true` if scope has its own context, `false` if context is not present, or inherited from its parent. */ hasOwn(context: Context): boolean; + + /** + * Enhance an {@link Api} within this scope by surrounding it with + * middleware. + * + * @param api - the api being enhanced + * @param middlewares - collection of {@link Middleware} to be added to this {@link Api} + * @param options - specifies which layer of dispatch `middlewares` will be applied + * @see {@link Api.around} + * @since 4.1 + */ + around(api: Api, ...options: Parameters["around"]>): void; } +/** + * A set of methods and values that can be decorated on a per-scope + * basis. Apis are ideal for situations that require context + * sensitivity such as dependency injection, test mocking, and + * instrumentation. + * + * @template A - core shape of the Api + * @see {@link createApi} + * @since 4.1 + */ +export interface Api { + /** + * Every member of `A` "lifted" into an operation that invokes that + * member on the current {Scope} + */ + operations: { + [K in keyof A]: A[K] extends Operation ? A[K] + : A[K] extends (...args: infer TArgs) => infer TReturn + ? TReturn extends Operation ? A[K] + : (...args: TArgs) => Operation + : Operation; + }; + /** + * Enhance an {@link Api} within this scope by surrounding it with + * middleware. + * + * @param middlewares - a set of decorators that will surround the api core + * @param options - specify at which layer of dispatch, `middleware` will apply + * @returns an {Operation} that installs the middleware in the current {Scope} + */ + around: ( + middlewares: Partial>, + options?: { + at: "min" | "max"; + }, + ) => Operation; + + /** + * Call an API as it exists on `scope`. + */ + invoke: ( + scope: Scope, + key: K, + args: A[K] extends (...args: any) => unknown ? Parameters : [], + ) => A[K] extends (...args: any) => unknown ? ReturnType : A[K]; +} + +/** + * An general function that can be used to surround any other function + * or value. + * + * @since 4.1 + */ +export interface Middleware { + /** + * Execute a single link in the middleware stack by doing whatever + * computation is necessary and then optionally delegating to the + * next link. + * + * @param args - the arguments to the value being surrounded. + * @param next - the next function in the change. It will accept the + * arguments contained in `args` + * @returns a value with the same shape as `next()`'s return value + */ + (args: TArgs, next: (...args: TArgs) => TReturn): TReturn; +} + +/** + * The shape of middlewares can surround a particular {Api} + * + * Members of an Api that are values are surrounded by no-arg functions. + */ +export type Around = { + [K in keyof Api]: Api[K] extends (...args: infer TArgs) => infer TReturn + ? Middleware + : Middleware<[], Api[K]>; +}; + /** * Unwrap the type of an `Operation`. * Analogous to the built in [`Awaited`](https://www.typescriptlang.org/docs/handbook/utility-types.html#awaitedtype) type. diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 000000000..6e9083445 --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,425 @@ +import { run } from "../mod.ts"; +import { createApi } from "../experimental.ts"; +import { constant } from "../lib/constant.ts"; +import { + type Operation, + resource, + scoped, + spawn, + useScope, +} 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; + }, + } as const, + ); + + 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", + ]); + }); + }); + + it("propagates new ancestor middleware to existing child scopes", async () => { + let api = createApi("test", { + *num(value: number): Operation { + return value; + }, + }); + + await run(function* () { + let nummer = yield* resource<{ num(): Operation }>( + function* (provide) { + let scope = yield* useScope(); + yield* provide({ + *num() { + return yield* scope.run(() => api.operations.num(5)); + }, + }); + }, + ); + + yield* api.around({ + *num(args, next) { + return (yield* next(...args)) * 2; + }, + }); + + expect(yield* nummer.num()).toEqual(10); + }); + }); + + it("propagates new ancestor middleware to existing child scopes that also have middleware", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + await run(function* () { + let tester = yield* resource<{ test(): Operation }>( + function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("child")); + return output.concat("/child"); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }, + ); + + yield* api.around({ + *test(args, next) { + let [input] = args; + let output = yield* next(input.concat("parent")); + return output.concat("/parent"); + }, + }); + + expect(yield* tester.test()).toEqual([ + "parent", + "child", + "/child", + "/parent", + ]); + }); + }); + + it("isolates sibling scopes from each other's middleware", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + function sibling(label: string) { + return resource<{ test(): Operation }>(function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat(label))).concat(`/${label}`); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }); + } + + await run(function* () { + let a = yield* sibling("a"); + let b = yield* sibling("b"); + + expect(yield* a.test()).toEqual(["a", "/a"]); + expect(yield* b.test()).toEqual(["b", "/b"]); + expect(yield* api.operations.test([])).toEqual([]); + }); + }); + + it("composes middleware across a three-level scope tree", async () => { + let api = createApi("test", { + *test(order: string[]): Operation { + return order; + }, + }); + + function layer(label: string) { + return resource<{ test(): Operation }>(function* (provide) { + let scope = yield* useScope(); + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat(label))).concat(`/${label}`); + }, + }); + yield* provide({ + *test() { + return yield* scope.run(() => api.operations.test([])); + }, + }); + }); + } + + await run(function* () { + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat("grand"))).concat("/grand"); + }, + }); + + let leaf = yield* resource<{ test(): Operation }>( + function* (provide) { + yield* api.around({ + *test(args, next) { + let [input] = args; + return (yield* next(input.concat("parent"))).concat("/parent"); + }, + }); + let child = yield* layer("child"); + yield* provide(child); + }, + ); + + expect(yield* leaf.test()).toEqual([ + "grand", + "parent", + "child", + "/child", + "/parent", + "/grand", + ]); + }); + }); + + it("does not affect un-aroundified keys when middleware is installed for a different key", async () => { + let api = createApi("test", { + *foo(): Operation { + return 1; + }, + *bar(): Operation { + return 1; + }, + }); + + await run(function* () { + yield* api.around({ + *foo(args, next) { + return (yield* next(...args)) * 100; + }, + }); + + expect(yield* api.operations.foo()).toEqual(100); + expect(yield* api.operations.bar()).toEqual(1); + }); + }); + + it("does not leak middleware from a finished child scope back to its parent", async () => { + let api = createApi("test", { + *num(value: number): Operation { + return value; + }, + }); + + await run(function* () { + let result = yield* scoped(function* () { + yield* api.around({ + *num(args, next) { + return (yield* next(...args)) * 2; + }, + }); + return yield* api.operations.num(5); + }); + + expect(result).toEqual(10); + expect(yield* api.operations.num(5)).toEqual(5); + }); + }); +});