Skip to content

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.

Alchemy now runs on Effect 4.1 (#2754):

Terminal window
bun add effect@^4.1.0 @effect/platform-node@^4.1.0

Effect 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
| Path

Foldkit projects need foldkit@^0.170 and @foldkit/vite-plugin@^0.29 (#2790).

Alchemy Domain shipped 30 features this release. Thanks Taras Kornichuk, who built most of them!

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 1
class Bank extends Domain.Model("Bank", { aggregates: [Account] }) {}
// deploy 2: AccountSummary folds every earlier event
class 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:

Terminal window
[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.

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.

One path down the tree; at every level the other five relays do the same beside it. Each relay calls at most six at once, because a Cloudflare Worker invocation runs at most six outgoing calls at a time.

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.

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.OpenAccountWorkflow

The 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"],
}) {}
Bank · bounded contextexposedprivateHTTP clienttoHttpApiAgenttoMcpTerminaltoCliBankApithe only way inDepositMoneyWithdrawMoneyAccountSummaryAccountStatementFraudReviewLedgerFraudCheck
Clients reach only what the Api lists. The aggregate, the policy, the projection and the external system stay inside, free to change.

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):

Terminal window
$ 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 → Incremented

A 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 answer
Story.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 returned
Story.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
);

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.call step, stubbed or real. Before, a bare resolve answered the only pending call; now each answer follows the call that 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 then asserts, including ones a policy writes on other instances. Add a Story.then for 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.clock earlier than the current time, or a string that isn’t a time, now fails the story (#2564).
  • Use clientInProcess for Api tests. CustomerApi.testClient is now CustomerApi.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).

A service starts as a single image:

alchemy.run.ts
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 };
}),
);

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.

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:

src/api.ts
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")),
};
}),
) {}

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:

src/worker.ts
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:

src/api.ts
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 Api

A 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.

Docs: Sidecars · Cloud Run.

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 service
const 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.

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.ServiceAccount
WorkerServiceAccountKey GCP.IAM.ServiceAccountKey
AwsWorkloadIdentityPool GCP.IAM.WorkloadIdentityPool
AwsWorkloadIdentityProvider GCP.IAM.WorkloadIdentityProvider
WorkerWorkloadIdentityUser GCP.IAM.Member

The 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.

A GCP profile signs in through a named gcloud configuration, the way an AWS profile uses aws sso login (#2635, #2680):

Terminal window
gcloud auth login --configuration=work
alchemy profile edit --add GCP --method gcloud --set configuration=work

alchemy 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.

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.

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).

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:

Terminal window
alchemy state list --backend cloudflare --name alchemy-state-store-team-a

Pin 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",
}),

Your next alchemy deploy asks to upgrade the store. In CI, pass --yes, or upgrade it ahead of time:

Terminal window
alchemy state bootstrap

The 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:

Terminal window
$ alchemy state migrate
my-app/prod: migrated (42 records)
$ alchemy state migrate --rollback # back to S3, before downgrading

Docs: 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.

Terminal window
bun add -d @alchemy.run/test@latest effect
bunx alchemy-test
import { 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 s
timeoutMargin: Duration.seconds(2), // Lambda configured for 32 s

Docs: Lambda.

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.retry saw attempt 1 on every retry.
  • Activity.raceAll hung forever on the two engines that can’t support it; it now fails at once and points to the Durable Object engine.

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):

Terminal window
$ alchemy deploy && alchemy drift
[Api] drifted
[Db] drifted
Plan: 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 destroy plans from state without running your program, so a dev stage tears down without cloud credentials (#2491).

Thanks Nacho Vazquez!

  • A secret removed from a Worker’s env is unbound instead of staying live forever (#2327).
  • forceDestroy deletes 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 json bindings 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!

  • A Vite app with publicDir: false no longer serves files from your machine through ASSETS (#2615), and the Bun RPC sidecar listens on loopback only (#2845).
  • Vite plugins and server.proxy keep their own WebSockets (#2516), and large responses behind a local reverse proxy aren’t truncated (#2841).
  • alchemy dev runs on NixOS with dev.workerd or ALCHEMY_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!

Alchemy Domain

  • Aggregate.predict runs a command’s rules on the client, so a UI shows the outcome before the server answers (#2568), and View.snapshot returns 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

  • codeUpdateStrategy sets how running Durable Objects move onto new code (#2863). Docs.
  • Workers import any file through rules (#2792), and Flagship flags evaluate locally in alchemy dev (#2746).
  • consumeNotifications handles 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.Endpoint global endpoints, regional NAT gateways, and Auto Scaling by instance attributes (#2507, #2498, #2817).

Tooling

  • Docker builds take args and BuildKit secrets, and Docker.Container takes hardening options (#2225, #2623).
  • Drizzle 1.0 rc.5 asks rename-or-create questions during plan (#2433).
  • Command.Dev takes a port or 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.