Skip to content

Commit e10cd3a

Browse files
committed
✨ 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.
1 parent 58fabfc commit e10cd3a

7 files changed

Lines changed: 612 additions & 31 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: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 type { ScopeInternal } from "./scope-internal.ts";
13+
import { Children } from "./contexts.ts";
14+
import { Ok } from "./result.ts";
15+
16+
export interface ApiInternal<A> extends Api<A> {
17+
context: Context<{
18+
max: Partial<Around<A>>[];
19+
min: Partial<Around<A>>[];
20+
}>;
21+
core: A;
22+
cache: WeakMap<Scope, A>;
23+
invalidate(scope: Scope): void;
24+
}
25+
26+
export function createApiInternal<A extends {}>(
27+
name: string,
28+
core: A,
29+
): ApiInternal<A> {
30+
let fields = Object.keys(core) as (keyof A)[];
31+
32+
let context = createContext(`api::${name}`) as ApiInternal<A>["context"];
33+
34+
let cache = new WeakMap<Scope, A>();
35+
36+
let api: ApiInternal<A> = {
37+
core,
38+
context,
39+
cache,
40+
invalidate(scope: Scope) {
41+
cache.delete(scope);
42+
let children = scope.get(Children);
43+
if (children) {
44+
for (let child of children) {
45+
api.invalidate(child);
46+
}
47+
}
48+
},
49+
invoke: (scope, key, args) => {
50+
let handle = cache.get(scope);
51+
if (!handle) {
52+
handle = createHandle(api, scope as ScopeInternal);
53+
cache.set(scope, handle);
54+
}
55+
let member = handle[key];
56+
if (typeof member === "function") {
57+
return member(...args);
58+
} else {
59+
return member;
60+
}
61+
},
62+
around: (decorator, options = { at: "max" }) => ({
63+
*[Symbol.iterator]() {
64+
let scope = (yield GetScope) as Scope;
65+
scope.around(api, decorator, options);
66+
},
67+
}),
68+
operations: fields.reduce((sum, field) => {
69+
if (typeof core[field] === "function") {
70+
return Object.assign(sum, {
71+
[field]: (...args: any) => ({
72+
*[Symbol.iterator]() {
73+
let scope = (yield GetScope) as Scope;
74+
let target = api.invoke(scope, field, args);
75+
76+
return isOperation(target) ? yield* target : target;
77+
},
78+
}),
79+
});
80+
} else {
81+
return Object.assign(sum, {
82+
[field]: {
83+
*[Symbol.iterator]() {
84+
let scope = (yield GetScope) as Scope;
85+
let target = api.invoke(scope, field, [] as any);
86+
return isOperation(target) ? yield* target : target;
87+
},
88+
},
89+
});
90+
}
91+
}, {} as Api<A>["operations"]),
92+
};
93+
return api;
94+
}
95+
96+
function createHandle<A extends {}>(
97+
api: ApiInternal<A>,
98+
scope: ScopeInternal,
99+
): A {
100+
// there is no middleware at all for this api, so the handle _is_ the core.
101+
if (!scope.get(api.context)) {
102+
return api.core;
103+
}
104+
105+
let handle = Object.create(api.core);
106+
107+
for (let key of Object.keys(api.core) as Array<keyof A>) {
108+
let { min, max } = scope.reduce(api.context, (sum, current) => {
109+
let min = current.min.flatMap((around) =>
110+
around[key] ? [around[key]] : []
111+
);
112+
let max = current.max.flatMap((around) =>
113+
around[key] ? [around[key]] : []
114+
);
115+
116+
sum.min.push(...min);
117+
sum.max.unshift(...max);
118+
119+
return sum;
120+
}, {
121+
min: [] as Around<A>[typeof key][],
122+
max: [] as Around<A>[typeof key][],
123+
});
124+
125+
let stack = combine(max.concat(min) as Middleware<unknown[], unknown>[]);
126+
127+
if (typeof api.core[key] === "function") {
128+
handle[key] = (...args: unknown[]) =>
129+
stack(args, api.core[key] as (...args: unknown[]) => unknown);
130+
} else {
131+
Object.defineProperty(handle, key, {
132+
enumerable: true,
133+
get() {
134+
return stack([], () => api.core[key]);
135+
},
136+
});
137+
}
138+
}
139+
140+
return handle;
141+
}
142+
143+
function isOperation<T>(
144+
target: Operation<T> | T,
145+
): target is Operation<T> {
146+
return target && !isNativeIterable(target) &&
147+
typeof (target as Operation<T>)[Symbol.iterator] === "function";
148+
}
149+
150+
function isNativeIterable(target: unknown): boolean {
151+
return (
152+
typeof target === "string" || Array.isArray(target) ||
153+
target instanceof Map || target instanceof Set
154+
);
155+
}
156+
157+
function combine<TArgs extends unknown[], TReturn>(
158+
middlewares: Middleware<TArgs, TReturn>[],
159+
): Middleware<TArgs, TReturn> {
160+
if (middlewares.length === 0) {
161+
return (args, next) => next(...args);
162+
}
163+
return middlewares.reduceRight((sum, middleware) => (args, next) =>
164+
middleware(args, (...args) => sum(args, next))
165+
);
166+
}
167+
168+
const GetScope: Effect<Scope> = {
169+
description: "Fast, non-typesafe lookup of co-routine scope",
170+
enter: (resolve, routine) => {
171+
resolve(Ok(routine.scope));
172+
return (didExit) => didExit(Ok());
173+
},
174+
};

lib/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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() {},
27+
set(scope, context, value) {
28+
return (scope as ScopeInternal).contexts[context.name] = value;
29+
},
30+
delete(scope, context) {
31+
return delete (scope as ScopeInternal).contexts[context.name];
32+
},
33+
}),
34+
};

0 commit comments

Comments
 (0)