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