2.0.0-beta.85 - Effect 4.1, Domain Replay & DynamoDB State
beta.85 is 249 pull requests from 39 contributors: 84 features and 95 bug fixes.
It moves Alchemy to Effect 4.1. Alchemy Domain’s deploys now roll out
every model change by replaying history, and one Domain model can be served
over HTTP, MCP or the CLI, so people and agents reach it however they
prefer. GCP gets redesigned Cloud Run services and containers and connects to AWS
without keys. Custom domains work with any DNS provider, the
Cloudflare state store takes its Worker name, account and domain, AWS
state uses DynamoDB and plans 270× faster, Alchemist exposes the whole CLI
as a programmatic API, and Alchemy’s test runner ships as
@alchemy.run/test.
Effect 4.1
Section titled “Effect 4.1”Alchemy now runs on Effect 4.1 (#2754):
bun add effect@^4.1.0 @effect/platform-node@^4.1.0Effect 4.1 deletes effect/Crypto. Its SecureRandom and Digest services
replace it, and the platform layer provides them, so StackServices changes
with it:
export type StackServices = | FileSystem | Crypto | Digest | SecureRandom | PathFoldkit projects need foldkit@^0.170 and @foldkit/vite-plugin@^0.29
(#2790).
Alchemy Domain
Section titled “Alchemy Domain”Alchemy Domain shipped 30 features this release. Thanks Taras Kornichuk, who built most of them!
Deploys roll out every change to a model
Section titled “Deploys roll out every change to a model”A Domain model’s views and projections are derived from its events. Change the model, and the deploy brings every consumer up to date from the history, with no script to write (#2725, #2875):
| You change | The deploy |
|---|---|
| add a view or projection | feeds it every past event, in the order it was recorded |
bump a view’s version |
recomputes it, and every view and projection that reads it |
bump a projection’s version |
runs it again over the whole history |
list a view as lazy |
skips the backfill; each key fills itself on first read |
| add a policy | acts on new events only, so the past is never acted on twice |
| remove a view or projection | stops it |
Adding a view is one line in the model:
// deploy 1class Bank extends Domain.Model("Bank", { aggregates: [Account] }) {}
// deploy 2: AccountSummary folds every earlier eventclass Bank extends Domain.Model("Bank", { aggregates: [Account], views: [AccountSummary],}) {}Changing what a view computes is a version bump:
export class AccountSummary extends Domain.View("AccountSummary", { from: [Account], key: Account, state: Schema.Struct({ net: Schema.Number }), initial: { net: 0 }, evolve: { MoneyDeposited: (v, e) => ({ net: v.net + e.amount }), // new: applied to every past withdrawal MoneyWithdrawn: (v, e) => ({ net: v.net - e.amount }), }, version: 1,}) {}The deploy picks the cheapest correct strategy. A view keyed by the one aggregate it reads is rebuilt in place: each key re-reads its own aggregate’s events the first time it’s touched, so the deploy waits for nothing. Any other view, such as a total across every account, is rebuilt from the history in new storage, and the deploy waits and reports progress:
[DomainBackfillRun] Backfilling view:Deposits reading history 41% (1,690/4,096 subtrees, 410,211 instances) (48s) ...[DomainBackfillRun] Backfilled view:Deposits (112.4s)Reads stay correct while new code rolls out, history.complete tells a
screen when a view has caught up, and a deploy interrupted halfway picks up
where it stopped.
How it scales
Section titled “How it scales”On Cloudflare the replay runs as Durable Objects, not on the deploying machine. Every aggregate instance lists itself in a directory on its first commit: a tree where each node holds up to 256 instances and splits into 16 children when it fills. A backfill’s root splits that directory into a few hundred subtrees and hands them to relays. A relay given more than six items splits them among six child relays. Six because a Cloudflare Worker invocation runs at most six outgoing calls at a time, so a seventh would only wait in line. The tree grows deeper instead of wider, about log₆ of the instance count: 6 levels for 10,000 instances, 8 for a million.
At the bottom, a view keyed by its aggregate reads its own instance’s events and applies them. A view that many instances feed, like a timeline, gets a scanner per instance that pushes each key its share; once every scanner has pushed, each key applies its events in the order they were recorded. Every part of the run is a class only the new code has, so nothing starts on the old code while Cloudflare rolls the deploy out.
We measured it on Cloudflare, rebuilding a view over accounts with 10 deposits each:
| Accounts | Events | The deploy waited |
|---|---|---|
| 10,000 | 100,000 | 13 s |
| 100,000 | 1,000,000 | 58 s |
| 1,000,000 | 10,000,000 | 8 min |
Past about 100,000 accounts the limit isn’t the tree but Cloudflare’s cap on how fast one account can create and call Durable Objects. The run paces itself to that cap the way TCP does: it sends more while the account keeps up, halves when Cloudflare refuses calls, and resends the refused work. The million-account run held about 2,000 keys a second.
Docs: Consumers added later receive the history · Change a view: bump its version · Fill a view lazily · History at scale.
Alchemy upgrades your deployed model
Section titled “Alchemy upgrades your deployed model”A Domain model is more than code: on Cloudflare it is a set of Durable Object classes holding every aggregate, view row and running saga. Changing how those classes are laid out would normally mean a hand-written migration and a careful cutover. In Alchemy it’s part of the deploy.
From this release, the deployed layout is versioned. Every Worker that hosts
a model records the Domain version it was deployed with, and the Worker’s
reconciler upgrades older layouts on the next deploy, renaming classes in
place in the same upload, so no data is copied or lost
(#2560). Future layout
changes ship as new upgrade steps in Alchemy, and your deploy applies them.
A Worker deployed by a newer Alchemy is never moved back: an older one
refuses with DomainVersionTooNew before uploading anything.
For the bank example, the first deploy with beta.85 changes the Worker like this:
Worker tag (none) tag alchemy:domain:v0 class AccountAggregate class Bank.AccountAggregate class OpenAccountWorkflow class Bank.OpenAccountWorkflowThe renames ride in the same upload as the new code, as Cloudflare
renamed_classes migrations, so every account and running saga keeps its
storage and nothing is copied.
This is what DDD buys you here. Because Alchemy knows the shape of your model (its aggregates, views and sagas), it can own the infrastructure under it, and evolve that infrastructure for you the same way it evolves a view.
Each model now owns its data, so two models that list the same aggregate get separate instances, even in one Worker. And a model can keep all of it in a Cloudflare jurisdiction (#2601):
Layer.provide(DomainCloudflare.layerDurableObjects({ jurisdiction: "eu" }));A jurisdiction is part of an object’s identity, so a model that already has data starts empty in its new jurisdiction, and its old objects stay where they were.
Docs: Upgrade without losing data · Keep the model in a jurisdiction.
Serve a Domain model over HTTP, MCP or the CLI
Section titled “Serve a Domain model over HTTP, MCP or the CLI”A Domain.Api names the parts of a model a client may reach
(#2820):
export class BankApi extends Domain.Api("BankApi", { model: Bank, commands: ["DepositMoney", "WithdrawMoney"], views: ["AccountSummary"],}) {}The same Api can now be served to whoever needs it, through the same middleware and rejections, so an agent can do exactly what a signed-in client can and no more.
As an HTTP JSON API with an OpenAPI document, with live reads as Server-Sent Events, for partners, scripts and browsers (#2587):
const BankHttp = DomainHttp.toHttpApi(BankApi);
export const BankHttpLive = HttpApiBuilder.layer(BankHttp.api, { openapiPath: "/openapi.json",}).pipe(Layer.provide(BankHttp.layer), Layer.provide(BankLive));As an MCP server for agents, or as Effect AI tools for an agent inside your app (#2586):
export const BankMcp = DomainAi.toMcp(BankApi).pipe( Layer.provide( McpServer.layerHttp({ name: "bank", version: "1.0.0", path: "/mcp" }), ), Layer.provide(BankLive),);As a command-line tool, for people and agents in a terminal (#2588):
$ bank deposit-money --target a-1 --amount 1000{ "reply": { "balance": 1000 }, "stream": "Account/a-1", "version": 1 }
$ bank withdraw-money --target a-1 --amount 5000 # exit code 1{ "_tag": "InsufficientFunds", "balance": 1000, "requested": 5000 }And as Foldkit commands and subscriptions for a web app (#2700).
Docs: HTTP API · MCP · CLI · Foldkit.
Stories run real adapters and show what went wrong
Section titled “Stories run real adapters and show what went wrong”A story tests a model as given, when, then. Stories now tell you exactly what failed. A failing story prints every step, marked passed, failed or not run, with the events committed so far (#2475):
yield* story( Story.clock("2026-03-01T09:00:00Z"), Story.given(c1, new Incremented({ by: 2, total: 2 })), Story.when(c1, new Increment({ by: 3 })), Story.then(c1, new Incremented({ by: 4, total: 6 })), Story.replied({ total: 6 }),);StoryFailure: Then: on Counter/c-1
✓ 1 Clock 2026-03-01T09:00:00.000Z ✓ 2 Given Counter/c-1: Incremented { by: 2, total: 2 } ✓ 3 When Counter/c-1: Increment { by: 3 } ✗ 4 Then Counter/c-1: expected Incremented { by: 4, total: 6 } got Incremented { by: 3, total: 5 } 5 Replied { total: 6 } events so far Counter/c-1: Incremented → IncrementedA story also fails on any event a command or policy committed that no step asserts, on every stream, so a side effect can’t slip past it (#2551):
StoryFailure: committed events no step asserts (assert them with then): Counter/c-1: Capped (from step 4)Steps type-check against the last command, so a rejection it can’t return, or a reply it doesn’t give, is a compile error instead of a failing run (#2559):
story( Story.when(c1, new Increment({ by: 1 })), Story.rejected(new NotAnOperator()), // Type 'RejectedStep<NotAnOperator>' is not assignable to type // '"Increment does not reject with NotAnOperator"'.);Time moves with Story.advance, and scheduled commands fire when they come
due (#2629):
story( Story.when(c1, new Cap()), Story.then(c1, new Capped()), Story.advance("1 hour"), Story.then(c1, new Reminded()), // the scheduled reminder fired);Stories also changed how external systems are tested. Before, every
external system a story listed was scripted, and nothing outside the model
ran. Now you choose which systems to fake with stub, and the rest run for
real through the adapter Layers you provide
(#2477,
#2481).
Either way, every call is asserted. Story.call checks what the model sent,
and the next step settles how the call ended. For a stubbed system, that
step supplies the answer. For a real one, it checks the answer the adapter
gave:
// FraudCheck is stubbed: the story supplies its answerStory.call(FraudCheck.score, { accountId: account.id, amount: 60_000 }),Story.succeed(FraudCheck.score, { risk: 0.95 }),
// Payments runs for real: the story checks what the adapter returnedStory.call(Payments.refund, { settlementId: "st_1", amount: 500 }),Story.returned(Payments.refund, { refundId: "re_st_1" }),Stubbed (stub: [...]) |
Real (adapter Layer provided) | |
|---|---|---|
| what was sent | Story.call |
Story.call |
| success | Story.succeed supplies the value |
Story.returned asserts the value |
| declared error | Story.fail supplies it |
Story.failed asserts it |
| defect | Story.die supplies it |
Story.died asserts it |
A real call that no step asserts fails the story, so nothing an adapter does goes unchecked. Supplied answers are checked against the operation’s schemas, like a real adapter’s (#2548), and a story that leaves a system unstubbed requires its adapter Layer in its type:
const story = Story.make(Bank, { layer: BankHandlers, stub: [FraudCheck] });
it.effect("refunds through the real payments adapter", () => story( Story.when( account, new RecordSettlement({ settlementId: "st_1", amount: 500 }), ), Story.then( account, new SettlementOnClosedAccount({ settlementId: "st_1", amount: 500 }), ), Story.call(Payments.refund, { settlementId: "st_1", amount: 500 }), Story.returned(Payments.refund, { refundId: "re_st_1" }), ).pipe(Effect.provide(StripePayments)), // required: Payments isn't stubbed);Update your stories
Section titled “Update your stories”Rename the options and steps:
const story = Story.make(Bank, { layer: BankHandlers, externalSystems: [FraudCheck, Payments], stub: [FraudCheck, Payments],});
story( Story.when(account, new WithdrawMoney({ amount: 60_000 })), Story.expectCall(FraudCheck.score, { amount: 60_000 }), Story.resolve(FraudCheck.score, { risk: 0.95 }), Story.call(FraudCheck.score, { amount: 60_000 }), Story.succeed(FraudCheck.score, { risk: 0.95 }),);Then fix what the stricter checks now catch:
- Every call needs a
Story.callstep, stubbed or real. Before, a bareresolveanswered the only pending call; now each answer follows thecallthat checks what was sent. - List every system you want faked in
stub. A system left out runs for real, and the story’s type requires its adapter Layer. - Assert every committed event. A story fails on events no
thenasserts, including ones a policy writes on other instances. Add aStory.thenfor each. - Answer with values the schema accepts. A stubbed answer the operation’s success schema rejects now fails the policy.
- Move the clock forward only. A
Story.clockearlier than the current time, or a string that isn’t a time, now fails the story (#2564). - Use
clientInProcessfor Api tests.CustomerApi.testClientis nowCustomerApi.clientInProcess(#2550).
Docs: Story · Answer outside systems · Assert real calls.
Cloud Run services and containers, redesigned
Section titled “Cloud Run services and containers, redesigned”A Cloud Run service runs one or more containers in each instance. The first
takes requests, and the rest run beside it on localhost. Cloud Run in
Alchemy was rebuilt around that model. Any container can be an image or your
own Effect program, and a container lists or binds the ones it needs
(#2644,
#2652,
#2654,
#2657).
One container
Section titled “One container”A service starts as a single image:
import * as Alchemy from "alchemy";import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "MyApp", { providers: GCP.providers(), state: GCP.state() }, Effect.gen(function* () { const proxy = yield* GCP.Run.Service("Proxy", { template: { containers: [ { image: "us-docker.pkg.dev/cloudrun/container/hello", ports: [{ containerPort: 8080 }], }, ], }, }); return { url: proxy.uri }; }),);Add a sidecar
Section titled “Add a sidecar”GCP.Run.Container declares a container as a resource. List it after the
first one, and dependsOn holds the proxy back until the cache passes its
startup probe:
Effect.gen(function* () { const cache = yield* GCP.Run.Container("Cache", { image: "docker.io/library/redis:7-alpine", port: 6379, startupProbe: { tcpSocket: {} }, }); const proxy = yield* GCP.Run.Service("Proxy", { template: { containers: [ { image: "us-docker.pkg.dev/cloudrun/container/hello", ports: [{ containerPort: 8080 }], dependsOn: [cache.name], }, cache, ], }, }); return { url: proxy.uri };}),A plain container spec works in the list too. Every container takes
startupProbe, livenessProbe and dependsOn.
Run your own program
Section titled “Run your own program”Give the service main and an Effect, and Alchemy bundles your program into
the first container. An Effect service binds its sidecars instead of listing
them. bindPort adds the Cache to the revision, makes the service wait for
it, and returns its address:
import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/http/HttpServerResponse";
export const Cache = GCP.Run.Container("Cache", { image: "docker.io/library/redis:7-alpine", port: 6379, startupProbe: { tcpSocket: {} },});
export default class Api extends GCP.Run.Service<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const cache = yield* GCP.Run.bindPort(Cache, 6379); // cache.url === "http://localhost:6379" return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Run your own program in a sidecar
Section titled “Run your own program in a sidecar”A Container with main runs your Effect program in its own image. Its
methods become a typed client for whoever binds it, called over localhost,
streams included. A program Container binds sidecars of its own:
import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";import * as Stream from "effect/Stream";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { Cache } from "./api.ts";
export default class Worker extends GCP.Run.Container<Worker>()( "Worker", { main: import.meta.url, port: 3000, startupProbe: { httpGet: { path: "/health" } }, }, Effect.gen(function* () { const cache = yield* GCP.Run.bindPort(Cache, 6379); return { greet: (name: string) => Effect.succeed(`hello ${name}`), count: () => Stream.make(1, 2, 3), fetch: Effect.succeed(HttpServerResponse.text("ok")), }; }),) {}The service binds the Worker with bindContainer and calls its methods:
import Worker from "./worker.ts";
export default class Api extends GCP.Run.Service<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const cache = yield* GCP.Run.bindPort(Cache, 6379); const worker = yield* GCP.Run.bindContainer(Worker); return { fetch: Effect.succeed(HttpServerResponse.text("hello")), fetch: worker.greet("sam").pipe(Effect.map(HttpServerResponse.text)), }; }),) {}Bindings chain, so one revision now runs three containers and starts them in order:
revision├── start Cache → TCP :6379├── start Worker (dependsOn Cache) → GET :3000/health├── start Api (dependsOn Worker) → GET :8080/health└── Ready → traffic to ApiA service that runs an image lists its containers; a service or Container that runs your program binds them, which gives it a client and orders startup for it.
Call private Cloud Run services
Section titled “Call private Cloud Run services”InvokeService calls the methods of a private Cloud Run service, signed with
the caller’s ID token, with types taken from the service class
(#2653):
export default class Quotes extends GCP.Run.Service<Quotes>()( "Quotes", { main: import.meta.url }, // the IAM invoker check stays on Effect.gen(function* () { return { quote: (symbol: string) => Effect.succeed({ symbol, price: 42 }), ticks: (symbol: string) => Stream.make(41, 42, 43), }; }),) {}
// in another serviceconst quotes = yield* GCP.Run.InvokeService(Quotes);const { price } = yield* quotes.quote("GOOG");Pass an Effect RPC schema or an HttpApi api to InvokeService or
bindContainer for a client with typed errors, and serve it with
ServeRpc or ServeHttpApi
(#2654):
const tasks = yield* GCP.Run.InvokeService(TaskBackend, { schema: TaskRpcs });// Effect<Task, TaskNotFound | …>const task = yield* tasks.getTask({ id: "t1" });A service that returns methods but turns off the invoker check fails to deploy, so RPC methods are never public by accident.
Docs: Call it from another service · Run it as a sidecar.
Connect AWS and GCP without keys
Section titled “Connect AWS and GCP without keys”A GCP binding on a Lambda, ECS task, EC2 instance or App Runner service no longer creates a service account key. The host’s IAM role impersonates the service account through workload identity federation (#2648). Your code doesn’t change:
export default Worker.make( { main: import.meta.url, functionUrl: true }, Effect.gen(function* () { const publish = yield* GCP.PubSub.Publish(yield* Events); return { fetch: publish({ body: { messages: [{ data: btoa("hello") }] } }).pipe( Effect.as(HttpServerResponse.text("published")), ), }; }).pipe(Effect.provide(GCP.PubSub.PublishHttp)),);WorkerServiceAccount GCP.IAM.ServiceAccountWorkerServiceAccountKey GCP.IAM.ServiceAccountKeyAwsWorkloadIdentityPool GCP.IAM.WorkloadIdentityPoolAwsWorkloadIdentityProvider GCP.IAM.WorkloadIdentityProviderWorkerWorkloadIdentityUser GCP.IAM.MemberThe other direction works the same way: Cloud Run and Cloud Functions assume
their AWS role with a Google ID token, with no IAM user or access key
(#2645).
WorkloadIdentityPool and WorkloadIdentityProvider are resources too, for
keyless CI from GitHub Actions or any OIDC issuer
(#2130).
Docs: GCP federated identity · AWS federated identity.
Migrations on Cloud SQL, AlloyDB and Spanner
Section titled “Migrations on Cloud SQL, AlloyDB and Spanner”Every GCP SQL database applies migrations on deploy, like D1, Neon,
PlanetScale, Turso, RDS and DSQL
(#2651):
const db = yield* GCP.SQL.Database("App", { instance: instance.instanceName, migrations: "./migrations", migrationsUser: user.userName, migrationsPassword: password.text,});Cloud SQL runs them over its data API by default, which needs no network
route, or through the Cloud SQL connector. Spanner resumes a failed
migration at the statement that failed. GCP.SQL.Instance can now be
private-IP only, with SSL mode and backup retention in the stack
(#2642), and Pub/Sub
subscriptions write straight into BigQuery
(#2213). Thanks
Lucas Duailibe!
Docs: Cloud SQL.
Sign in with gcloud
Section titled “Sign in with gcloud”A GCP profile signs in through a named gcloud configuration, the way an AWS
profile uses aws sso login
(#2635,
#2680):
gcloud auth login --configuration=workalchemy profile edit --add GCP --method gcloud --set configuration=workalchemy profile edit --add GCP lists your configurations, or creates one
and opens the browser. Each run asks gcloud for a fresh token, and nothing is
copied into the profile. Signed in as yourself, you can also create projects
without an organization or folder
(#2616).
Docs: GCP credentials.
Custom domains with any DNS provider
Section titled “Custom domains with any DNS provider”A custom domain’s DNS no longer has to live with the platform that serves
it. domain.dns names the DNS provider that holds the records, on every
platform (#1888):
yield* AWS.Lambda.Function("Api", { main: "./src/handler.ts", domain: { name: "api.example.com", dns: Cloudflare.DNS.Adapter(), },});
yield* Cloudflare.Worker("Web", { main: "./src/web.ts", domain: { name: "app.example.com", zoneId: saasZone.zoneId, cnameTarget: fallback.origin, dns: AWS.Route53.Adapter(), },});
yield* Fly.Website.Vite("Docs", { domain: { name: "docs.example.com", dns: Hetzner.DNS.Adapter(), },});Alchemy publishes whatever the platform asks for: certificate validation records, ACME challenges, verification TXTs and aliases. Cloudflare, Route 53 and Hetzner adapters ship today, and an adapter for another DNS provider is three functions. Thanks Dan Myles and bjorntechTobbe!
Docs: DNS adapters.
Cloudflare State Store v8
Section titled “Cloudflare State Store v8”The Cloudflare state store is now deployed with direct API calls instead of a stack, as state store v8, and you choose its Worker name, account and domain (#2385).
Choose the Worker name
Section titled “Choose the Worker name”workerName sets the store’s Worker name. Without it, you get the same default
store as before:
state: Cloudflare.state({ workerName: "team-a-state" }),It replaces name, which prefixed the Worker name with alchemy-state-store-.
A store you named that way keeps its Worker and secrets when you pass the full
name:
Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state({ name: "team-a" }), state: Cloudflare.state({ workerName: "alchemy-state-store-team-a" }), }, Effect.gen(function* () {}),);--name takes the Worker name too:
alchemy state list --backend cloudflare --name alchemy-state-store-team-aPin the account and serve it on your domain
Section titled “Pin the account and serve it on your domain”Pin the account so a login against another one can’t read or deploy the store
there, and serve it on a hostname in one of your zones for networks that can’t
reach workers.dev:
state: Cloudflare.state({ accountId: "c8eeff0e...", domain: "alchemy-state.example.com",}),Upgrade from v7
Section titled “Upgrade from v7”Your next alchemy deploy asks to upgrade the store. In CI, pass --yes, or
upgrade it ahead of time:
alchemy state bootstrapThe upgrade keeps the store’s auth token, encryption key and state, and only ever creates a secret that’s missing, so a deploy can’t rotate the token or replace the key. A store that has lost its key isn’t given a new one unless you confirm.
Docs: Cloudflare state store · More than one store.
AWS state now uses DynamoDB and plans 270× faster
Section titled “AWS state now uses DynamoDB and plans 270× faster”AWS.state() keeps state in one on-demand DynamoDB table per account and
region, and a deploy loads the stage with one Query
(#2683,
#2691):
| S3 | DynamoDB | |
|---|---|---|
| plan, 250 resources | 21.6 s | 0.08 s |
| unchanged redeploy, 1,000 resources | 195 s | 0.5 s |
| destroy, 1,000 resources | 162 s | 12.5 s |
Existing stages move with one command. Older Alchemy versions are fenced out of the S3 copy, and nothing in S3 is deleted:
$ alchemy state migratemy-app/prod: migrated (42 records)
$ alchemy state migrate --rollback # back to S3, before downgradingDocs: AWS state store · Migrating from S3.
Alchemist: the Alchemy CLI as a programmatic API
Section titled “Alchemist: the Alchemy CLI as a programmatic API”The alchemy CLI is now a thin layer over alchemy/Alchemist. Everything
it does (plan, apply, drift, nuke, logs, state) is one call you can make from
your own program, and every step is a typed event you can handle
(#2765):
import * as Alchemist from "alchemy/Alchemist";
const program = Effect.gen(function* () { const plan = yield* Alchemist.plan({ stack: Stack, operation: "deploy", stage: "prod", }); if (Alchemist.hasChanges(plan)) yield* Alchemist.apply(plan);
const scan = yield* Alchemist.Drift.scan({ stack: Stack, stage: "prod" }); if (Alchemist.Drift.hasDrift(scan)) yield* Alchemist.Drift.execute(scan);}).pipe( Alchemist.onEvent((event) => Effect.log(event.type)), Effect.provide(Alchemist.layer()), Effect.scoped,);Build your own CLI, a deploy bot or a CI step on the same API the alchemy
command uses. Every run also writes one NDJSON log under .alchemy/log/,
child processes and sidecars included. Thanks
Rahul Mishra!
Docs: CLI API.
@alchemy.run/test: a test runner for Effect
Section titled “@alchemy.run/test: a test runner for Effect”Alchemy’s own test runner is now on npm as @alchemy.run/test
(#2452). It is small
and built only for Effect: tests are Effects, and every test file runs in
one bun process, with files and tests in flight at once.
bun add -d @alchemy.run/test@latest effectbunx alchemy-testimport { describe, expect, it } from "@alchemy.run/test";import * as Effect from "effect/Effect";
const charge = (amount: number) => Effect.succeed(amount > 5000 ? 402 : 200);
describe("Checkout", () => { it.live("charges the card", () => Effect.gen(function* () { yield* Effect.log("POST /charges { amount: 9000 }"); const status = yield* charge(9000); if (status !== 200) { yield* Effect.logError(`payment provider returned ${status}`); } expect(status).toBe(200); }), );});It prints one line per test as it finishes, with a running count, so you can follow a long run as it goes. Each test’s stdout and stderr are buffered on their own. A passing test’s output stays quiet; a failing test prints its error and its output at once, in one piece, never interleaved with tests running beside it. The last line links one log file holding every test’s output, passes included:
running 4 tests from 1 files
[1/4 ✓1 ✗0] ✓ test/Checkout.test.ts > Checkout > adds an item to the cart (1ms)[2/4 ✓2 ✗0] ✓ test/Checkout.test.ts > Checkout > applies a discount (151ms)[3/4 ✓2 ✗1] ✗ test/Checkout.test.ts > Checkout > charges the card (0ms) AssertionError: expected 402 to be 200 at <anonymous> (test/Checkout.test.ts:21:42)--- captured output ---POST /charges { amount: 9000 }payment provider returned 402--- end output ---[4/4 ✓3 ✗1] ✓ test/Checkout.test.ts > Checkout > sends a receipt (82ms)
Tests: 1 failed | 3 passed (1 files, 250ms)
Full log: .alchemy/log/test/2026-11-02T19-02-29-pid37920.log (2.6 KB)That last line is there for agents. An agent running the suite reads the log to see what happened in any test, without piping output to a file or running the tests again. When nothing finishes for 10 s, the runner lists the tests still running and how long each has taken, so a hang names itself.
--stage <name> gives each git worktree its own stage, so agents in
parallel worktrees test against one cloud account without colliding
(#2613). Test.make,
which deploys a stack for a test file, lives in the same package and
works with bun test and Vitest too.
Docs: Test harness · Worktrees.
Lambda aliases, streaming and partial batch failures
Section titled “Lambda aliases, streaming and partial batch failures”alias publishes a version on each deploy and routes every event source,
URL and integration through it, so a rollback is one update-alias call
(#2504):
export default class Api extends AWS.Lambda.Function<Api>()( "Api", { main: import.meta.url, alias: "live", functionUrl: true }, Effect.gen(function* () { yield* AWS.SQS.consumeQueueMessages(jobs, processJob); // → api:live return { fetch: handleHttp }; // URL on qualifier "live" }),) {}SQS, Kinesis and DynamoDB Streams handlers receive the Lambda event and can return its batch response, so only the failed records are retried (#2508):
yield* AWS.SQS.consumeQueueMessages( queue, Effect.fn(function* (event) { const failed = yield* Effect.filter(event.Records, (record) => handle(record.body).pipe( Effect.as(false), Effect.catchCause(() => Effect.succeed(true)), ), ); return { batchItemFailures: failed.map((r) => ({ itemIdentifier: r.messageId })), }; }),);A RESPONSE_STREAM Function URL streams an Effect response body instead of
buffering it (#2502), and
a handler that runs past its timeout is interrupted, so finalizers run and
telemetry flushes before Lambda kills the sandbox
(#2682):
timeout: Duration.seconds(30), // handler interrupted at 30 stimeoutMargin: Duration.seconds(2), // Lambda configured for 32 sDocs: Lambda.
95 bug fixes
Section titled “95 bug fixes”Most of this release is fixes, many found by new test suites rather than by
users. About 900 provider failures that were a plain Error are now tagged
errors you can catch (#2711),
and ECR images hash only what .dockerignore keeps, so hashing a large repo
dropped from 344 s to 8 s
(#2351).
One conformance suite for every workflow engine
Section titled “One conformance suite for every workflow engine”Every WorkflowEngine implementation now runs the same 28-case conformance
suite: the Durable Object engine, Cloudflare Workflows and Lambda durable
functions (#2916). The
cases cover results, idempotent starts, durable sleeps, signals,
compensations, defects, retries, interrupts and child workflows. The suite
found 12 of the 15 engine bugs fixed in this release, including:
- Sleeps ran later on every hop, because wake-ups inherited the Durable Object alarm’s retry backoff. Four one-second sleeps took over 45 s; they now take about 4 s.
- A repeated or late signal hung for three minutes, then died.
- An interrupt sent right after start was dropped, or stopped the run without running its compensations.
Activity.retrysaw attempt 1 on every retry.Activity.raceAllhung forever on the two engines that can’t support it; it now fails at once and points to the Durable Object engine.
Drift only reports real changes
Section titled “Drift only reports real changes”alchemy drift now sees every setting that Lambda, DynamoDB, S3, SQS,
EventBridge, Step Functions, WAF, ELB, ECS and EC2 networking manage, so a
change made in the console shows up
(#2661,
#2705). Untouched
resources no longer show up as drifted
(#2496,
#2169,
#2647):
$ alchemy deploy && alchemy drift[Api] drifted[Db] driftedPlan: no changes--repair recreates deleted resources in dependency order
(#2495), and EC2 creates
are idempotent, so a retry never launches a second instance
(#2598). Thanks
DivMode,
Gjermund Garaba and
Ahmed Hesham!
Deploys never take over a resource they don’t own
Section titled “Deploys never take over a resource they don’t own”- A resource in a dependency cycle checks ownership before it’s created, so it can’t overwrite a Worker or Lambda someone else owns (#2195, #2620).
- A replacement is refused before it touches anything when its new name belongs to someone else (#2200).
- Recovering an interrupted create never deletes a same-named resource by guessing (#2199, #2534).
- A ref to a resource in the same stack is a dependency, so destroy no longer races it (#2913).
alchemy destroyplans from state without running your program, so a dev stage tears down without cloud credentials (#2491).
Thanks Nacho Vazquez!
Cloudflare
Section titled “Cloudflare”- A secret removed from a Worker’s
envis unbound instead of staying live forever (#2327). forceDestroydeletes an R2 bucket holding unfinished multipart uploads (#2537).- A new container-backed Durable Object deploys in one pass, and a local-only Container image works (#2342, #2922).
- Preview Workers keep
jsonbindings and JWKs as written (#2127), and two async Workers can bind each other (#2681). - The login picker lists every account, not the first 20 (#2902), and a Secrets Store with more than 20 secrets finds them (#2443).
Thanks Patt O’Brien, Drew Powers, Andoni Enriquez and Harry Solovay!
Local dev
Section titled “Local dev”- A Vite app with
publicDir: falseno longer serves files from your machine throughASSETS(#2615), and the Bun RPC sidecar listens on loopback only (#2845). - Vite plugins and
server.proxykeep their own WebSockets (#2516), and large responses behind a local reverse proxy aren’t truncated (#2841). alchemy devruns on NixOS withdev.workerdorALCHEMY_WORKERD_PATH(#2791), and Docker is contacted only for Workers with containers (#2784).- Concurrent processes on one OAuth profile no longer revoke it (#1923).
Thanks Yanqi Zong and 0xMilkEnjoyer!
Also in this release
Section titled “Also in this release”Alchemy Domain
Aggregate.predictruns a command’s rules on the client, so a UI shows the outcome before the server answers (#2568), andView.snapshotreturns state with the cursor to follow it live from (#2567). Docs.- Views, projections and policies can be keyed by a Schema value instead of an aggregate (#2554), and a scheduled command can be cancelled with
unschedule(#2547). - External system calls are checked against their schemas, and an operation can declare several errors (#2548, #2553).
Cloudflare
codeUpdateStrategysets how running Durable Objects move onto new code (#2863). Docs.- Workers import any file through
rules(#2792), and Flagship flags evaluate locally inalchemy dev(#2746). consumeNotificationshandles Cloudflare alerts in code, and Effect logs reach Workers Logs at their own level (#2519).- Workflows, Queues and R2 buckets take a
jurisdiction(#2734, #2538, #2758), and Email Routing takes subdomains and subaddressing (#2762).
AWS
- Smaller runtime bundles: a Lambda ships 0.51 MB instead of 1.30 MB, minified with source maps (#2492).
EventBridge.Endpointglobal endpoints, regional NAT gateways, and Auto Scaling by instance attributes (#2507, #2498, #2817).
Tooling
- Docker builds take args and BuildKit secrets, and
Docker.Containertakes hardening options (#2225, #2623). - Drizzle 1.0 rc.5 asks rename-or-create questions during plan (#2433).
Command.Devtakes aportor waits for its URL (#2750). Docs.- A stack can declare its stages, so a misspelled stage is a type error (#2131).
See the CHANGELOG for every other change, and thanks to everyone who contributed.