Skip to content

What's new in beta.38

v2.0.0-beta.38 adds two new building blocks — full Cloudflare Email (zone routing + the send_email Worker binding) and the Action plan node for arbitrary Effects that run during apply — plus a behind-the-scenes Neon refactor onto the typed @distilled.cloud/neon SDK and a fault-tolerance fix in the apply transport.

Four new resources and one binding cover the whole flow: turn on Email Routing for a zone, register verified destination addresses, forward inbound mail with routing rules, and send outbound mail from a Worker.

// alchemy.run.ts — enable Email Routing on a zone you own
const routing = yield* Cloudflare.EmailRouting("Routing", {
zone: "example.com",
});
// Register a verified destination
const ops = yield* Cloudflare.EmailAddress("Ops", {
email: "ops@example.com",
});
// Forward inbound info@ → ops@
const rule = yield* Cloudflare.EmailRule("InfoForward", {
zone: "example.com",
matchers: [{ type: "literal", field: "to", value: "info@example.com" }],
actions: [{ type: "forward", value: ["ops@example.com"] }],
});
// Declare a `send_email` binding (the id is the env key)
export const Email = Cloudflare.SendEmail("EmailOps", {
destinationAddress: "ops@example.com",
allowedSenderAddresses: ["noreply@example.com"],
});

Bind SendEmail on a Worker and the runtime client gives you send (parsed) and sendRaw (RFC 822 bytes) with the same Effect error channel as every other binding:

export default Cloudflare.Worker("Notifier",
{ main: import.meta.path },
Effect.gen(function* () {
const email = yield* Cloudflare.SendEmail.bind(Email);
return {
fetch: Effect.gen(function* () {
yield* email.send({
from: "noreply@example.com",
to: "ops@example.com",
subject: "Hello",
text: "Hi from the edge",
});
return HttpServerResponse.text("sent");
}),
};
}).pipe(Effect.provide(Cloudflare.SendEmailBindingLive)),
);

Providers › EmailRouting · EmailAddress · EmailRule · SendEmail

Action — Effects that run as part of apply

Section titled “Action — Effects that run as part of apply”

An Action is a node in the stack DAG that runs an arbitrary Effect during apply when its resolved input changes — the missing primitive for things like database migrations, cache invalidation, deploy announcements, or anything else that isn’t a cloud resource but needs to run in order alongside one.

The shape is intentionally close to Resource: define once, instantiate in a stack, pass it inputs, get an Output back that downstream nodes can depend on.

import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
const Sync = Alchemy.Action("Sync",
Effect.fn(function* (input: { table: string }) {
return { rows: 42 };
}),
);
// In a stack:
const rows = yield* Sync({ table: bucket.name });
// ^? Output<{ rows: number }>

Unlike a Resource, an Action has no create/update/replace/delete lifecycle — the engine just hashes the resolved input and runs the body when it drifts. Removing the Action from the stack drops its persisted state in the GC pass; the body is never re-invoked on delete.

The plan reports actions on their own row:

Plan: 1 to create | 1 to update | 1 to run
~ Api (Cloudflare.Worker)
+ BucketA (Cloudflare.R2Bucket)
λ AnnounceDeploy (AnnounceDeploy) [action]
· DbMigrate (DbMigrate) [action]

λ (cyan) means will run this apply; · (gray) means input hash matches, skip. --force flips skip→run for actions the same way it does for resources.

Init-style construction works too, for the common case where the body needs Effect Services from the stack’s layers:

const Sync = Alchemy.Action("Sync",
Effect.gen(function* () {
const db = yield* Database;
const logger = yield* Logger;
return Effect.fn(function* (input: { table: string }) {
yield* logger.info(`syncing ${input.table}`);
return { rows: yield* db.count(input.table) };
});
}),
);

The init Effect runs once per process (memoized), so the runner is shared across every instance and every re-run.

Actions also participate in the dependency graph in both directions — they can take resource Outputs as input and their own output can be consumed by downstream resources or other actions. The scheduler waits on a separate “stable” signal so an action body never observes a resource’s precreate stub; it only sees terminal cloud state.

Concepts › Actions

Neon.Project and Neon.Branch no longer ship a hand-rolled HTTP wrapper — they route through the typed @distilled.cloud/neon SDK. The user-facing API is unchanged; what’s gone is the api.ts shim, the manual JSON shapes, and the per-call error mapping.

import * as api from "./api.ts";
import { getProject, updateProject, deleteProject } from "@distilled.cloud/neon/Operations";

404 handling now uses the SDK’s tagged error directly:

getProject({ project_id }).pipe(
Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
)

This is mostly an internal cleanup, but it pays off immediately: errors carry typed tags instead of opaque strings, retries hang off Schedule instead of hand-written try/catch, and the next time the upstream OpenAPI spec moves we regenerate one package instead of patching one file.

The transport that streams resource lifecycle events between the apply worker and the renderer now treats transient send/receive errors as retryable instead of collapsing the whole run. A flaky connection that drops mid-apply no longer aborts the stack — the transport backs off, reconnects, and resumes.

  • effect@4.0.0-beta.66. Tracks the latest Effect release. No public Alchemy API change; downstream apps pinning Effect themselves should bump in lockstep.
  • ignore is now vendored. The MIT-licensed ignore package is inlined into the Alchemy build to shrink the supply-chain surface (one less transitive dependency for everyone running alchemy deploy in CI).
  • OTLP dashboard refresh. Self-hosted OTel dashboards now have resource-usage ranking and error-rate charts per Stack / Stage. No code change in user code — it’s all dashboard config.