diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 356e26c54..56a236ae1 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -21,7 +21,11 @@ import type { InputProps } from "../../Input.ts"; import * as ProviderLayer from "../../Local/ProviderLayer.ts"; import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; import * as Provider from "../../Provider.ts"; -import { Resource, type ResourceBinding } from "../../Resource.ts"; +import { + isResourceEffect, + Resource, + type ResourceBinding, +} from "../../Resource.ts"; import { Stack } from "../../Stack.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { HyperdriveDevOrigin } from "../Hyperdrive/Hyperdrive.ts"; @@ -1182,7 +1186,10 @@ export const LiveWorkerProvider = () => : Redacted.isRedacted(value) && typeof Redacted.value(value) === "string" ? Redacted.value(value) - : Effect.isEffect(value) + : // Resource references are opaque handles, not env + // var values — running one here would register a + // resource outside the construction phase. + Effect.isEffect(value) && !isResourceEffect(value) ? yield* value as Effect.Effect : undefined, ]; diff --git a/packages/alchemy/src/Diff.ts b/packages/alchemy/src/Diff.ts index e3853dbb2..8b67e7cd0 100644 --- a/packages/alchemy/src/Diff.ts +++ b/packages/alchemy/src/Diff.ts @@ -3,7 +3,7 @@ import * as Redacted from "effect/Redacted"; import type { Input } from "./Input.ts"; import * as Output from "./Output.ts"; import type { BindingNode } from "./Plan.ts"; -import type { ResourceBinding } from "./Resource.ts"; +import { isResourceEffect, type ResourceBinding } from "./Resource.ts"; import { isPrimitive } from "./Util/data.ts"; export type Diff = NoopDiff | UpdateDiff | ReplaceDiff; @@ -45,6 +45,10 @@ export const isResolved = (value: Input): value is T => const _hasUnresolved = (value: unknown): boolean => { if (value == null || isPrimitive(value)) return false; + // Non-class resource references are permanently opaque — `resolveInput` + // never executes them (see Plan.ts) — so they don't make props + // "unresolved": a provider's diff can still run against the rest. + if (isResourceEffect(value)) return false; if (Output.isExpr(value) || Effect.isEffect(value)) return true; if (Array.isArray(value)) return value.some(_hasUnresolved); if (typeof value === "object") { diff --git a/packages/alchemy/src/Output.ts b/packages/alchemy/src/Output.ts index 26514cd2c..0b09bb7a7 100644 --- a/packages/alchemy/src/Output.ts +++ b/packages/alchemy/src/Output.ts @@ -7,7 +7,12 @@ import type { Pipeable } from "effect/Pipeable"; import * as Redacted from "effect/Redacted"; import { SingleShotGen } from "effect/Utils"; import { getRefMetadata, isRef, type Ref } from "./Ref.ts"; -import { isResource, type Resource, type ResourceLike } from "./Resource.ts"; +import { + isResource, + isResourceEffect, + type Resource, + type ResourceLike, +} from "./Resource.ts"; import { RuntimeContext, sanitizeKey } from "./RuntimeContext.ts"; import { Stack } from "./Stack.ts"; import { Stage } from "./Stage.ts"; @@ -650,6 +655,18 @@ export const evaluate: ( // Plan.ts for rationale. `Config.redacted` resolves to a `Redacted`, // which stays opaque via the branch below. return yield* evaluate(yield* expr, upstream); + } else if (isResourceEffect(expr)) { + // Non-class resource references (`const db = Hyperdrive("db", …)`) + // stay opaque to evaluation — see resolveInput in Plan.ts for + // rationale. + return expr; + } else if (Effect.isEffect(expr) && typeof expr !== "function") { + // Per-field Effect inputs — see resolveInput in Plan.ts for rationale. + // Resolving here keeps apply-time props symmetric with plan-time + // resolution (and stops the object walk below from shredding an Effect + // into a plain `{"~effect/Effect/args": ...}` object). Function-form + // Effects (resource classes, `Binding.Policy` tags) stay opaque. + return yield* evaluate(yield* expr, upstream); } else if (Duration.isDuration(expr) || Redacted.isRedacted(expr)) { // Opaque value — see resolveInput in Plan.ts for rationale. return expr; diff --git a/packages/alchemy/src/Plan.ts b/packages/alchemy/src/Plan.ts index 7b3bfc313..65432d72a 100644 --- a/packages/alchemy/src/Plan.ts +++ b/packages/alchemy/src/Plan.ts @@ -38,6 +38,7 @@ import { } from "./Provider.ts"; import { isResource, + isResourceEffect, type ResourceBinding, type ResourceLike, } from "./Resource.ts"; @@ -374,7 +375,10 @@ export const make = ( )); }); - const resolveInput = (input: any): Effect.Effect => + // The error channel is `unknown` because per-field Effect inputs + // (`Input` admits `Effect`) surface their own failures, + // which are not statically known here. + const resolveInput = (input: any): Effect.Effect => Effect.gen(function* () { if (!input) { return input; @@ -388,6 +392,26 @@ export const make = ( // `Config.redacted` resolves to a `Redacted`, which stays opaque via // the branch below. return yield* resolveInput(yield* input); + } else if (isResourceEffect(input)) { + // Non-class resource references (`const db = Hyperdrive("db", …)`) + // are Effects too, but they are handles to stack resources, not + // per-field values. Executing one here would re-derive its FQN + // from the ambient namespace (none at plan time) and mint a + // phantom resource. They stay opaque — the construction phase + // (e.g. Worker env binding registration) already consumed them. + return input; + } else if (Effect.isEffect(input) && typeof input !== "function") { + // Per-field Effect inputs (e.g. `Stack.useSync(...)`) — `Input` + // admits `Effect` for any prop, so run the effect and resolve + // into its result, mirroring the Config branch above. Function-form + // Effects (resource classes, `effectClass` constructors, + // `Binding.Policy` tags) stay opaque: they are class references + // carried in props (e.g. Worker `exports`), not per-field values. + // Requirements are erased here — the effect runs against the + // ambient plan context (see the Requirements TODO on `Input`). + return yield* resolveInput( + yield* input as Effect.Effect, + ); } else if (Duration.isDuration(input) || Redacted.isRedacted(input)) { // Opaque values that are resolved downstream. We don't walk them // because it would strip their prototype, resulting in a plain object diff --git a/packages/alchemy/src/Resource.ts b/packages/alchemy/src/Resource.ts index 4dd19b39f..cad81d086 100644 --- a/packages/alchemy/src/Resource.ts +++ b/packages/alchemy/src/Resource.ts @@ -125,6 +125,21 @@ export const isResource = (value: any): value is ResourceLike => { return typeof value === "object" && value !== null && "Type" in value; }; +// Registered with `Symbol.for` (like `alchemy/Expr`) so the brand survives +// duplicate alchemy module instances in one process. +export const ResourceEffectSymbol = Symbol.for("alchemy/ResourceEffect"); + +/** + * True for the Effect returned by a non-class resource call + * (`const db = Hyperdrive("db", props)`). Such an Effect is a *reference* + * to a stack resource, not a per-field value: input resolution + * (`resolveInput` in Plan.ts, `Output.evaluate`) must leave it opaque + * instead of executing it — execution outside the construction phase would + * re-derive the FQN from the ambient namespace and mint a phantom resource. + */ +export const isResourceEffect = (value: unknown): boolean => + typeof value === "object" && value !== null && ResourceEffectSymbol in value; + export type Resource< Type extends string = any, Props extends object | undefined = any, @@ -174,115 +189,136 @@ export function Resource( const constructor = ( id: string, props: Props | Effect.Effect | undefined, - ) => - Effect.gen(function* () { - const stack = yield* Stack; - const namespace = yield* CurrentNamespace; - const fqn = toFqn(namespace, id); - - const existing = stack.resources[fqn]; - if (existing) { - // // TODO(sam): check if props are different and die - return existing; - } - const bind = ( - ...args: - | [sid: string, data: R["Binding"]] - | [template: TemplateStringsArray, ...args: any[]] - ) => - typeof args[0] === "string" - ? Effect.gen(function* () { - const [sid, data] = args as [sid: string, data: R["Binding"]]; - (stack.bindings[fqn] ??= []).push({ - sid, - data, - }); - return undefined; - }) - : (data: R["Binding"]) => { - const stringifyBindArg = (arg: any): string | undefined => { - if (arg === undefined) { - return undefined; - } + ) => { + // One constructor call denotes one resource per stack. The FQN-keyed + // memo below only collapses re-executions that see the same ambient + // namespace; this per-effect memo also covers re-executions *outside* + // the construction phase (e.g. a per-field Effect like + // `Effect.map(dbRole, …)` resolved by Plan/Apply re-runs the wrapped + // reference with no namespace in scope), which would otherwise + // re-derive a different FQN and mint a phantom resource. + const memo = new WeakMap(); + return Object.assign( + Effect.gen(function* () { + const stack = yield* Stack; + const memoized = memo.get(stack); + if (memoized) { + return memoized; + } + const namespace = yield* CurrentNamespace; + const fqn = toFqn(namespace, id); - if (Array.isArray(arg)) { - return arg - .flatMap((item) => { - const stringified = stringifyBindArg(item); - return stringified === undefined ? [] : [stringified]; - }) - .join(", "); - } + const existing = stack.resources[fqn]; + if (existing) { + // // TODO(sam): check if props are different and die + memo.set(stack, existing as R); + return existing; + } + const bind = ( + ...args: + | [sid: string, data: R["Binding"]] + | [template: TemplateStringsArray, ...args: any[]] + ) => + typeof args[0] === "string" + ? Effect.gen(function* () { + const [sid, data] = args as [sid: string, data: R["Binding"]]; + (stack.bindings[fqn] ??= []).push({ + sid, + data, + }); + return undefined; + }) + : (data: R["Binding"]) => { + const stringifyBindArg = (arg: any): string | undefined => { + if (arg === undefined) { + return undefined; + } - if ( - arg && - (typeof arg === "object" || typeof arg === "function") - ) { - if ("LogicalId" in arg && typeof arg.LogicalId === "string") { - return arg.LogicalId; + if (Array.isArray(arg)) { + return arg + .flatMap((item) => { + const stringified = stringifyBindArg(item); + return stringified === undefined ? [] : [stringified]; + }) + .join(", "); } - if ("id" in arg && typeof arg.id === "string") { - return arg.id; + if ( + arg && + (typeof arg === "object" || typeof arg === "function") + ) { + if ( + "LogicalId" in arg && + typeof arg.LogicalId === "string" + ) { + return arg.LogicalId; + } + + if ("id" in arg && typeof arg.id === "string") { + return arg.id; + } } - } - return String(arg); - }; + return String(arg); + }; - return bind( - `${(args[0] as TemplateStringsArray) - .flatMap((text, i) => { - const stringified = stringifyBindArg(args[i + 1]); - return stringified !== undefined - ? [text, stringified] - : [text]; - }) - .join("")}`, - data, - ); - }; + return bind( + `${(args[0] as TemplateStringsArray) + .flatMap((text, i) => { + const stringified = stringifyBindArg(args[i + 1]); + return stringified !== undefined + ? [text, stringified] + : [text]; + }) + .join("")}`, + data, + ); + }; - const target: any = { - Type: type, - Namespace: namespace, - FQN: fqn, - LogicalId: id, - Props: props, - Provider: ProviderTag as Provider, - RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe( - Effect.map(Option.getOrElse(() => defaultRemovalPolicy)), - ), - Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe( - Effect.map(Option.getOrUndefined), - ), - bind, - toString(this: typeof target) { - return `Resource<${this.Type}>(${this.LogicalId})`; - }, - [Symbol.toPrimitive](this: typeof target, hint: string) { - return hint === "number" ? NaN : this.toString(); - }, - }; + const target: any = { + Type: type, + Namespace: namespace, + FQN: fqn, + LogicalId: id, + Props: props, + Provider: ProviderTag as Provider, + RemovalPolicy: yield* Effect.serviceOption(RemovalPolicy).pipe( + Effect.map(Option.getOrElse(() => defaultRemovalPolicy)), + ), + Adopt: yield* Effect.serviceOption(AdoptPolicy).pipe( + Effect.map(Option.getOrUndefined), + ), + bind, + toString(this: typeof target) { + return `Resource<${this.Type}>(${this.LogicalId})`; + }, + [Symbol.toPrimitive](this: typeof target, hint: string) { + return hint === "number" ? NaN : this.toString(); + }, + }; - const Resource: R = (stack.resources[fqn] = new Proxy(target, { - set: (t, prop, value) => { - t[prop as keyof typeof t] = value; - return true; - }, - get: (t, prop) => - typeof prop === "symbol" || prop in t - ? t[prop as keyof typeof t] - : new Output.PropExpr(Output.of(Resource), prop), - })) as R; - Resource.Props = Effect.isEffect(props) - ? yield* props.pipe( - Effect.provideService(Self, Resource), - Effect.provideService(Self(type), Resource), - ) - : props; - return Resource; - }); + const Resource: R = (stack.resources[fqn] = new Proxy(target, { + set: (t, prop, value) => { + t[prop as keyof typeof t] = value; + return true; + }, + get: (t, prop) => + typeof prop === "symbol" || prop in t + ? t[prop as keyof typeof t] + : new Output.PropExpr(Output.of(Resource), prop), + })) as R; + Resource.Props = Effect.isEffect(props) + ? yield* props.pipe( + Effect.provideService(Self, Resource), + Effect.provideService(Self(type), Resource), + ) + : props; + memo.set(stack, Resource); + return Resource; + }), + { [ResourceEffectSymbol]: true }, + ); + }; const ProviderTag = Provider(type); diff --git a/packages/alchemy/test/Diff.test.ts b/packages/alchemy/test/Diff.test.ts index 3c3ecc27d..0dccadbc3 100644 --- a/packages/alchemy/test/Diff.test.ts +++ b/packages/alchemy/test/Diff.test.ts @@ -1,6 +1,8 @@ -import { deepEqual, havePropsChanged } from "@/Diff"; +import { deepEqual, havePropsChanged, isResolved } from "@/Diff"; import { describe, expect, test } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; +import { Bucket } from "./test.resources.ts"; describe("Diff", () => { describe("havePropsChanged with Redacted values", () => { @@ -108,4 +110,16 @@ describe("Diff", () => { ).toBe(false); }); }); + + describe("isResolved with Effect-valued props", () => { + test("a per-field Effect input counts as unresolved", () => { + expect(isResolved({ domain: Effect.succeed("example.com") })).toBe(false); + }); + + test("a non-class resource reference counts as resolved (opaque)", () => { + // `resolveInput` never executes resource references (see Plan.ts), + // so their presence must not disable a provider's custom diff. + expect(isResolved({ env: { DB: Bucket("A", {}) } })).toBe(true); + }); + }); }); diff --git a/packages/alchemy/test/Output.test.ts b/packages/alchemy/test/Output.test.ts index 738683f01..4a5ded6ee 100644 --- a/packages/alchemy/test/Output.test.ts +++ b/packages/alchemy/test/Output.test.ts @@ -5,7 +5,9 @@ import { Stack } from "@/Stack"; import { Stage } from "@/Stage"; import { inMemoryState } from "@/State/InMemoryState"; import type { ResourceState } from "@/State/ResourceState"; +import { effectClass } from "@/Util/effect"; import { describe, expect, it } from "@effect/vitest"; +import { Bucket } from "./test.resources.ts"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -168,6 +170,105 @@ describe("Output.evaluate", () => { ); }); + describe("per-field Effect inputs", () => { + it.effect("resolves a raw Effect value at the top level", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate(Effect.succeed(1337), {}); + expect(result).toBe(1337); + }), + ), + ); + + it.effect("resolves an Effect value nested inside an object", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { domain: Effect.succeed("example.com"), url: false }, + {}, + ); + expect(result).toEqual({ domain: "example.com", url: false }); + }), + ), + ); + + it.effect("resolves an Effect value nested inside an array", () => + provideState( + Effect.gen(function* () { + const [result] = yield* Output.evaluate([Effect.succeed(42)], {}); + expect(result).toBe(42); + }), + ), + ); + + it.effect("an Effect resolving to undefined yields undefined", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { domain: Effect.succeed(undefined) }, + {}, + ); + expect(result.domain).toBeUndefined(); + }), + ), + ); + + it.effect("resolves recursively into the Effect's result", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + Effect.succeed({ nested: Effect.succeed("x") }), + {}, + ); + expect(result).toEqual({ nested: "x" }); + }), + ), + ); + + it.effect("an Effect resolving to a Redacted keeps it wrapped", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + Effect.succeed(Redacted.make("hunter2")), + {}, + ); + expect(Redacted.isRedacted(result)).toBe(true); + expect( + Redacted.value(result as unknown as Redacted.Redacted), + ).toBe("hunter2"); + }), + ), + ); + + it.effect("function-form Effects (effect classes) stay opaque", () => + provideState( + Effect.gen(function* () { + // Resource classes, `Binding.Policy` tags, and `effectClass` + // constructors are real Effects (`Effect.isEffect(X)` is true) + // but are carried in props as class references (e.g. Worker + // `exports`) — evaluate must not run them. + const cls = effectClass(Effect.succeed("must-not-run")); + const result = yield* Output.evaluate({ exports: { X: cls } }, {}); + expect(result.exports.X).toBe(cls); + }), + ), + ); + + it.effect("non-class resource references stay opaque", () => + provideState( + Effect.gen(function* () { + // `const db = Hyperdrive("db", …)` returns an object-form Effect + // too, but it is a handle to a stack resource — evaluate must + // not run it (execution outside the construction phase would + // mint a phantom resource under a different FQN). + const ref = Bucket("A", {}); + const result = yield* Output.evaluate({ env: { DB: ref } }, {}); + expect(result.env.DB).toBe(ref); + }), + ), + ); + }); + describe("LiteralExpr", () => { it.effect("evaluates Output.literal(value)", () => provideState( diff --git a/packages/alchemy/test/apply.test.ts b/packages/alchemy/test/apply.test.ts index 0148e7e01..1af9183a0 100644 --- a/packages/alchemy/test/apply.test.ts +++ b/packages/alchemy/test/apply.test.ts @@ -18,6 +18,7 @@ import * as Redacted from "effect/Redacted"; import { ArtifactProbe, BindingTarget, + Bucket, DeletedBindingRegressionTarget, DurationResource, Function, @@ -4614,3 +4615,151 @@ describe("Duration round-trip through state", () => { }), ); }); + +describe("per-field Effect props", () => { + // Stage-conditional props expressed as per-field Effects + // (`Stack.useSync(...)`) are the only shape that keeps class-form + // `InferEnv` literal inference while varying values per stage. The engine + // must resolve them before any provider lifecycle method reads the props. + test.provider( + "a Stack.useSync prop resolves before the provider sees it", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "A") seen.push(props.string); + }), + }; + + const output = yield* TestResource("A", { + string: Stack.useSync((s) => + s.stage === "test" ? `from-${s.stage}` : undefined, + ) as any, + }).pipe(stack.deploy, hook(capture)); + + expect(output.string).toBe("from-test"); + expect(seen).toHaveLength(1); + expect(Effect.isEffect(seen[0])).toBe(false); + expect(seen[0]).toBe("from-test"); + + // The persisted props must hold the concrete value, not a + // shredded Effect object. + const persisted = yield* getState<{ + props: TestResourceProps; + }>("A"); + expect(persisted.props.string).toBe("from-test"); + }), + ); + + test.provider( + "a Stack.useSync prop resolving to undefined arrives as a real undefined", + (stack) => + Effect.gen(function* () { + // The Worker `domain` case: an unresolved Effect object is truthy, + // so a stage where the value resolves to `undefined` would wrongly + // enter reconciliation (and crash the API client). A resolved + // `undefined` keeps provider skip-guards working. + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "A") seen.push(props.string); + }), + }; + + const output = yield* TestResource("A", { + string: Stack.useSync((s) => + s.stage === "prod" ? "prod-only" : undefined, + ) as any, + }).pipe(stack.deploy, hook(capture)); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeUndefined(); + // Provider fell through its `news.string ?? id` default — the + // undefined was a genuine undefined, not a truthy Effect object. + expect(output.string).toBe("A"); + }), + ); + + // Non-class resource references (`const db = Cloudflare.Hyperdrive("db", + // …)`) are Effects too. They must NOT be executed by input resolution: + // re-running the constructor outside the construction phase re-derives the + // FQN from the ambient namespace (none at plan/apply time) and mints a + // phantom resource — `MissingSourceError: Source not found`. + test.provider( + "a resource reference in another resource's props stays opaque", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "B") seen.push(props.object?.string); + }), + }; + + // Hoisted reference, the way an app hoists + // `const db = Cloudflare.Hyperdrive("db", …)`. + const bucket = Bucket("Bucket", {}); + + yield* Effect.gen(function* () { + // Register the reference under a namespace — the way a Worker's + // env binding values are registered inside the composite's + // `Namespace.push(id)` scope. Executing the reference outside + // this scope would compute FQN "Bucket" instead of "NS/Bucket". + const Site = Construct.fn(function* (_id: string, _props: {}) { + return yield* bucket; + }); + yield* Site("NS", {}); + yield* TestResource("B", { + string: "x", + object: { string: bucket as any }, + }); + }).pipe(stack.deploy, hook(capture)); + + // The deploy converged, and the reference reached the provider as + // the same un-executed Effect object — no phantom resource, no + // MissingSourceError. + expect(seen).toHaveLength(1); + expect(seen[0]).toBe(bucket); + }), + ); + + test.provider( + "a plain Effect wrapping a resource reference resolves through the reference", + (stack) => + Effect.gen(function* () { + const seen: unknown[] = []; + const capture = { + create: (id: string, props: TestResourceProps) => + Effect.sync(() => { + if (id === "B") seen.push(props.string); + }), + }; + + // The `AI_GATEWAY_ID: Effect.map(aiGateway, g => g.gatewayId)` + // shape: a plain (unbranded) Effect wrapping a resource reference. + // Resolving it executes the wrapper — the wrapped reference must + // return the resource registered during construction (under + // "NS/Bucket"), not mint a phantom root-level "Bucket". + const bucket = Bucket("Bucket", {}); + + const output = yield* Effect.gen(function* () { + const Site = Construct.fn(function* (_id: string, _props: {}) { + return yield* bucket; + }); + yield* Site("NS", {}); + return yield* TestResource("B", { + string: Effect.map(bucket, (b) => b.name) as any, + }); + }).pipe(stack.deploy, hook(capture)); + + // The wrapper resolved to the real resource's attribute and the + // provider saw the concrete value. + expect(seen).toEqual(["Bucket"]); + expect(output.string).toBe("Bucket"); + }), + ); +}); diff --git a/packages/alchemy/test/plan.test.ts b/packages/alchemy/test/plan.test.ts index 7150899de..046cec7e8 100644 --- a/packages/alchemy/test/plan.test.ts +++ b/packages/alchemy/test/plan.test.ts @@ -2457,6 +2457,127 @@ describe("Config props are resolved through plan", () => { ); }); +describe("per-field Effect props are resolved through plan", () => { + test( + "an Effect prop is resolved to its concrete value in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed("resolved-effect-value") as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(Effect.isEffect(props.string)).toBe(false); + expect(props.string).toBe("resolved-effect-value"); + }), + ); + + test( + "an Effect prop resolving to undefined lands as undefined", + Effect.gen(function* () { + // The domain-shaped case from the Worker provider: an unresolved + // Effect is truthy, so a stage-conditional prop that should be + // "off" would wrongly enter reconciliation. A resolved `undefined` + // keeps provider guards like `domain === undefined` working. + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed(undefined) as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(props.string).toBeUndefined(); + }), + ); + + test( + "an Effect nested inside an object prop is resolved in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + object: { string: Effect.succeed("nested") as any }, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(props.object).toEqual({ string: "nested" }); + }), + ); + + test( + "diff observes resolved Effect props (noop when value is unchanged)", + Effect.gen(function* () { + yield* seed({ + A: { + instanceId, + providerVersion: 0, + logicalId: "A", + fqn: "A", + namespace: undefined, + resourceType: "Test.TestResource", + status: "created", + props: { + string: "same", + }, + attr: { + string: "same", + stringArray: [], + stableString: "A", + stableArray: ["A"], + replaceString: undefined, + redacted: undefined, + redactedArray: undefined, + }, + downstream: [], + bindings: [], + }, + }); + // If the Effect reached `diff` unresolved, the provider's + // `isResolved(news)` guard would bail and the engine would fall back + // to a conservative prop-hash comparison against the raw Effect — + // producing a spurious update. A noop proves diff saw the value. + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Effect.succeed("same") as any, + }); + }).pipe(makePlan); + + expect(plan.resources.A!.action).toBe("noop"); + }), + ); + + test( + "a resource reference prop stays opaque in the plan", + Effect.gen(function* () { + // Non-class resource reference (`const db = Hyperdrive("db", …)`) — + // an Effect, but a handle to a stack resource. resolveInput must not + // execute it: outside the construction phase the ambient namespace + // differs, so execution would mint a phantom resource. + const bucket = Bucket("A", {}); + const plan = yield* Effect.gen(function* () { + yield* bucket; + yield* TestResource("B", { + object: { string: bucket as any }, + }); + }).pipe(makePlan); + + // No phantom resource appeared in the plan. + expect(Object.keys(plan.resources).sort()).toEqual(["A", "B"]); + const node: any = plan.resources.B!; + const props = node.props as TestResourceProps; + // Same reference, un-executed. + expect(props.object!.string).toBe(bucket as any); + }), + ); +}); + describe("Redacted props/outputs are preserved through plan", () => { test( "Redacted prop on a new resource is preserved as a Redacted in the plan",