Skip to content

Commit 0821cfc

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 d5ee199 commit 0821cfc

7 files changed

Lines changed: 571 additions & 27 deletions

File tree

deno.json

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

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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// deno-lint-ignore-file ban-types no-explicit-any
2+
import { createContext } from "./context.ts";
3+
import { useScope } from "./scope.ts";
4+
import type {
5+
Api,
6+
Around,
7+
Context,
8+
Middleware,
9+
Operation,
10+
Scope,
11+
} from "./types.ts";
12+
import type { ScopeInternal } from "./scope-internal.ts";
13+
14+
export interface ApiInternal<A> extends Api<A> {
15+
context: Context<{
16+
capture: Partial<Around<A>>[];
17+
bubble: Partial<Around<A>>[];
18+
}>;
19+
core: A;
20+
}
21+
22+
export function createApiInternal<A extends {}>(
23+
name: string,
24+
core: A,
25+
): ApiInternal<A> {
26+
let fields = Object.keys(core) as (keyof A)[];
27+
28+
let context = createContext(`api::${name}`) as ApiInternal<A>["context"];
29+
30+
let api: ApiInternal<A> = {
31+
core,
32+
context,
33+
invoke: (scope, key, args) => {
34+
let handle = createHandle(api, scope);
35+
let member = handle[key];
36+
if (typeof member === "function") {
37+
return member(...args);
38+
} else {
39+
return member;
40+
}
41+
},
42+
around: (decorator, options = {}) => ({
43+
*[Symbol.iterator]() {
44+
let scope = yield* useScope();
45+
scope.around(api, decorator, options);
46+
},
47+
}),
48+
operations: fields.reduce((sum, field) => {
49+
if (typeof core[field] === "function") {
50+
return Object.assign(sum, {
51+
[field]: (...args: any) => ({
52+
*[Symbol.iterator]() {
53+
let scope = yield* useScope();
54+
let target = api.invoke(scope, field, args);
55+
56+
return isOperation(target) ? yield* target : target;
57+
},
58+
}),
59+
});
60+
} else {
61+
return Object.assign(sum, {
62+
[field]: {
63+
*[Symbol.iterator]() {
64+
let scope = yield* useScope();
65+
let target = api.invoke(scope, field, [] as any);
66+
return isOperation(target) ? yield* target : target;
67+
},
68+
},
69+
});
70+
}
71+
}, {} as Api<A>["operations"]),
72+
};
73+
return api;
74+
}
75+
76+
function createHandle<A extends {}>(api: ApiInternal<A>, scope: Scope): A {
77+
let handle = Object.create(api.core);
78+
let $scope = scope as ScopeInternal;
79+
for (let key of Object.keys(api.core) as Array<keyof A>) {
80+
let dispatch = (args: unknown[], next: (...args: unknown[]) => unknown) => {
81+
let { bubble, capture } = $scope.reduce(api.context, (sum, current) => {
82+
let min = current.bubble.flatMap((around) =>
83+
around[key] ? [around[key]] : []
84+
);
85+
let max = current.capture.flatMap((around) =>
86+
around[key] ? [around[key]] : []
87+
);
88+
89+
sum.bubble.push(...min);
90+
sum.capture.unshift(...max);
91+
92+
return sum;
93+
}, {
94+
bubble: [] as Around<A>[typeof key][],
95+
capture: [] as Around<A>[typeof key][],
96+
});
97+
98+
let stack = combine(
99+
capture.concat(bubble) as Middleware<unknown[], unknown>[],
100+
);
101+
return stack(args, next);
102+
};
103+
104+
if (typeof api.core[key] === "function") {
105+
handle[key] = (...args: unknown[]) =>
106+
dispatch(args, api.core[key] as (...args: unknown[]) => unknown);
107+
} else {
108+
Object.defineProperty(handle, key, {
109+
enumerable: true,
110+
get() {
111+
return dispatch([], () => api.core[key]);
112+
},
113+
});
114+
}
115+
}
116+
return handle;
117+
}
118+
119+
function isOperation<T>(
120+
target: Operation<T> | T,
121+
): target is Operation<T> {
122+
return target && !isNativeIterable(target) &&
123+
typeof (target as Operation<T>)[Symbol.iterator] === "function";
124+
}
125+
126+
function isNativeIterable(target: unknown): boolean {
127+
return (
128+
typeof target === "string" || Array.isArray(target) ||
129+
target instanceof Map || target instanceof Set
130+
);
131+
}
132+
133+
function combine<TArgs extends unknown[], TReturn>(
134+
middlewares: Middleware<TArgs, TReturn>[],
135+
): Middleware<TArgs, TReturn> {
136+
if (middlewares.length === 0) {
137+
return (args, next) => next(...args);
138+
}
139+
return middlewares.reduceRight((sum, middleware) => (args, next) =>
140+
middleware(args, (...args) => sum(args, next))
141+
);
142+
}

lib/api.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
export const api = {
11+
Scope: createApi("Scope", {
12+
create() {
13+
throw new TypeError(`no handler for Scope.create()`);
14+
},
15+
*destroy() {},
16+
set(scope, context, value) {
17+
return (scope as ScopeInternal).contexts[context.name] = value;
18+
},
19+
delete(scope, context) {
20+
return delete (scope as ScopeInternal).contexts[context.name];
21+
},
22+
}),
23+
} as {
24+
Scope: Api<{
25+
create(parent: Scope): [Scope, () => Operation<void>];
26+
destroy(scope: Scope): Operation<void>;
27+
set<T>(scope: Scope, context: Context<T>, value: T): T;
28+
delete<T>(scope: Scope, context: Context<T>): boolean;
29+
}>;
30+
};

