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.
Cloudflare Email
Section titled “Cloudflare Email”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 ownconst routing = yield* Cloudflare.EmailRouting("Routing", { zone: "example.com",});
// Register a verified destinationconst 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.
Neon on @distilled.cloud/neon
Section titled “Neon on @distilled.cloud/neon”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.
Fault tolerance in the apply transport
Section titled “Fault tolerance in the apply transport”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.
Other changes worth knowing about
Section titled “Other changes worth knowing about”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.ignoreis now vendored. The MIT-licensedignorepackage is inlined into the Alchemy build to shrink the supply-chain surface (one less transitive dependency for everyone runningalchemy deployin 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.