| 1 | import * as Config from "effect/Config"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { pipe } from "effect/Function"; |
| 5 | import type { Pipeable } from "effect/Pipeable"; |
| 6 | import { SingleShotGen } from "effect/Utils"; |
| 7 | import { getRefMetadata, isRef, type Ref } from "./Ref.ts"; |
| 8 | import { isResource, type Resource, type ResourceLike } from "./Resource.ts"; |
| 9 | import { RuntimeContext, sanitizeKey } from "./RuntimeContext.ts"; |
| 10 | import { Stack } from "./Stack.ts"; |
| 11 | import { Stage } from "./Stage.ts"; |
| 12 | import * as State from "./State/State.ts"; |
| 13 | import { isPlainData, isPrimitive, type Primitive } from "./Util/data.ts"; |
| 14 | |
| 15 | const inspect = Symbol.for("nodejs.util.inspect.custom"); |
| 16 | |
| 17 | export const of = <R extends ResourceLike>( |
| 18 | resource: Ref<R> | R, |
| 19 | ): R extends ResourceLike |
| 20 | ? ResourceExpr<R["Attributes"]> |
| 21 | : RefExpr<R["Attributes"]> => { |
| 22 | if (isRef(resource)) { |
| 23 | const metadata = getRefMetadata(resource); |
| 24 | return new RefExpr( |
| 25 | metadata.stack, |
| 26 | metadata.stage, |
| 27 | metadata.id, |
| 28 | // Surface the target's resource type and logical id as |
| 29 | // statically-known properties so duck-typing classifiers (Worker |
| 30 | // env bindings) and label/bind templates (`${resource.LogicalId}`, |
| 31 | // `host.bind\`...\``) identify the ref exactly like a |
| 32 | // locally-declared resource. |
| 33 | { |
| 34 | LogicalId: metadata.id, |
| 35 | ...(metadata.type !== undefined ? { Type: metadata.type } : {}), |
| 36 | }, |
| 37 | ) as any; |
| 38 | } |
| 39 | return new ResourceExpr(resource) as any; |
| 40 | }; |
| 41 | |
| 42 | export const asOutput = <T>(t: T | Output<T> | Effect.Effect<T>): Output<T> => |
| 43 | isOutput(t) |
| 44 | ? t |
| 45 | : Effect.isEffect(t) |
| 46 | ? new EffectExpr(VoidExpr, () => t) |
| 47 | : new LiteralExpr(t); |
| 48 | |
| 49 | /** |
| 50 | * Lift a plan-time Effect into an {@link Output}. |
| 51 | * |
| 52 | * The effect runs when the stack resolves the Output during plan/deploy — |
| 53 | * with the stack's services (cloud credentials, region, ...) provided — and |
| 54 | * never inside a deployed runtime: constructing the Output is inert, so |
| 55 | * helpers built on `fromEffect` (e.g. AMI lookups) are safe to call from |
| 56 | * composition code that is re-executed inside a Function/Worker/Instance |
| 57 | * bundle. |
| 58 | * |
| 59 | * The effect must not fail (`E = never`) — die with a descriptive error for |
| 60 | * unresolvable lookups. |
| 61 | */ |
| 62 | export const fromEffect = <A, Req = never>( |
| 63 | effect: Effect.Effect<A, never, Req>, |
| 64 | ): ToOutput<A, Req> => new EffectExpr(VoidExpr, () => effect) as any; |
| 65 | |
| 66 | export const isOutput = (value: any): value is Output<any> => |
| 67 | value && |
| 68 | (typeof value === "object" || typeof value === "function") && |
| 69 | ExprSymbol in value; |
| 70 | |
| 71 | export interface Output<A = any, Req = any> extends Pipeable { |
| 72 | /** @internal phantom */ |
| 73 | readonly kind: string; |
| 74 | /** @internal phantom */ |
| 75 | readonly A: A; |
| 76 | /** @internal phantom */ |
| 77 | readonly req: Req; |
| 78 | /** @internal phantom */ |
| 79 | [Symbol.iterator](): Iterator< |
| 80 | Effect.Effect<void, never, Req>, |
| 81 | Accessor<A>, |
| 82 | void |
| 83 | >; |
| 84 | bind(id: string): Effect.Effect<Effect.Effect<A>, never, RuntimeContext>; |
| 85 | asEffect(): Effect.Effect<Accessor<A>, never, Req>; |
| 86 | as<T>(): Output<T, Req>; |
| 87 | } |
| 88 | |
| 89 | export interface Accessor<A> extends Effect.Effect<A> {} |
| 90 | |
| 91 | export type ToOutput<A, Req = never> = |
| 92 | // Branded primitives (`string & Brand<"...">`) are assignable to `object` |
| 93 | // via the brand intersection, so they must short-circuit to a plain Output |
| 94 | // before the object check — otherwise they explode into an ObjectExpr |
| 95 | // mapped over every String/Number method. Date is opaque for the same |
| 96 | // reason (mirrors AttrOutput in Resource.ts). |
| 97 | [A] extends [Primitive | Date] |
| 98 | ? Output<A, Req> |
| 99 | : [Extract<A, object>] extends [never] |
| 100 | ? Output<A, Req> |
| 101 | : [Extract<A, any[]>] extends [never] |
| 102 | ? ObjectExpr< |
| 103 | { |
| 104 | [attr in keyof A]: A[attr]; |
| 105 | }, |
| 106 | Req |
| 107 | > |
| 108 | : ArrayExpr<Extract<A, any[]>, Req>; |
| 109 | |
| 110 | export const ExprSymbol = Symbol.for("alchemy/Expr"); |
| 111 | |
| 112 | const exprKind = (node: any): unknown => node?.[ExprSymbol]?.kind ?? node?.kind; |
| 113 | |
| 114 | export const isExpr = (value: any): value is Expr<any> => |
| 115 | value && |
| 116 | (typeof value === "object" || typeof value === "function") && |
| 117 | ExprSymbol in value; |
| 118 | |
| 119 | export type Expr<A = any, Req = any> = |
| 120 | | AllExpr<Expr<A, Req>[]> |
| 121 | | ApplyExpr<any, A, Req> |
| 122 | | EffectExpr<any, A, Req> |
| 123 | | FlatMapExpr<any, A, Req> |
| 124 | | LiteralExpr<A> |
| 125 | | NamedExpr<A, Req> |
| 126 | | PropExpr<A, keyof A, Req> |
| 127 | | ResourceExpr<A, Req> |
| 128 | | RefExpr<A> |
| 129 | | StackRefExpr<A>; |
| 130 | |
| 131 | export abstract class BaseExpr<A = any, Req = any> implements Output<A, Req> { |
| 132 | declare readonly kind: any; |
| 133 | declare readonly A: A; |
| 134 | declare readonly src: ResourceLike; |
| 135 | declare readonly req: Req; |
| 136 | // we use a kind tag instead of instanceof to protect ourselves from duplicate alchemy module imports |
| 137 | constructor() {} |
| 138 | as<T>(): Output<T, Req> { |
| 139 | return this as any; |
| 140 | } |
| 141 | |
| 142 | [Symbol.iterator](): Iterator< |
| 143 | Effect.Effect<void, never, Req>, |
| 144 | Accessor<A>, |
| 145 | void |
| 146 | > { |
| 147 | // @ts-expect-error - TODO(sam): fix this (works at runtime, but maybe indicates a bad assumption) |
| 148 | return new SingleShotGen(this.asEffect()); |
| 149 | } |
| 150 | |
| 151 | asEffect(): any { |
| 152 | return this.bind(this.toString()); |
| 153 | } |
| 154 | |
| 155 | public bind(id: string): any { |
| 156 | // `set`/`get` store keys verbatim, so canonicalize here (the caller's job). |
| 157 | const key = sanitizeKey(id); |
| 158 | return RuntimeContext.pipe( |
| 159 | Effect.flatMap((ctx) => |
| 160 | Effect.map(ctx.set(key, this), (k) => ctx.get<A>(k)), |
| 161 | ), |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | public pipe(...fns: any[]): any { |
| 166 | // @ts-expect-error |
| 167 | return pipe(this, ...fns); |
| 168 | } |
| 169 | public abstract [inspect](): string; |
| 170 | public toString(): string { |
| 171 | return this[inspect](); |
| 172 | } |
| 173 | } |
| 174 | export type ObjectExpr<A, Req = any> = Output<A, Req> & { |
| 175 | [Prop in keyof Exclude<A, undefined>]-?: ToOutput< |
| 176 | Exclude<A, undefined>[Prop] | Extract<A, undefined>, |
| 177 | Req |
| 178 | >; |
| 179 | }; |
| 180 | |
| 181 | export type ArrayExpr<A extends any[], Req = any> = Output<A, Req> & { |
| 182 | [i in Extract<keyof A, number>]: ToOutput<A[i], Req>; |
| 183 | }; |
| 184 | |
| 185 | export const isResourceExpr = <Value = any, Req = any>( |
| 186 | node: Expr<Value, Req> | any, |
| 187 | ): node is ResourceExpr<Value, Req> => exprKind(node) === "ResourceExpr"; |
| 188 | |
| 189 | export class ResourceExpr<Value, Req = never> extends BaseExpr<Value, Req> { |
| 190 | readonly kind = "ResourceExpr"; |
| 191 | constructor( |
| 192 | readonly src: ResourceLike, |
| 193 | readonly stables?: Record<string, any>, |
| 194 | ) { |
| 195 | super(); |
| 196 | return proxy(this); |
| 197 | } |
| 198 | [inspect](): string { |
| 199 | return this.src.LogicalId; |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | export const isPropExpr = <A = any, Prop extends keyof A = keyof A, Req = any>( |
| 204 | node: any, |
| 205 | ): node is PropExpr<A, Prop, Req> => exprKind(node) === "PropExpr"; |
| 206 | |
| 207 | export class PropExpr< |
| 208 | A = any, |
| 209 | Id extends keyof A = keyof A, |
| 210 | Req = any, |
| 211 | > extends BaseExpr<A[Id], Req> { |
| 212 | readonly kind = "PropExpr"; |
| 213 | constructor( |
| 214 | public readonly expr: Expr<A, Req>, |
| 215 | public readonly identifier: Id, |
| 216 | ) { |
| 217 | super(); |
| 218 | return proxy(this); |
| 219 | } |
| 220 | [inspect](): string { |
| 221 | return `${this.expr[inspect]()}.${this.identifier.toString()}`; |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | export const literal = <A>(value: A) => new LiteralExpr(value); |
| 226 | |
| 227 | export const isLiteralExpr = <A = any>(node: any): node is LiteralExpr<A> => |
| 228 | exprKind(node) === "LiteralExpr"; |
| 229 | |
| 230 | export class LiteralExpr<A> extends BaseExpr<A, never> { |
| 231 | readonly kind = "LiteralExpr"; |
| 232 | constructor(public readonly value: A) { |
| 233 | super(); |
| 234 | return proxy(this); |
| 235 | } |
| 236 | [inspect](): string { |
| 237 | return String(this.value); |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | export const VoidExpr = new LiteralExpr(void 0); |
| 242 | |
| 243 | export const map: { |
| 244 | <A, B>( |
| 245 | fn: (value: A) => B, |
| 246 | ): <Req>(output: Output<A, Req>) => ToOutput<B, Req>; |
| 247 | <A, B, Req>(output: Output<A, Req>, fn: (value: A) => B): ToOutput<B, Req>; |
| 248 | } = (<A, B, Req>( |
| 249 | ...args: [fn: (value: A) => B] | [output: Output<A, Req>, fn: (value: A) => B] |
| 250 | ) => |
| 251 | args.length === 1 |
| 252 | ? <Req>(output: Output<A, Req>): ToOutput<B, Req> => |
| 253 | new ApplyExpr(output as Expr<A, Req>, args[0]) as any |
| 254 | : new ApplyExpr(args[0] as any, args[1])) as any; |
| 255 | |
| 256 | //Output.ApplyExpr<any, any, ResourceLike, any> |
| 257 | export const isApplyExpr = <In = any, Out = any, Req = any>( |
| 258 | node: Output<Out, Req>, |
| 259 | ): node is ApplyExpr<In, Out, Req> => exprKind(node) === "ApplyExpr"; |
| 260 | |
| 261 | export class ApplyExpr<A, B, Req = never> extends BaseExpr<B, Req> { |
| 262 | readonly kind = "ApplyExpr"; |
| 263 | constructor( |
| 264 | public readonly expr: Expr<A, Req>, |
| 265 | public readonly f: (value: A) => B, |
| 266 | ) { |
| 267 | super(); |
| 268 | return proxy(this); |
| 269 | } |
| 270 | |
| 271 | [inspect](): string { |
| 272 | return `${this.expr[inspect]()}.map(${this.f.toString()})`; |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | export const mapEffect = |
| 277 | <A, B, Req2>(fn: (value: A) => Effect.Effect<B, never, Req2>) => |
| 278 | <Req>(output: Output<A, Req>): ToOutput<B, Req | Req2> => |
| 279 | new EffectExpr(output as Expr<A, Req>, fn) as any; |
| 280 | |
| 281 | export const flatMap: { |
| 282 | <A, B, Req2>( |
| 283 | fn: (value: A) => Output<B, Req2>, |
| 284 | ): <Req>(output: Output<A, Req>) => ToOutput<B, Req | Req2>; |
| 285 | <A, B, Req, Req2>( |
| 286 | output: Output<A, Req>, |
| 287 | fn: (value: A) => Output<B, Req2>, |
| 288 | ): ToOutput<B, Req | Req2>; |
| 289 | } = (<A, B, Req, Req2>( |
| 290 | ...args: |
| 291 | | [fn: (value: A) => Output<B, Req2>] |
| 292 | | [output: Output<A, Req>, fn: (value: A) => Output<B, Req2>] |
| 293 | ) => |
| 294 | args.length === 1 |
| 295 | ? <Req>(output: Output<A, Req>): ToOutput<B, Req | Req2> => |
| 296 | new FlatMapExpr(output as Expr<A, Req>, args[0]) as any |
| 297 | : new FlatMapExpr(args[0] as any, args[1])) as any; |
| 298 | |
| 299 | export const isFlatMapExpr = <In = any, Out = any, Req = any, Req2 = any>( |
| 300 | node: any, |
| 301 | ): node is FlatMapExpr<In, Out, Req, Req2> => exprKind(node) === "FlatMapExpr"; |
| 302 | |
| 303 | export class FlatMapExpr<A, B, Req = never, Req2 = never> extends BaseExpr< |
| 304 | B, |
| 305 | Req | Req2 |
| 306 | > { |
| 307 | readonly kind = "FlatMapExpr"; |
| 308 | constructor( |
| 309 | public readonly expr: Expr<A, Req>, |
| 310 | public readonly f: (value: A) => Output<B, Req2>, |
| 311 | ) { |
| 312 | super(); |
| 313 | return proxy(this); |
| 314 | } |
| 315 | [inspect](): string { |
| 316 | return `${this.expr[inspect]()}.flatMap(${this.f.toString()})`; |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | export const isEffectExpr = <In = any, Out = any, Req = any, Req2 = any>( |
| 321 | node: any, |
| 322 | ): node is EffectExpr<In, Out, Req, Req2> => exprKind(node) === "EffectExpr"; |
| 323 | |
| 324 | export class EffectExpr<A, B, Req = never, Req2 = never> extends BaseExpr< |
| 325 | B, |
| 326 | Req |
| 327 | > { |
| 328 | readonly kind = "EffectExpr"; |
| 329 | constructor( |
| 330 | public readonly expr: Expr<A, Req>, |
| 331 | public readonly f: (value: A) => Effect.Effect<B, never, Req2>, |
| 332 | ) { |
| 333 | super(); |
| 334 | return proxy(this); |
| 335 | } |
| 336 | [inspect](): string { |
| 337 | return `${this.expr[inspect]()}.mapEffect(${this.f.toString()})`; |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | export const isNamedExpr = <A = any, Req = any>( |
| 342 | node: any, |
| 343 | ): node is NamedExpr<A, Req> => exprKind(node) === "NamedExpr"; |
| 344 | |
| 345 | /** |
| 346 | * Wraps another `Expr` and overrides its `toString()` / inspect output. |
| 347 | * |
| 348 | * `BaseExpr` derives the binding id from `this.toString()`, so |
| 349 | * wrapping an expression in `NamedExpr` makes that derived id stable and |
| 350 | * caller-controlled (e.g. an env var name like `"API_KEY"`). |
| 351 | */ |
| 352 | export class NamedExpr<A, Req = never> extends BaseExpr<A, Req> { |
| 353 | readonly kind = "NamedExpr"; |
| 354 | constructor( |
| 355 | public readonly expr: Expr<A, Req>, |
| 356 | public readonly bindingName: string, |
| 357 | ) { |
| 358 | super(); |
| 359 | return proxy(this); |
| 360 | } |
| 361 | [inspect](): string { |
| 362 | return this.bindingName; |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | export const named = <A, Req>( |
| 367 | expr: Output<A, Req>, |
| 368 | name: string, |
| 369 | ): Output<A, Req> => new NamedExpr(expr as Expr<A, Req>, name) as any; |
| 370 | |
| 371 | export const all = <Outs extends (Output | Expr)[]>(...outs: Outs) => |
| 372 | new AllExpr(outs as any) as unknown as All<Outs>; |
| 373 | |
| 374 | export type All<Outs extends (Output | Expr)[]> = number extends Outs["length"] |
| 375 | ? [Outs[number]] extends [ |
| 376 | Output<infer V, infer Req> | Expr<infer V, infer Req>, |
| 377 | ] |
| 378 | ? Output<V, Req> |
| 379 | : never |
| 380 | : Tuple<Outs>; |
| 381 | |
| 382 | type Tuple< |
| 383 | Outs extends (Output | Expr)[], |
| 384 | Values extends any[] = [], |
| 385 | Req = never, |
| 386 | > = Outs extends [infer H, ...infer Tail extends (Output | Expr)[]] |
| 387 | ? H extends Output<infer V, infer Req2> |
| 388 | ? Tuple<Tail, [...Values, V], Req | Req2> |
| 389 | : never |
| 390 | : Output<Values, Req>; |
| 391 | |
| 392 | export const isAllExpr = <Outs extends Expr[] = Expr[]>( |
| 393 | node: any, |
| 394 | ): node is AllExpr<Outs> => exprKind(node) === "AllExpr"; |
| 395 | |
| 396 | export class AllExpr<Outs extends Expr[]> extends BaseExpr<Outs> { |
| 397 | readonly kind = "AllExpr"; |
| 398 | constructor(public readonly outs: Outs) { |
| 399 | super(); |
| 400 | return proxy(this); |
| 401 | } |
| 402 | [inspect](): string { |
| 403 | return `all(${this.outs.map((out) => out[inspect]()).join(", ")})`; |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | export const isRefExpr = <A = any>(node: any): node is RefExpr<A> => |
| 408 | exprKind(node) === "RefExpr"; |
| 409 | |
| 410 | export class RefExpr<A> extends BaseExpr<A, never> { |
| 411 | readonly kind = "RefExpr"; |
| 412 | constructor( |
| 413 | public readonly stack: string | undefined, |
| 414 | public readonly stage: string | undefined, |
| 415 | public readonly resourceId: string, |
| 416 | /** |
| 417 | * Statically-known properties of the ref's target (currently its |
| 418 | * resource `Type`), served as literals by the proxy instead of |
| 419 | * `PropExpr`s — mirrors {@link ResourceExpr}'s `stables`. |
| 420 | */ |
| 421 | readonly stables?: Record<string, any>, |
| 422 | ) { |
| 423 | super(); |
| 424 | return proxy(this); |
| 425 | } |
| 426 | [inspect](): string { |
| 427 | return `ref(${this.resourceId}, { stack: ${this.stack}, stage: ${this.stage} })`; |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | export const isStackRefExpr = <A = any>(node: any): node is StackRefExpr<A> => |
| 432 | exprKind(node) === "StackRefExpr"; |
| 433 | |
| 434 | /** |
| 435 | * A reference to the persisted output of a Stack at `(stack, stage)`. |
| 436 | * |
| 437 | * Resolved at evaluation time by reading `state.getOutput({ stack, |
| 438 | * stage })`. Distinct from {@link RefExpr}, which references a |
| 439 | * single resource's attributes within a stack/stage. `stage` may be |
| 440 | * `undefined`, in which case it falls back to the current stage. |
| 441 | */ |
| 442 | export class StackRefExpr<A> extends BaseExpr<A, never> { |
| 443 | readonly kind = "StackRefExpr"; |
| 444 | constructor( |
| 445 | public readonly stack: string, |
| 446 | public readonly stage: string | undefined, |
| 447 | ) { |
| 448 | super(); |
| 449 | return proxy(this); |
| 450 | } |
| 451 | [inspect](): string { |
| 452 | return `stackRef(${this.stack}${ |
| 453 | this.stage ? `, { stage: ${this.stage} }` : "" |
| 454 | })`; |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | /** |
| 459 | * Build an `Output<A>` referencing the persisted output of another |
| 460 | * Stack. The returned Effect resolves to a lazy `Output<A>` whose |
| 461 | * value is read from the state store at plan/apply time. |
| 462 | * |
| 463 | * Returns `Effect<Output<A>>` (not `Output<A>` directly) so that |
| 464 | * `yield* Output.stackRef(...)` reads ergonomically inside an Effect |
| 465 | * generator and lines up with `Resource.ref` and `Stack.stage.<name>`. |
| 466 | */ |
| 467 | export const stackRef = <A>( |
| 468 | stack: string, |
| 469 | options: { stage?: string } = {}, |
| 470 | ): Effect.Effect<Output<A, never>> => |
| 471 | Effect.succeed(new StackRefExpr<A>(stack, options.stage) as any); |
| 472 | |
| 473 | export const filter = <Outs extends any[]>(...outs: Outs) => |
| 474 | outs.filter(isOutput) as unknown as Filter<Outs>; |
| 475 | |
| 476 | export type Filter<Outs extends any[]> = number extends Outs["length"] |
| 477 | ? Output< |
| 478 | Extract<Outs[number], Output>["value"], |
| 479 | Extract<Outs[number], Output>["req"] |
| 480 | > |
| 481 | : FilterTuple<Outs>; |
| 482 | |
| 483 | export type FilterTuple< |
| 484 | Outs extends (Output | Expr)[], |
| 485 | Values extends any[] = [], |
| 486 | > = Outs extends [infer H, ...infer Tail extends (Output | Expr)[]] |
| 487 | ? H extends Output<infer V> |
| 488 | ? FilterTuple<Tail, [...Values, V]> |
| 489 | : FilterTuple<Tail, Values> |
| 490 | : Output<Values>; |
| 491 | |
| 492 | export const interpolate = <Args extends any[]>( |
| 493 | template: TemplateStringsArray, |
| 494 | ...args: Args |
| 495 | ): All<Args> extends Output<any, infer Req> ? Output<string, Req> : never => { |
| 496 | const outs = args.map((arg) => (isOutput(arg) ? arg : literal(arg))); |
| 497 | const expr = all(...outs).pipe( |
| 498 | map((args) => |
| 499 | template |
| 500 | .map((str, i) => str + (args[i] == null ? "" : String(args[i]))) |
| 501 | .join(""), |
| 502 | ), |
| 503 | ); |
| 504 | // The binding id of a mapped Output embeds the mapper's source, which the |
| 505 | // runtime bundler reprints differently from the deploying process, so the |
| 506 | // two sides would derive different keys. Name it from the template and its |
| 507 | // arguments instead, which are identical on both sides. |
| 508 | const name = template |
| 509 | .map((str, i) => |
| 510 | i < outs.length ? `${str}\${${(outs[i] as any)[inspect]()}}` : str, |
| 511 | ) |
| 512 | .join(""); |
| 513 | return named(expr as Output<string, any>, `interpolate(${name})`) as any; |
| 514 | }; |
| 515 | |
| 516 | function proxy(self: any): any { |
| 517 | const target = Object.assign(() => {}, self); |
| 518 | if (inspect in self) { |
| 519 | Object.defineProperty(target, inspect, { |
| 520 | value: self[inspect].bind(self), |
| 521 | configurable: true, |
| 522 | }); |
| 523 | } |
| 524 | const proxy = new Proxy(target, { |
| 525 | has: (_, prop) => |
| 526 | prop === ExprSymbol || prop === inspect |
| 527 | ? true |
| 528 | : // Statically-known literal props (`Type`, `LogicalId` on |
| 529 | // resource/ref exprs) are visible to `in` checks so duck-typing |
| 530 | // code paths (e.g. `"LogicalId" in arg`) treat them like real |
| 531 | // properties, matching what `get` serves. |
| 532 | ((isResourceExpr(self) || isRefExpr(self)) && |
| 533 | self.stables !== undefined && |
| 534 | prop in self.stables) || |
| 535 | prop in self, |
| 536 | get: (target, prop) => |
| 537 | prop === Symbol.toPrimitive |
| 538 | ? (hint: string) => { |
| 539 | // Any JS-level coercion of an unresolved Output produces a |
| 540 | // placeholder that *looks* like a real value but isn't: |
| 541 | // |
| 542 | // - `string` / `default` hints (`${output}`, `output + ""`, |
| 543 | // `==` against a primitive) previously fell through to |
| 544 | // `self.toString()` and returned the inspect form |
| 545 | // (e.g. "tunnel.tunnelId"). The bogus string flowed |
| 546 | // into resource props and into the cloud — only |
| 547 | // surfacing as an opaque downstream error (see PR |
| 548 | // description for a real Cloudflare DNS landing). |
| 549 | // |
| 550 | // - `number` hint (`+output`, `output * 2`, |
| 551 | // `Math.max(0, output)`) previously returned NaN, which |
| 552 | // propagates silently through arithmetic and lands as |
| 553 | // "the API rejected a NaN field" much later. |
| 554 | // |
| 555 | // All three hints fail loud at the coercion site with a |
| 556 | // pointer to the right composition API. |
| 557 | throw new Error( |
| 558 | `Cannot coerce Output<${self[inspect]()}> to a ` + |
| 559 | `${hint === "number" ? "number" : "string"} via JS coercion. ` + |
| 560 | `Use Output.interpolate\`...\` or Output.map(output, fn) ` + |
| 561 | `to compose Outputs — the value isn't known until deploy time.`, |
| 562 | ); |
| 563 | } |
| 564 | : prop === ExprSymbol |
| 565 | ? self |
| 566 | : prop === inspect |
| 567 | ? target[inspect] |
| 568 | : (isResourceExpr(self) || isRefExpr(self)) && |
| 569 | self.stables && |
| 570 | prop in self.stables |
| 571 | ? self.stables[prop as keyof typeof self.stables] |
| 572 | : prop in self |
| 573 | ? typeof self[prop as keyof typeof self] === "function" && |
| 574 | !("kind" in self) |
| 575 | ? new PropExpr(proxy, prop as never) |
| 576 | : self[prop as keyof typeof self] |
| 577 | : new PropExpr(proxy, prop as never), |
| 578 | apply: (_, thisArg, args) => { |
| 579 | if (isPropExpr(self)) { |
| 580 | // Method-style combinators on an Output proxy. `map`/`apply` and |
| 581 | // `mapEffect`/`effect` are aliases that mirror the standalone |
| 582 | // `Output.map` / `Output.mapEffect` / `Output.flatMap` functions. |
| 583 | if (self.identifier === "map" || self.identifier === "apply") { |
| 584 | return new ApplyExpr(self.expr, args[0]); |
| 585 | } else if ( |
| 586 | self.identifier === "mapEffect" || |
| 587 | self.identifier === "effect" |
| 588 | ) { |
| 589 | return new EffectExpr(self.expr, args[0]); |
| 590 | } else if (self.identifier === "flatMap") { |
| 591 | return new FlatMapExpr(self.expr, args[0]); |
| 592 | } |
| 593 | } |
| 594 | return undefined; |
| 595 | }, |
| 596 | }); |
| 597 | return proxy; |
| 598 | } |
| 599 | |
| 600 | /// Evaluation |
| 601 | |
| 602 | export class MissingSourceError extends Data.TaggedError("MissingSourceError")<{ |
| 603 | message: string; |
| 604 | srcId: string; |
| 605 | }> {} |
| 606 | |
| 607 | export class InvalidReferenceError extends Data.TaggedError( |
| 608 | "InvalidReferenceError", |
| 609 | )<{ |
| 610 | message: string; |
| 611 | stack: string; |
| 612 | stage: string; |
| 613 | resourceId: string; |
| 614 | }> {} |
| 615 | |
| 616 | export const evaluate: <A, Req = never>( |
| 617 | expr: Output<A, Req> | A, |
| 618 | upstream: { |
| 619 | [Id in string]: any; |
| 620 | }, |
| 621 | // Ancestor-path cycle guard — a plain-data value that appears on its own |
| 622 | // ancestor chain is cut to `undefined` (it could never serialize anyway). |
| 623 | // Immutable per-level so legitimately-shared diamond references survive |
| 624 | // (#1082). |
| 625 | ancestors?: ReadonlySet<object>, |
| 626 | ) => Effect.Effect< |
| 627 | A, |
| 628 | InvalidReferenceError | MissingSourceError | Config.ConfigError, |
| 629 | State.State | Req |
| 630 | > = (expr, upstream, ancestors = new Set()) => |
| 631 | Effect.gen(function* () { |
| 632 | if (isResource(expr)) { |
| 633 | const srcId = expr.FQN; |
| 634 | const src = upstream[srcId as keyof typeof upstream]; |
| 635 | if (!Object.hasOwn(upstream, srcId)) { |
| 636 | // type-safety should prevent this but let the caller decide how to handle it |
| 637 | return yield* new MissingSourceError({ |
| 638 | message: `Source ${srcId} not found`, |
| 639 | srcId, |
| 640 | }); |
| 641 | } |
| 642 | return src; |
| 643 | } else if (isOutput(expr)) { |
| 644 | if (isResourceExpr(expr)) { |
| 645 | const srcId = expr.src.FQN; |
| 646 | const src = upstream[srcId as keyof typeof upstream]; |
| 647 | if (!Object.hasOwn(upstream, srcId)) { |
| 648 | // type-safety should prevent this but let the caller decide how to handle it |
| 649 | return yield* new MissingSourceError({ |
| 650 | message: `Source ${srcId} not found`, |
| 651 | srcId, |
| 652 | }); |
| 653 | } |
| 654 | return src; |
| 655 | } else if (isLiteralExpr(expr)) { |
| 656 | return expr.value; |
| 657 | } else if (isApplyExpr(expr)) { |
| 658 | return expr.f(yield* evaluate(expr.expr, upstream)); |
| 659 | } else if (isEffectExpr(expr)) { |
| 660 | // TODO(sam): the same effect shoudl be memoized so that it's not run multiple times |
| 661 | return yield* expr.f(yield* evaluate(expr.expr, upstream)); |
| 662 | } else if (isFlatMapExpr(expr)) { |
| 663 | // Resolve the source, hand it to `f` to produce a new Output, then |
| 664 | // recursively evaluate that Output (flattening one level). |
| 665 | const value = yield* evaluate(expr.expr, upstream); |
| 666 | return yield* evaluate(expr.f(value), upstream); |
| 667 | } else if (isAllExpr(expr)) { |
| 668 | return yield* Effect.all( |
| 669 | expr.outs.map((out) => evaluate(out, upstream)), |
| 670 | ); |
| 671 | } else if (isPropExpr(expr)) { |
| 672 | return (yield* evaluate(expr.expr, upstream))?.[expr.identifier]; |
| 673 | } else if (isNamedExpr(expr)) { |
| 674 | return yield* evaluate(expr.expr, upstream); |
| 675 | } else if (isRefExpr(expr)) { |
| 676 | const state = yield* yield* State.State; |
| 677 | const stack = expr.stack ?? (yield* Stack).name; |
| 678 | const stage = expr.stage ?? (yield* Stage); |
| 679 | const resource = yield* state.get({ |
| 680 | stack, |
| 681 | stage, |
| 682 | fqn: expr.resourceId, |
| 683 | }); |
| 684 | if (!resource) { |
| 685 | return yield* Effect.fail( |
| 686 | new InvalidReferenceError({ |
| 687 | message: `Reference to '${expr.resourceId}' in stack '${stack}' and stage '${stage}' not found. Have you deployed '${stage}' or '${stack}'?`, |
| 688 | stack, |
| 689 | stage, |
| 690 | resourceId: expr.resourceId, |
| 691 | }), |
| 692 | ); |
| 693 | } |
| 694 | // RefExpr targets persisted resources; tasks aren't cross-stack |
| 695 | // referenceable. Return the resource's output attrs, otherwise the |
| 696 | // task's output value, otherwise undefined. |
| 697 | return (resource as any).attr ?? (resource as any).output; |
| 698 | } else if (isStackRefExpr(expr)) { |
| 699 | const state = yield* yield* State.State; |
| 700 | const stack = expr.stack; |
| 701 | const stage = expr.stage ?? (yield* Stage); |
| 702 | const output = yield* state.getOutput({ stack, stage }); |
| 703 | if (output == null) { |
| 704 | return yield* Effect.fail( |
| 705 | new InvalidReferenceError({ |
| 706 | message: `Reference to stack '${stack}' at stage '${stage}' not found. Have you deployed stage '${stage}' of '${stack}'?`, |
| 707 | stack, |
| 708 | stage, |
| 709 | resourceId: stack, |
| 710 | }), |
| 711 | ); |
| 712 | } |
| 713 | return output; |
| 714 | } |
| 715 | } |
| 716 | if (Config.isConfig(expr)) { |
| 717 | // Resolve Config against the deploy environment — see resolveInput in |
| 718 | // Plan.ts for rationale. `Config.Redacted` resolves to a `Redacted`, |
| 719 | // which stays opaque via the leaf fallthrough below. |
| 720 | return yield* evaluate(yield* expr, upstream, ancestors); |
| 721 | } else if (isPlainData(expr)) { |
| 722 | if (ancestors.has(expr)) { |
| 723 | return undefined; |
| 724 | } |
| 725 | const nested = new Set(ancestors).add(expr); |
| 726 | if (Array.isArray(expr)) { |
| 727 | return yield* Effect.all( |
| 728 | expr.map((item) => evaluate(item, upstream, nested)), |
| 729 | ); |
| 730 | } |
| 731 | return Object.fromEntries( |
| 732 | yield* Effect.all( |
| 733 | Object.entries(expr).map(([key, value]) => |
| 734 | evaluate(value, upstream, nested).pipe( |
| 735 | Effect.map((value) => [key, value]), |
| 736 | ), |
| 737 | ), |
| 738 | ), |
| 739 | ); |
| 740 | } |
| 741 | // Everything else is a leaf returned by identity: Duration, Redacted, |
| 742 | // Date, and effect runtime values (a Worker's `exports` carries each |
| 743 | // DO's `constructor` Effect and captured `services` Context). Rebuilding |
| 744 | // a class instance entry-by-entry strips its prototype, and effect |
| 745 | // ≥4.0.0-beta.103's Context is cyclic (#1082). This sits after |
| 746 | // `Config.isConfig` on purpose — Configs are Effects but must resolve. |
| 747 | return expr; |
| 748 | }) as Effect.Effect<any>; |
| 749 | |
| 750 | export const hasOutputs = (value: any): value is Output<any, any> => |
| 751 | Object.keys(upstreamAny(value)).length > 0; |
| 752 | |
| 753 | /** |
| 754 | * Cycle guard shared by the upstream walkers. Marks `value` as visited in |
| 755 | * `seen`; returns true when it was already visited (the caller returns `{}` |
| 756 | * — the first visit already contributed the subtree's resources to the |
| 757 | * FQN-keyed union, so skipping repeats is lossless). |
| 758 | */ |
| 759 | const alreadySeen = (value: object, seen: WeakSet<object>): boolean => { |
| 760 | if (seen.has(value)) { |
| 761 | return true; |
| 762 | } |
| 763 | seen.add(value); |
| 764 | return false; |
| 765 | }; |
| 766 | |
| 767 | // The dependency rule (#1082): a Resource or Output IS a dependency; plain |
| 768 | // data (arrays, plain objects) is traversed to find them; every other value |
| 769 | // — class instances like effect's Effect/Layer/Context, Dates, SDK objects, |
| 770 | // functions — is a leaf. See `isPlainData` in Util/data.ts for why leaves |
| 771 | // must never be walked. |
| 772 | |
| 773 | export const upstreamAny = ( |
| 774 | value: any, |
| 775 | seen: WeakSet<object> = new WeakSet(), |
| 776 | ): { |
| 777 | [ID in string]: Resource; |
| 778 | } => { |
| 779 | if (isResource(value)) { |
| 780 | return { [value.FQN]: value as Resource }; |
| 781 | } else if (isExpr(value)) { |
| 782 | return upstream(value, seen); |
| 783 | } else if (isPlainData(value)) { |
| 784 | if (alreadySeen(value, seen)) { |
| 785 | return {}; |
| 786 | } |
| 787 | return Object.assign( |
| 788 | {}, |
| 789 | ...Object.values(value).map((value) => resolveUpstream(value, seen)), |
| 790 | ); |
| 791 | } |
| 792 | return {}; |
| 793 | }; |
| 794 | |
| 795 | // TODO(sam): add a type |
| 796 | export const upstream = <E extends Output<any, any>>( |
| 797 | expr: E, |
| 798 | seen: WeakSet<object> = new WeakSet(), |
| 799 | ): any => { |
| 800 | if (isResource(expr)) { |
| 801 | return { |
| 802 | [(expr as unknown as Resource).FQN]: expr, |
| 803 | }; |
| 804 | } else if (isResourceExpr(expr)) { |
| 805 | return { |
| 806 | [expr.src.FQN]: expr.src, |
| 807 | }; |
| 808 | } else if (isPropExpr(expr)) { |
| 809 | return upstream(expr.expr, seen); |
| 810 | } else if (isAllExpr(expr)) { |
| 811 | return Object.assign({}, ...expr.outs.map((out) => upstream(out, seen))); |
| 812 | } else if ( |
| 813 | isEffectExpr(expr) || |
| 814 | isApplyExpr(expr) || |
| 815 | isFlatMapExpr(expr) || |
| 816 | isNamedExpr(expr) |
| 817 | ) { |
| 818 | return upstream(expr.expr, seen); |
| 819 | } else if (isPlainData(expr)) { |
| 820 | if (alreadySeen(expr, seen)) { |
| 821 | return {}; |
| 822 | } |
| 823 | return Object.values(expr) |
| 824 | .map((v) => upstream(v as any, seen)) |
| 825 | .reduce(toObject, {}); |
| 826 | } |
| 827 | return {}; |
| 828 | }; |
| 829 | |
| 830 | // TODO(sam): add a type |
| 831 | export const resolveUpstream = <const A>( |
| 832 | value: A, |
| 833 | seen: WeakSet<object> = new WeakSet(), |
| 834 | ): any => { |
| 835 | if (isPrimitive(value)) { |
| 836 | return {} as any; |
| 837 | } else if (isResource(value)) { |
| 838 | return { [(value as unknown as Resource).FQN]: value } as any; |
| 839 | } else if (isOutput(value)) { |
| 840 | return upstream(value, seen) as any; |
| 841 | } else if (isPlainData(value)) { |
| 842 | if (alreadySeen(value, seen)) { |
| 843 | return {} as any; |
| 844 | } |
| 845 | return Object.fromEntries( |
| 846 | Object.values(value) |
| 847 | .map((v) => resolveUpstream(v, seen)) |
| 848 | .flatMap(Object.entries), |
| 849 | ) as any; |
| 850 | } |
| 851 | return {} as any; |
| 852 | }; |
| 853 | |
| 854 | const toObject = <A, B>(acc: B, v: A) => ({ |
| 855 | ...acc, |
| 856 | ...v, |
| 857 | }); |
| 858 | |
| 859 | export const log = <A>(_value: A) => |
| 860 | Effect.gen(function* () { |
| 861 | // TODO(sam): implement a log effect |
| 862 | }); |
| 863 | |
| 864 | export const toEnvKey = <const ID extends string, const Suffix extends string>( |
| 865 | id: ID, |
| 866 | suffix: Suffix, |
| 867 | ) => `${replace(toUpper(id))}_${replace(toUpper(suffix))}` as const; |
| 868 | |
| 869 | export const toUpper = <const S extends string>(str: S) => |
| 870 | str.toUpperCase() as string extends S ? S : Uppercase<S>; |
| 871 | |
| 872 | const replace = <const S extends string>(str: S) => |
| 873 | str.replace(/-/g, "_") as Replace<S>; |
| 874 | |
| 875 | type Replace<S extends string, Accum extends string = ""> = string extends S |
| 876 | ? S |
| 877 | : S extends "" |
| 878 | ? Accum |
| 879 | : S extends `${infer S}${infer Rest}` |
| 880 | ? S extends "-" |
| 881 | ? Replace<Rest, `${Accum}_`> |
| 882 | : Replace<Rest, `${Accum}${S}`> |
| 883 | : Accum; |