Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/cloudflare-dev/src/EffectWorker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,9 @@ export default class EffectWorker extends Cloudflare.Worker<EffectWorker>()(
"EffectWorker",
{
main: import.meta.filename,
dev: {
port: Config.number("PORT").pipe(Config.withDefault(1338)),
},
},
Effect.gen(function* () {
const kv = yield* Cloudflare.KVNamespace.bind(KV);
Expand Down
4 changes: 2 additions & 2 deletions packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is a Config an Effect?

? yield* value as Effect.Effect<any>
: undefined,
];
Expand Down
28 changes: 7 additions & 21 deletions packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,8 +49,10 @@ export const bindWorkerAsyncBindings = Effect.fnUntraced(function* (
: bindingEff
) as WorkerBindingResource;

const bindingMeta: InputProps<WorkerBinding> | undefined =
yield* asEffect(toBinding(bindingName, binding));
const bindingMeta: InputProps<WorkerBinding> | undefined = toBinding(
bindingName,
binding,
);

if (bindingMeta) {
yield* resource.bind`${bindingName}`({
Expand All @@ -75,20 +75,14 @@ type BindingSpec = InputProps<
const toBinding = (
bindingName: string,
binding: WorkerBindingResource,
): BindingSpec | Effect.Effect<BindingSpec> | undefined => {
// narrowing to Config<unknown> doesn't work for us, we need any
const isConfig: (a: any) => a is Config.Config<any> = Config.isConfig;
// narrowing to Redacted<unknown> doesn't work for us, we need any
const isRedacted: (a: any) => a is Redacted.Redacted<any> =
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);
Comment on lines +85 to 86

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now type checks without needing a separate helper 🎉

val evaluates to type Json.

if (typeof val === "string") {
return {
Expand All @@ -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",
Expand Down Expand Up @@ -228,7 +214,7 @@ const toBinding = (
return {
type: "worker_loader",
name: bindingName,
} as any;
};
} else {
return {
type: "json",
Expand Down
12 changes: 8 additions & 4 deletions packages/alchemy/src/Output.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -554,7 +555,7 @@ export const evaluate: <A, Req = never>(
},
) => Effect.Effect<
A,
InvalidReferenceError | MissingSourceError,
InvalidReferenceError | MissingSourceError | Config.ConfigError,
State.State | Req
> = (expr, upstream) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -644,9 +645,12 @@ export const evaluate: <A, Req = never>(
}
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) {
Expand Down
22 changes: 15 additions & 7 deletions packages/alchemy/src/Plan.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -373,18 +374,25 @@ export const make = <A>(
));
});

const resolveInput = (input: any): Effect.Effect<any> =>
const resolveInput = (input: any): Effect.Effect<any, Config.ConfigError> =>
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), {
Expand Down
67 changes: 67 additions & 0 deletions packages/alchemy/test/Output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>),
).toBe("hunter2");
}),
),
);
});

describe("LiteralExpr", () => {
it.effect("evaluates Output.literal(value)", () =>
provideState(
Expand Down
54 changes: 54 additions & 0 deletions packages/alchemy/test/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
Loading