| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import type * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import type { Input } from "./Input.ts"; |
| 6 | import * as Output from "./Output.ts"; |
| 7 | import { describeDataPlane, type DataPlaneResolution } from "./Provider.ts"; |
| 8 | import { isResource, type ResourceLike } from "./Resource.ts"; |
| 9 | import { Self } from "./Self.ts"; |
| 10 | import { taggedFunction } from "./Util/effect.ts"; |
| 11 | |
| 12 | export interface ServiceLike { |
| 13 | kind: "Service"; |
| 14 | } |
| 15 | |
| 16 | export 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 | |
| 22 | type 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 | */ |
| 66 | export 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 | */ |
| 116 | export 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 | */ |
| 178 | const 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 | |
| 243 | const 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 | */ |
| 266 | const 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 | */ |
| 309 | export const Host: Effect.Effect<ResourceLike | undefined> = |
| 310 | Effect.serviceOption( |
| 311 | Self as unknown as Context.Service<ResourceLike, ResourceLike>, |
| 312 | ).pipe(Effect.map(Option.getOrUndefined)); |