Skip to content

Commit 3537660

Browse files
authored
✨ Introduce context APIs for algebraic effects (#1211)
* ✨ Introduce context APIs for algebraic effects (#1101) 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. * ⚡️ statically compute api tree at decorate time (#1183) The computational complexity of reifying an api handle was being deferred until the moment the api was being called, and then every invocation paid the cost of calculating the _entire_ middleware stack from the root all they way back to the decorate scope. The handle was cached for subsequent invocations, but any new `around()` invalidated that scope and every descendant, which meant that every single one had to walk the entire scope chain on the next invocation. The handle is now materialized at decoration time. Each api context carries `local` (this scope's contributions), `total` (the inherited stack), and `handle` (the assembled api). `around()` appends the new decorator onto `local` and then walks the subtree in `install()`, rebuilding each descendant's handle in place. Critically, the inherited middleware stack is cached at every scope that has middleware, so any subsequent calls to around() can begin from that total, and do not need to walk back up the entire scope chain. In all cases, because the api handle is computed eagerly, `invoke()` is single context lookup and dispatch. * ♻️ Don't use api decorator for scope.destroy We were installing an api decorator as the method for implementing the destroy method on scope, but this required recomputing the middleware stack for every scope creation which is totally unecessary work. This just puts it on a prototype method of ScopeInternal. That way, there is one version of the function, and the default api handle, just invokes it straight away.
1 parent 58fabfc commit 3537660

7 files changed

Lines changed: 843 additions & 36 deletions

File tree

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build:jsr": "deno run -A tasks/build-jsr.ts"
1111
},
1212
"lint": {
13-
"rules": { "exclude": ["prefer-const", "require-yield"] },
13+
"rules": { "exclude": ["prefer-const", "require-yield", "ban-types"] },
1414
"exclude": ["build", "docs", "tasks", "bench"],
1515
"plugins": ["lint/prefer-let.ts"]
1616
},

experimental.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./lib/api.ts";

lib/api-internal.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// deno-lint-ignore-file ban-types no-explicit-any
2+
import { createContext } from "./context.ts";
3+
import type {
4+
Api,
5+
Around,
6+
Context,
7+
Effect,
8+
Middleware,
9+
Operation,
10+
Scope,
11+
} from "./types.ts";
12+
import { Children } from "./contexts.ts";
13+
import { Ok } from "./result.ts";
14+
15+
export interface ApiInternal<A> extends Api<A> {
16+
context: Context<{
17+
local: Decorator<A>;
18+
total: Decorator<A>;
19+
handle: A;
20+
}>;
21+
core: A;
22+
}
23+
24+
export function createApiInternal<A extends {}>(
25+
name: string,
26+
core: A,
27+
): ApiInternal<A> {
28+
let fields = Object.keys(core) as (keyof A)[];
29+
30+
let context = createContext(`api::${name}`) as ApiInternal<A>["context"];
31+
32+
let api: ApiInternal<A> = {
33+
core,
34+
context,
35+
36+
invoke: (scope, key, args) => {
37+
let handle = scope.get(api.context)?.handle ?? core;
38+
39+
let member = handle[key];
40+
if (typeof member === "function") {
41+
return member(...args);
42+
} else {
43+
return member;
44+
}
45+
},
46+
around: (decorator, options = { at: "max" }) => ({
47+
*[Symbol.iterator]() {
48+
let scope = (yield GetScope) as Scope;
49+
decorateApi(scope, api, decorator, options);
50+
},
51+
}),
52+
operations: fields.reduce((sum, field) => {
53+
if (typeof core[field] === "function") {
54+
return Object.assign(sum, {
55+
[field]: (...args: any) => ({
56+
*[Symbol.iterator]() {
57+
let scope = (yield GetScope) as Scope;
58+
let target = api.invoke(scope, field, args);
59+
60+
return isOperation(target) ? yield* target : target;
61+
},
62+
}),
63+
});
64+
} else {
65+
return Object.assign(sum, {
66+
[field]: {
67+
*[Symbol.iterator]() {
68+
let scope = (yield GetScope) as Scope;
69+
let target = api.invoke(scope, field, [] as any);
70+
return isOperation(target) ? yield* target : target;
71+
},
72+
},
73+
});
74+
}
75+
}, {} as Api<A>["operations"]),
76+
};
77+
return api;
78+
}
79+
80+
export function decorateApi<A>(
81+
scope: Scope,
82+
api: ApiInternal<A>,
83+
decorator: Partial<Around<A>>,
84+
options?: {
85+
at: "min" | "max";
86+
},
87+
) {
88+
// read existing total and local
89+
let current = scope.get(api.context) ?? { total: {}, local: {} };
90+
91+
let local = decorate(current.local, {
92+
[options?.at ?? "max"]: decorator,
93+
});
94+
95+
if (!scope.hasOwn(api.context)) {
96+
scope.set(api.context, {
97+
local,
98+
total: current.total,
99+
handle: api.core,
100+
});
101+
} else {
102+
current.local = local;
103+
}
104+
105+
install(scope, api, current.total);
106+
}
107+
108+
function decorate<A>(base: Decorator<A>, next: Decorator<A>): Decorator<A> {
109+
return {
110+
max: base.max ? next.max ? append(base.max, next.max) : base.max : next.max,
111+
min: next.min ? base.min ? append(next.min, base.min) : next.min : base.min,
112+
};
113+
}
114+
115+
function append<A>(
116+
outer: Partial<Around<A>>,
117+
inner: Partial<Around<A>>,
118+
): Partial<Around<A>> {
119+
let result: Partial<Around<A>> = { ...outer };
120+
for (let key of Object.keys(inner) as (keyof A)[]) {
121+
let current = outer[key];
122+
let decoration = inner[key];
123+
if (!current) {
124+
result[key] = decoration;
125+
} else {
126+
let pair = [current, decoration] as Middleware<unknown[], unknown>[];
127+
result[key] = combine(pair) as Around<A>[keyof A];
128+
}
129+
}
130+
return result;
131+
}
132+
133+
function install<A>(
134+
scope: Scope,
135+
api: ApiInternal<A>,
136+
total: Decorator<A>,
137+
): void {
138+
if (scope.hasOwn(api.context)) {
139+
let context = scope.expect(api.context);
140+
141+
context.total = total;
142+
143+
total = decorate(context.total, context.local);
144+
145+
context.handle = createApiHandle(total, api.core);
146+
}
147+
148+
for (let child of scope.expect(Children)) {
149+
install(child, api, total);
150+
}
151+
}
152+
153+
function createApiHandle<A>(decoration: Decorator<A>, core: A): A {
154+
let around = decoration.max
155+
? decoration.min ? append(decoration.max, decoration.min) : decoration.max
156+
: decoration.min;
157+
if (!around) {
158+
return core;
159+
}
160+
let handle: A = {} as A;
161+
for (let key of Object.keys(core as object) as Array<keyof A>) {
162+
let middleware = around[key] as Middleware<unknown[], unknown> | undefined;
163+
if (middleware) {
164+
if (typeof core[key] === "function") {
165+
handle[key] = ((...args: unknown[]) =>
166+
middleware(args, core[key] as (...args: unknown[]) => unknown)) as A[
167+
keyof A
168+
];
169+
} else {
170+
Object.defineProperty(handle, key, {
171+
enumerable: true,
172+
get() {
173+
return middleware([], () => core[key]);
174+
},
175+
});
176+
}
177+
} else {
178+
handle[key] = core[key];
179+
}
180+
}
181+
return handle;
182+
}
183+
184+
function isOperation<T>(
185+
target: Operation<T> | T,
186+
): target is Operation<T> {
187+
return target && !isNativeIterable(target) &&
188+
typeof (target as Operation<T>)[Symbol.iterator] === "function";
189+
}
190+
191+
function isNativeIterable(target: unknown): boolean {
192+
return (
193+
typeof target === "string" || Array.isArray(target) ||
194+
target instanceof Map || target instanceof Set
195+
);
196+
}
197+
198+
function combine<TArgs extends unknown[], TReturn>(
199+
middlewares: Middleware<TArgs, TReturn>[],
200+
): Middleware<TArgs, TReturn> {
201+
if (middlewares.length === 0) {
202+
return (args, next) => next(...args);
203+
}
204+
return middlewares.reduceRight((sum, middleware) => (args, next) =>
205+
middleware(args, (...args) => sum(args, next))
206+
);
207+
}
208+
209+
interface Decorator<A> {
210+
min?: Partial<Around<A>>;
211+
max?: Partial<Around<A>>;
212+
}
213+
214+
const GetScope: Effect<Scope> = {
215+
description: "Fast, non-typesafe lookup of co-routine scope",
216+
enter: (resolve, routine) => {
217+
resolve(Ok(routine.scope));
218+
return (didExit) => didExit(Ok());
219+
},
220+
};

lib/api.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// deno-lint-ignore-file ban-types
2+
import type { Api, Context, Operation, Scope } from "./types.ts";
3+
import { createApiInternal } from "./api-internal.ts";
4+
import type { ScopeInternal } from "./scope-internal.ts";
5+
6+
export function createApi<A extends {}>(name: string, core: A): Api<A> {
7+
return createApiInternal(name, core);
8+
}
9+
10+
interface ScopeApi {
11+
create(parent: Scope): [Scope, () => Operation<void>];
12+
destroy(scope: Scope): Operation<void>;
13+
set<T>(scope: Scope, context: Context<T>, value: T): T;
14+
delete<T>(scope: Scope, context: Context<T>): boolean;
15+
}
16+
17+
interface Apis {
18+
Scope: Api<ScopeApi>;
19+
}
20+
21+
export const api: Apis = {
22+
Scope: createApi<ScopeApi>("Scope", {
23+
create() {
24+
throw new TypeError(`no handler for Scope.create()`);
25+
},
26+
destroy(scope) {
27+
return (scope as ScopeInternal).destroy();
28+
},
29+
set(scope, context, value) {
30+
return (scope as ScopeInternal).contexts[context.name] = value;
31+
},
32+
delete(scope, context) {
33+
return delete (scope as ScopeInternal).contexts[context.name];
34+
},
35+
}),
36+
};

0 commit comments

Comments
 (0)