lib/scope-internal.ts

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1+
import type { ApiInternal } from "./api-internal.ts";
2+
import { api as effection } from "./api.ts";
13
import { Children, Priority } from "./contexts.ts";
24
import { Err, Ok, unbox } from "./result.ts";
35
import { createTask } from "./task.ts";
46
import type { Context, Operation, Scope, Task } from "./types.ts";
57
import { type WithResolvers, withResolvers } from "./with-resolvers.ts";
68

9+
const api = effection.Scope;
10+
711
export function createScopeInternal(
812
parent?: Scope,
13+
): [ScopeInternal, () => Operation<void>] {
14+
if (!parent) {
15+
let [global, destroy] = buildScopeInternal();
16+
global.around(api, {
17+
create([parent]) {
18+
return buildScopeInternal(parent);
19+
},
20+
});
21+
return [global, destroy] as const;
22+
} else {
23+
return api.invoke(parent, "create", [parent]) as [
24+
ScopeInternal,
25+
() => Operation<void>,
26+
];
27+
}
28+
}
29+
30+
export function buildScopeInternal(
31+
parent?: Scope,
932
): [ScopeInternal, () => Operation<void>] {
1033
let destructors = new Set<() => Operation<void>>();
1134

@@ -19,7 +42,7 @@ export function createScopeInternal(
1942
return (contexts[context.name] ?? context.defaultValue) as T | undefined;
2043
},
2144
set<T>(context: Context<T>, value: T): T {
22-
return contexts[context.name] = value;
45+
return api.invoke(scope, "set", [scope, context, value]) as T;
2346
},
2447
expect<T>(context: Context<T>): T {
2548
let value = scope.get(context);
@@ -31,7 +54,7 @@ export function createScopeInternal(
3154
return value;
3255
},
3356
delete<T>(context: Context<T>): boolean {
34-
return delete contexts[context.name];
57+
return api.invoke(scope, "delete", [scope, context]);
3558
},
3659
hasOwn<T>(context: Context<T>): boolean {
3760
return !!Reflect.getOwnPropertyDescriptor(contexts, context.name);
@@ -51,52 +74,99 @@ export function createScopeInternal(
5174
};
5275
},
5376

77+
around<A extends {}>(
78+
api: ApiInternal<A>,
79+
...params: Parameters<ApiInternal<A>["around"]>
80+
) {
81+
let [around, options] = params;
82+
if (!scope.hasOwn(api.context)) {
83+
scope.set(api.context, { bubble: [], capture: [] });
84+
}
85+
86+
let { bubble, capture } = scope.expect(api.context);
87+
88+
if (options?.capture) {
89+
capture.push(around);
90+
} else {
91+
bubble.push(around);
92+
}
93+
},
94+
5495
ensure(op: () => Operation<void>): () => void {
5596
destructors.add(op);
5697
return () => destructors.delete(op);
5798
},
99+
100+
reduce<T, S>(
101+
context: Context<T>,
102+
fn: (sum: S, item: T) => S,
103+
initial: S,
104+
): S {
105+
let sum = initial;
106+
let current = contexts;
107+
while (current) {
108+
if (Object.hasOwn(current, context.name)) {
109+
let item = current[context.name] as T;
110+
if (item) {
111+
sum = fn(sum, item);
112+
}
113+
}
114+
115+
current = Object.getPrototypeOf(current);
116+
}
117+
return sum;
118+
},
58119
});
59120

60121
scope.set(Priority, scope.expect(Priority) + 1);
61122
scope.set(Children, new Set());
62123
parent?.expect(Children).add(scope);
63124

125+
let destroy = () => api.invoke(scope, "destroy", [scope]);
126+
64127
let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {};
65128

66129
let destruction: WithResolvers<void> | undefined = undefined;
67130

68-
function* destroy(): Operation<void> {
69-
if (destruction) {
70-
return yield* destruction.operation;
71-
}
72-
destruction = withResolvers<void>();
73-
parent?.expect(Children).delete(scope);
74-
unbind();
75-
let outcome = Ok();
76-
try {
77-
for (let destructor of destructors) {
78-
try {
79-
destructors.delete(destructor);
80-
yield* destructor();
81-
} catch (error) {
82-
outcome = Err(error as Error);
83-
}
131+
scope.around(api, {
132+
*destroy(): Operation<void> {
133+
if (destruction) {
134+
return yield* destruction.operation;
84135
}
85-
} finally {
86-
if (outcome.ok) {
87-
destruction.resolve();
88-
} else {
89-
destruction.reject(outcome.error);
136+
destruction = withResolvers<void>();
137+
parent?.expect(Children).delete(scope);
138+
unbind();
139+
let outcome = Ok();
140+
try {
141+
for (let destructor of destructors) {
142+
try {
143+
destructors.delete(destructor);
144+
yield* destructor();
145+
} catch (error) {
146+
outcome = Err(error as Error);
147+
}
148+
}
149+
} finally {
150+
if (outcome.ok) {
151+
destruction.resolve();
152+
} else {
153+
destruction.reject(outcome.error);
154+
}
90155
}
91-
}
92156

93-
unbox(outcome);
94-
}
157+
unbox(outcome);
158+
},
159+
});
95160

96161
return [scope, destroy];
97162
}
98163

99164
export interface ScopeInternal extends Scope {
100165
contexts: Record<string, unknown>;
101166
ensure(op: () => Operation<void>): () => void;
167+
reduce<T, TSum>(
168+
context: Context<T>,
169+
fn: (sum: TSum, item: T) => TSum,
170+
initial: TSum,
171+
): TSum;
102172
}

0 commit comments

Comments
 (0)