Skip to content
Source
Sign inSign up

Binding.ts

278 lines12.4 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import type { Input } from "./Input.ts";
6import * as Output from "./Output.ts";
7import { describeDataPlane, type DataPlaneResolution } from "./Provider.ts";
8import { isResource, type ResourceLike } from "./Resource.ts";
9import { Self } from "./Self.ts";
10import { taggedFunction } from "./Util/effect.ts";
11​
12export interface ServiceLike {
13 kind: "Service";
15​
16export interface ServiceShape<
17 Identifier extends string,
18 Shape extends (...args: any[]) => Effect.Effect<any, any, any>,
20 extends Context.ServiceClass.Shape<Identifier, Shape>, ServiceLike {}
21​
22type BindParameters<Parameters extends any[], Req = never> = Parameters extends []
23 ? []
24 : // Variadic lists (`number extends length`) — e.g. `(...parameters:
25 // [Parameter, ...Parameter[]])` — must be checked FIRST: a plain array
26 // also matches the optional-head pattern below with itself as the rest,
27 // which recurses forever (TS2589).
28 number extends Parameters["length"]
29 ? Parameters extends [infer First, ...infer Rest]
30 ? [
31 Input<First> | Effect.Effect<First, never, Req>,
32 ...Array<Input<Rest[number]> | Effect.Effect<Rest[number], never, Req>>,
33 ]
34 : Array<Input<Parameters[number]> | Effect.Effect<Parameters[number], never, Req>>
35 : Parameters extends [infer First, ...infer Rest]
36 ? [Input<First> | Effect.Effect<First, never, Req>, ...BindParameters<Rest, Req>]
37 : // Optional head (e.g. `(bus?: EventBus)`) — `[infer F, ...R]` does
38 // not match a tuple with an optional first element, which used to
39 // collapse the whole parameter list to `[]` (`PutEvents(bus)` failed
40 // with "Expected 0 arguments").
41 Parameters extends [(infer First)?, ...infer Rest]
42 ? [(Input<First> | Effect.Effect<First, never, Req>)?, ...BindParameters<Rest, Req>]
43 : [];
44​
45/**
46 * The combined tag + callable + type form of a binding (the `Resource.ts`-style
47 * single-identifier pattern). `interface X extends Binding.Service<X, Id, Shape>`
48 * declares the type; `const X = Binding.Service<X>(id)` produces a value that is at
49 * once the Context tag (usable in `Layer.effect(X, …)` / `Effect.provide`), the
50 * callable (`X(resource)`), and carries the type.
51 */
52export interface Service<
53 Self,
54 Identifier extends string,
55 Shape extends (...args: any[]) => Effect.Effect<any, any, any>,
57 extends Context.Service<Self, Shape>, ServiceLike {
58 readonly key: Identifier;
59 new (_: never): ServiceShape<Identifier, Shape>;
60 <Req = never>(
61 ...args: BindParameters<Parameters<Shape>, Req>
62 ): Effect.Effect<
63 Effect.Success<ReturnType<Shape>>,
64 Effect.Error<ReturnType<Shape>>,
65 Self | Effect.Services<ReturnType<Shape>> | Req
66 >;
67 /**
68 * Invoke this capability at plan/deploy time as a **data source** — the
69 * Terraform data-source / Pulumi invoke shape — and get an
70 * {@link Output.Output} of the result.
71 *
72 * The returned Output is inert until the planner resolves it (with the
73 * stack's services provided), so `execute` is safe to call from
74 * composition code that is re-executed inside a deployed runtime bundle.
75 * The capability's implementation layer must be registered on the stack
76 * (cloud `providers()` layers include their plan-executable capabilities).
77 *
78 * Execution is hostless — {@link Host} resolves `undefined`, so
79 * implementations skip their `host.bind` IAM/env wiring and only the read
80 * runs. Failures die and fail the plan.
81 *
82 * Only capabilities whose runtime client is nullary (`() => Effect<A>`)
83 * are executable; parameterized clients type as `never` here.
84 */
85 execute<Req = never>(
86 ...args: BindParameters<Parameters<Shape>, Req>
87 ): Effect.Success<ReturnType<Shape>> extends () => Effect.Effect<infer A, infer _E, infer R2>
88 ? Output.ToOutput<A, Self | Effect.Services<ReturnType<Shape>> | R2 | Req>
89 : never;
91​
92/**
93 * Build a combined tag+callable binding (see {@link Service}). The returned
94 * value forwards the Effect/Tag protocol to its Context tag (via `taggedFunction`)
95 * so `Layer.effect`/`provide` work, while being directly callable to bind a
96 * resource at the call site.
97 */
98export const Service = <
99 Self extends ServiceLike & {
100 readonly key: string;
101 },
102>(
103 id: Self["key"],
104): Self => {
105 // Tag key is the caller-supplied `id`, so it can't be a class declaration.
106 // oxlint-disable-next-line effecttsgo/service-not-as-class
107 const tag = Context.Service<Self, (...args: any[]) => Effect.Effect<any>>(id);
108 const callable = (...args: any[]) =>
109 tag.use((f: (...a: any[]) => Effect.Effect<any>) =>
110 Effect.forEach(args, (arg) => (Effect.isEffect(arg) ? arg : Effect.succeed(arg)), {
111 concurrency: "unbounded",
112 }).pipe(
113 Effect.flatMap((resolved) =>
114 f(...resolved).pipe(
115 // Deploy-time data-plane routing: the client's calls must target
116 // whatever data plane the bound resource actually lives on. In an
117 // `alchemy dev` run a local-mode resource exists only on the
118 // emulator, so invoking its client against the ambient (live)
119 // cloud environment would miss it — or worse, mutate the real
120 // cloud. See {@link routeClientDataPlane}.
121 Effect.flatMap((client) => routeClientDataPlane(resolved, client)),
122 ),
123 ),
124 ),
125 );
126 // Plan-time invoke (see the `execute` doc on `Service`): bind hostless —
127 // `Binding.Host` is total and resolves `undefined`, so impls skip their
128 // `host.bind` wiring — run the nullary client, and lift the result into an
129 // Output the planner resolves with the stack's services.
130 (callable as any).execute = (...args: any[]) =>
131 Output.fromEffect(
132 callable(...args).pipe(
133 Effect.flatMap((client: any) => client()),
134 Effect.orDie,
135 ) as Effect.Effect<any, never, any>,
136 );
137 return taggedFunction(tag as any, callable) as unknown as Self;
138};
139​
140/**
141 * Route a binding client's invocations to the data plane its bound
142 * resource(s) actually live on.
143 *
144 * A dual-provider resource in an `alchemy dev` run resolves to its LOCAL
145 * provider (unless pinned live via `Alchemy.remote()`), so the physical
146 * resource exists only on the local emulator. A client invoked at deploy
147 * time — inside an {@link ../Action.ts Action} body or a plan-time
148 * {@link Service.execute} — would otherwise resolve the ambient (live)
149 * cloud environment and miss it, or mutate the real cloud. The bound
150 * resource's provider registers the emulator context as
151 * {@link ProviderService.localDataPlane} (e.g. AWS's `flociServices()`);
152 * this wrapper provides it *closest* around every invocation, so it wins
153 * over the ambient environment exactly like the local provider's lifecycle
154 * override does.
155 *
156 * Resolution happens once per bind, in the same context the bind step ran
157 * in (the Provider registry and `AlchemyContext` are ambient during stack
158 * evaluation and apply). At runtime inside a deployed Function/Worker there
159 * is no registry and no engine — the wrapper is skipped entirely.
160 */
161const routeClientDataPlane = (
162 resolvedArgs: readonly unknown[],
163 client: unknown,
164): Effect.Effect<any> =>
165 Effect.suspend(() => {
166 if (globalThis.__ALCHEMY_RUNTIME__) return Effect.succeed(client);
167 // Bind arguments are the capability's target resources by convention —
168 // scan the top level plus one array level (multi-resource bindings like
169 // ExecuteTransaction take tuples of resources).
170 const resources = resolvedArgs.flatMap((arg): ResourceLike[] =>
171 Array.isArray(arg) ? arg.filter(isResource) : isResource(arg) ? [arg] : [],
172 );
173 // Account-scoped clients (no resource among the args) need no routing:
174 // the ambient environment is already mode-aware — a dev run's ambient IS
175 // the emulator (see AWS/Providers.ts), a deploy's is the live chain.
176 if (resources.length === 0) return Effect.succeed(client);
177 return Effect.gen(function* () {
178 const planes: DataPlaneResolution[] = [];
179 for (const resource of resources) {
180 planes.push(yield* describeDataPlane(resource));
181 }
182 const local = [...new Set(planes.flatMap((p) => (p.kind === "local" ? [p.layer] : [])))];
183 if (local.length > 0) {
184 if (local.length > 1 || planes.some((p) => p.kind !== "local")) {
185 // Say exactly where each resource lands: a dual provider that never
186 // registered a data plane is the usual culprit, and it would
187 // otherwise read as "live" — the report that sent a user chasing a
188 // `remote()` they never wrote.
189 const where = resources
190 .map((r, i) => `${r.FQN} → ${describeResolution(planes[i]!)}`)
191 .join("; ");
192 return yield* Effect.die(
193 `Binding client spans mixed data planes: ${where}. A single API ` +
194 "call cannot span the local emulator and the real cloud. If a " +
195 "resource above is meant to be emulated, its provider must " +
196 "declare `dataPlane` on its ProviderLayer.dual registration; " +
197 "otherwise make the bound resources' modes agree (e.g. pipe them " +
198 "all through Alchemy.remote(), or none).",
199 );
200 }
201 return wrapClientInvocations(client, local[0]!);
202 }
203 // Live-mode / `Alchemy.remote()`: ambient in a `dev` run is the
204 // emulator, so provide the registration-captured live environment
205 // closest (the inverse of the local wrap above).
206 const live = [
207 ...new Set(
208 planes.flatMap((p) => (p.kind === "live" && p.layer !== undefined ? [p.layer] : [])),
209 ),
210 ];
211 if (live.length === 1) return wrapClientInvocations(client, live[0]!);
212 return client;
213 });
214 });
215​
216const describeResolution = (plane: DataPlaneResolution): string => {
217 switch (plane.kind) {
218 case "local":
219 return "local emulator";
220 case "live":
221 return "real cloud (live mode)";
222 case "undeclared":
223 return `real cloud (its provider ${plane.providerType} is dual-mode but registers no local data plane)`;
224 case "agnostic":
225 return "real cloud (mode-agnostic provider)";
226 case "unregistered":
227 return "real cloud (no provider registered)";
228 }
229};
230​
231/**
232 * Wrap every invocation surface of a binding client so its returned Effects
233 * run with `layer` provided closest. Handles the client shapes bindings
234 * return: a callable (the common per-operation client), an object of
235 * methods/Effects (multi-method clients), or a bare Effect. Anything else
236 * passes through untouched. Proxies preserve identity, extra properties,
237 * and method names.
238 */
239const wrapClientInvocations = (client: unknown, layer: Layer.Layer<any, any, never>): unknown => {
240 const provide = (value: unknown): unknown =>
241 Effect.isEffect(value) ? Effect.provide(value as Effect.Effect<any, any, any>, layer) : value;
242 if (Effect.isEffect(client)) return provide(client);
243 if (typeof client === "function") {
244 return new Proxy(client, {
245 apply: (target, thisArg, argArray) => provide(Reflect.apply(target, thisArg, argArray)),
246 });
247 }
248 if (typeof client === "object" && client !== null) {
249 return new Proxy(client, {
250 get: (target, prop, receiver) => {
251 const value = Reflect.get(target, prop, receiver);
252 if (typeof value === "function") {
253 return (...args: any[]) => provide(Reflect.apply(value, target, args));
254 }
255 return provide(value);
256 },
257 });
258 }
259 return client;
260};
261​
262/**
263 * Resolves the host resource a binding is attaching to (the Worker / Lambda
264 * Function), i.e. `Self`. It is typed WITHOUT a Context requirement because it
265 * is only ever read at DEPLOY time, inside the `if (!globalThis.__ALCHEMY_RUNTIME__)`
266 * guard of a binding's impl layer — at runtime the host is absent and the guard
267 * skips it, so leaking a `Self` requirement onto the runtime client would be
268 * wrong.
269 *
270 * Total: resolves to `undefined` when no host is ambient — a plan-time
271 * {@link Service.execute} invoke, or a binding client provided directly in a
272 * script/test outside any Function. Providers narrow it before calling
273 * `host.bind` (e.g. Cloudflare's `isWorker`, or `AWSBindings.runsAsRole`);
274 * both reject `undefined`.
275 */
276export const Host: Effect.Effect<ResourceLike | undefined> = Effect.serviceOption(
277 Self as unknown as Context.Service<ResourceLike, ResourceLike>,
278).pipe(Effect.map(Option.getOrUndefined));