diff --git a/examples/cloudflare-dev/src/EffectWorker.ts b/examples/cloudflare-dev/src/EffectWorker.ts index 541e40b6ac..4c3e3ff621 100644 --- a/examples/cloudflare-dev/src/EffectWorker.ts +++ b/examples/cloudflare-dev/src/EffectWorker.ts @@ -1,4 +1,5 @@ import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -15,6 +16,9 @@ export default class EffectWorker extends Cloudflare.Worker()( "EffectWorker", { main: import.meta.filename, + dev: { + port: Config.number("PORT").pipe(Config.withDefault(1338)), + }, }, Effect.gen(function* () { const kv = yield* Cloudflare.KVNamespace.bind(KV); diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 93db31b1a3..206631b955 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -1,8 +1,8 @@ import type * as cf from "@cloudflare/workers-types"; import * as workers from "@distilled.cloud/cloudflare/workers"; import * as zones from "@distilled.cloud/cloudflare/zones"; +import type * as Config from "effect/Config"; import type { ConfigError } from "effect/Config"; -import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -1178,7 +1178,7 @@ export const LiveWorkerProvider = () => : Redacted.isRedacted(value) && typeof Redacted.value(value) === "string" ? Redacted.value(value) - : Config.isConfig(value) || Effect.isEffect(value) + : Effect.isEffect(value) ? yield* value as Effect.Effect : undefined, ]; diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index b0027542d4..a792af803f 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -1,12 +1,10 @@ import type { PutScriptRequest } from "@distilled.cloud/cloudflare/workers"; -import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import type { InputProps } from "../../Input.ts"; import * as Output from "../../Output.ts"; import type { ResourceBinding } from "../../Resource.ts"; import { isYieldableEffectLike } from "../../Util/effect.ts"; -import { asEffect } from "../../Util/types.ts"; import { isAiGateway } from "../AiGateway/AiGateway.ts"; import { isAnalyticsEngineDataset } from "../AnalyticsEngine/AnalyticsEngineDataset.ts"; import { isArtifacts } from "../Artifacts/Artifacts.ts"; @@ -51,8 +49,10 @@ export const bindWorkerAsyncBindings = Effect.fnUntraced(function* ( : bindingEff ) as WorkerBindingResource; - const bindingMeta: InputProps | undefined = - yield* asEffect(toBinding(bindingName, binding)); + const bindingMeta: InputProps | undefined = toBinding( + bindingName, + binding, + ); if (bindingMeta) { yield* resource.bind`${bindingName}`({ @@ -75,20 +75,14 @@ type BindingSpec = InputProps< const toBinding = ( bindingName: string, binding: WorkerBindingResource, -): BindingSpec | Effect.Effect | undefined => { - // narrowing to Config doesn't work for us, we need any - const isConfig: (a: any) => a is Config.Config = Config.isConfig; - // narrowing to Redacted doesn't work for us, we need any - const isRedacted: (a: any) => a is Redacted.Redacted = - Redacted.isRedacted; - +): BindingSpec => { if (typeof binding === "string") { return { type: "plain_text", name: bindingName, text: binding, }; - } else if (isRedacted(binding)) { + } else if (Redacted.isRedacted(binding)) { const val = Redacted.value(binding); if (typeof val === "string") { return { @@ -103,14 +97,6 @@ const toBinding = ( text: JSON.stringify(val), }; } - } else if (isConfig(binding)) { - return binding.pipe( - Effect.flatMap((json) => { - const b = toBinding(bindingName, json)!; - return Effect.isEffect(b) ? b : Effect.succeed(b); - }), - Effect.orDie, - ); } else if (isAssets(binding)) { return { type: "assets", @@ -228,7 +214,7 @@ const toBinding = ( return { type: "worker_loader", name: bindingName, - } as any; + }; } else { return { type: "json", diff --git a/packages/alchemy/src/Output.ts b/packages/alchemy/src/Output.ts index 46e4622e3c..26514cd2c7 100644 --- a/packages/alchemy/src/Output.ts +++ b/packages/alchemy/src/Output.ts @@ -1,3 +1,4 @@ +import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -554,7 +555,7 @@ export const evaluate: ( }, ) => Effect.Effect< A, - InvalidReferenceError | MissingSourceError, + InvalidReferenceError | MissingSourceError | Config.ConfigError, State.State | Req > = (expr, upstream) => Effect.gen(function* () { @@ -644,9 +645,12 @@ export const evaluate: ( } if (Array.isArray(expr)) { return yield* Effect.all(expr.map((item) => evaluate(item, upstream))); - } else if (Redacted.isRedacted(expr)) { - return expr; - } else if (Duration.isDuration(expr)) { + } else if (Config.isConfig(expr)) { + // Resolve Config against the deploy environment — see resolveInput in + // 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 (Duration.isDuration(expr) || Redacted.isRedacted(expr)) { // Opaque value — see resolveInput in Plan.ts for rationale. return expr; } else if (typeof expr === "object" && expr !== null) { diff --git a/packages/alchemy/src/Plan.ts b/packages/alchemy/src/Plan.ts index 89ad545863..7b3bfc313d 100644 --- a/packages/alchemy/src/Plan.ts +++ b/packages/alchemy/src/Plan.ts @@ -1,3 +1,4 @@ +import * as Config from "effect/Config"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -373,18 +374,25 @@ export const make = ( )); }); - const resolveInput = (input: any): Effect.Effect => + const resolveInput = (input: any): Effect.Effect => Effect.gen(function* () { if (!input) { return input; } else if (Output.isExpr(input)) { return yield* resolveOutput(input); - } else if (Redacted.isRedacted(input)) { - return input; - } else if (Duration.isDuration(input)) { - // Duration is an opaque value; walking its internal `.value` - // would destroy the prototype and produce a plain `{ value: ... }` - // object that downstream consumers can't interpret. + } else if (Config.isConfig(input)) { + // Config is a lazy reference to the deploy environment. Resolve it + // here so the concrete value flows into diffing/hashing (an opaque + // Config hashes the same regardless of the underlying value) and so + // providers receive a resolved value instead of a Config object. + // `Config.redacted` resolves to a `Redacted`, which stays opaque via + // the branch below. + return yield* resolveInput(yield* input); + } 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 + // that downstream consumers can't interpret. Redacted additionally + // stays wrapped to preserve the secrecy boundary. return input; } else if (Array.isArray(input)) { return yield* Effect.all(input.map(resolveInput), { diff --git a/packages/alchemy/test/Output.test.ts b/packages/alchemy/test/Output.test.ts index 2987e27996..738683f01c 100644 --- a/packages/alchemy/test/Output.test.ts +++ b/packages/alchemy/test/Output.test.ts @@ -7,6 +7,8 @@ import { inMemoryState } from "@/State/InMemoryState"; import type { ResourceState } from "@/State/ResourceState"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -101,6 +103,71 @@ describe("Output.evaluate", () => { ); }); + describe("Config", () => { + it.effect("resolves a Config value at the top level", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate(Config.succeed(1337), {}); + expect(result).toBe(1337); + }), + ), + ); + + it.effect("resolves a Config value nested inside an object", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { port: Config.succeed(8080), host: "localhost" }, + {}, + ); + expect(result).toEqual({ port: 8080, host: "localhost" }); + }), + ), + ); + + it.effect("resolves a Config value nested inside an array", () => + provideState( + Effect.gen(function* () { + const [result] = yield* Output.evaluate([Config.succeed(42)], {}); + expect(result).toBe(42); + }), + ), + ); + + it.effect("resolves a Config against the ConfigProvider environment", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + { port: Config.number("PORT").pipe(Config.withDefault(1337)) }, + {}, + ).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { PORT: "8080" } }), + ), + ), + ); + expect(result).toEqual({ port: 8080 }); + }), + ), + ); + + it.effect("a Config resolving to a Redacted keeps it wrapped", () => + provideState( + Effect.gen(function* () { + const result = yield* Output.evaluate( + Config.succeed(Redacted.make("hunter2")), + {}, + ); + expect(Redacted.isRedacted(result)).toBe(true); + expect( + Redacted.value(result as unknown as Redacted.Redacted), + ).toBe("hunter2"); + }), + ), + ); + }); + describe("LiteralExpr", () => { it.effect("evaluates Output.literal(value)", () => provideState( diff --git a/packages/alchemy/test/plan.test.ts b/packages/alchemy/test/plan.test.ts index 8140627c25..7150899de9 100644 --- a/packages/alchemy/test/plan.test.ts +++ b/packages/alchemy/test/plan.test.ts @@ -18,6 +18,7 @@ import { import * as Test from "@/Test/Vitest"; import { describe, expect } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -2403,6 +2404,59 @@ describe("unresolved plan inputs in diff should conservatively update", () => { ); }); +describe("Config props are resolved through plan", () => { + test( + "a Config prop is resolved to its concrete value in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: Config.succeed("resolved-config-value") as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(Config.isConfig(props.string)).toBe(false); + expect(props.string).toBe("resolved-config-value"); + }), + ); + + test( + "a Config resolving to a Redacted keeps it wrapped in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + string: "x", + redacted: Config.succeed(Redacted.make("hunter2")) as any, + }); + }).pipe(makePlan); + + const node: any = plan.resources.A!; + expect(node.action).toBe("create"); + const props = node.props as TestResourceProps; + expect(Redacted.isRedacted(props.redacted)).toBe(true); + expect(Redacted.value(props.redacted!)).toBe("hunter2"); + }), + ); + + test( + "a Config nested inside an object prop is resolved in the plan", + Effect.gen(function* () { + const plan = yield* Effect.gen(function* () { + yield* TestResource("A", { + object: { string: Config.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" }); + }), + ); +}); + describe("Redacted props/outputs are preserved through plan", () => { test( "Redacted prop on a new resource is preserved as a Redacted in the plan",