Skip to content

2.0.0-beta.71 - Effect beta.105 & Better Auth

Alchemy now runs on effect 4.0.0-beta.105, with drizzle 1.0.0-rc.5-ab785fc bumped in lockstep to survive effect’s TaggedErrorClassTaggedError rename. On top of that, @alchemy.run/better-auth gains an effectful API and a database Layer for every platform, Worker props can now be an Effect, and a batch of alchemy dev fixes makes configured ports, queue consumers, and pre-beta.66 state behave.

effect beta.105 and the drizzle rc.5 lockstep

Section titled “effect beta.105 and the drizzle rc.5 lockstep”

effect 4.0.0-beta.105 renamed Schema.TaggedErrorClass to Schema.TaggedError:

export class AuthError extends Schema.TaggedErrorClass<AuthError>()(
export class AuthError extends Schema.TaggedError<AuthError>()(
"AuthError",
{ message: Schema.String },
) {}

drizzle-orm 1.0.0-rc.4 — which ships effect-native clients built on that API — broke against it, which pinned the entire dependency tree: you couldn’t take the effect upgrade without breaking drizzle, and vice versa. Drizzle’s 1.0.0-rc.5-ab785fc release ships TaggedError natively, so beta.71 upgrades both in lockstep (#1132, #1184):

"effect": ">=4.0.0-beta.102",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-kit": "1.0.0-rc.4",
"effect": ">=4.0.0-beta.105",
"drizzle-orm": "1.0.0-rc.5-ab785fc",
"drizzle-kit": "1.0.0-rc.5-ab785fc",

When you upgrade, pin drizzle exactly — a ^1.0.0-rc.4 range can still hand you the rc.4 that breaks against effect beta.105.

Alongside the version bump, the optional Drizzle drivers now load lazily (#1119) — importing the Postgres or MySQL client no longer requires @effect/sql-mysql2/@effect/sql-pg to be installed unless you actually connect:

import * as Drizzle from "alchemy/Drizzle";
import * as Drizzle from "alchemy/Drizzle/Postgres";
const db = yield* Drizzle.Postgres(conn.connectionString, { relations });

Docs: SQL & Drizzle.

Better Auth: every database, effectful API

Section titled “Better Auth: every database, effectful API”

@alchemy.run/better-auth now supports every storage backend and wraps Better Auth’s async API in an effectful interface (#1157): yield* BetterAuth(options) gives you a fully-typed auth instance, and the database is a Layer you pick per platform. Plugin typing flows end-to-end — every auth.api.* endpoint is an Effect with a tagged BetterAuthApiError — and schema migrations run automatically at deploy as an alchemy Action, input-hash diffed and tree-shaken out of runtime bundles.

const auth = yield* BetterAuth({
basePath: "/auth",
emailAndPassword: { enabled: true },
plugins: [anonymous()], // auth.api.signInAnonymous is now typed
});
// serve the Better Auth routes on Workers and Lambda unchanged
if (request.url.startsWith("/auth")) return yield* auth.fetch;
// or call endpoints directly — typed errors, set-cookie preserved
yield* auth.api.signInEmail({ body: { email, password } }).pipe(
Effect.catchTag("BetterAuthApiError", (e) => ...), // e.statusCode, e.body.code
);

The database Layers cover every environment → target pair: CloudflareD1 (native binding + migrations over the D1 HTTP API, local simulator included), Neon (serverless driver over WebSocket — no Hyperdrive, no pg), AuroraDataApi (SQL over HTTPS with IAM — no VPC attachment), CloudflareHyperdrive, generic Postgres and MySQL TCP fallbacks (PlanetScale, RDS, any connection string), SQLite for local dev, Memory for tests, and Drizzle to bring your own db via better-auth’s official adapter.

Because the database is a Layer, your auth code never changes — you swap the provided Layer. On D1:

import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
export const AuthDb = Cloudflare.D1.Database("AuthDb");
export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()(
"AuthWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } });
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/auth")) return yield* auth.fetch;
return HttpServerResponse.text("ok");
}),
};
}).pipe(Effect.provide(CloudflareD1(AuthDb))),
) {}

Moving to PlanetScale Postgres is only a Layer swap — the BetterAuth program is untouched:

import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1";
import { Postgres } from "@alchemy.run/better-auth/Postgres";
export const AuthDb = Cloudflare.D1.Database("AuthDb");
export const AuthDb = Planetscale.PostgresDatabase("AuthDb");
export const AuthRole = Planetscale.PostgresRole("AuthRole", { database: AuthDb });
const AuthDatabase = Layer.unwrap(
Effect.map(AuthRole, (role) => Postgres(role.connectionUrl)),
);
export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()(
"AuthWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } });
return {
fetch: ...,
};
}).pipe(Effect.provide(CloudflareD1(AuthDb))),
}).pipe(Effect.provide(AuthDatabase)),
) {}

The API reference also learned to document Layers as first-class pages (#1159) — each database Layer gets its own generated page declaring what it provides and its peer dependencies.

Docs: Better Auth.

A configured dev port could serve the wrong app (#1129): the proxy bound only 127.0.0.1, browsers prefer ::1, and any framework dev server hunting from its default port could squat the IPv6 half of localhost:

Terminal window
proxy listening on 127.0.0.1:3000
vite dev server binds [::1]:3000
http://localhost:3000 the wrong app

The proxy now owns its port on both address families, and a dev restart no longer cascades every configured port in the stack up by one.

Alchemy.remote() bindings no longer go stale (#1139) — Cloudflare’s edge preview was pinning the remote-binding proxy to sessions pointing at destroyed resources, 400ing every proxied KV/R2/D1 call for up to 50 minutes.

Framework production builds run in disposable child processes with NODE_ENV=production pinned (#1139, #1178), so user config and plugins can chdir, mutate process.env, or crash without taking the engine with them.

And every local worker announces where it’s serving as it comes up:

[api-worker] ready at http://localhost:1337

State from older versions recovers automatically

Section titled “State from older versions recovers automatically”

Stacks that mixed alchemy dev and alchemy deploy on versions before beta.66 could end up with state the engine no longer knew how to handle — deploys and destroys failing forever with errors like:

BadRequest: There is a malformed parameter in the request
Cloudflare queue "jobs" already has a worker consumer for script
"worker-t2po...", but this resource is configured for "worker-qwh3..."

beta.71 recognizes these situations and repairs them on your next run: old dev-mode state is routed to the local simulator instead of the real Cloudflare API (#1130), resources deployed before beta.66 are properly replaced — not silently abandoned — when you run dev (#1110), and queue/consumer wiring left behind by old runs is reattached or cleaned up (#1133).

  • Cloudflare tooling moves into the monorepo (#1122) — the runtime and framework adapters are now @alchemy.run/cloudflare-runtime and @alchemy.run/cloudflare-frameworks, developed, tested, and released with alchemy itself. Thanks Rahul Mishra!
  • Queue consumers work on Website.Vite workers in dev (#1113) — consumer wiring survives the Vite child’s boot order, and send() to a queue with no running consumer resolves instead of hanging forever.
  • Local queue consumers honor batch settings (#1126) — batchSize/maxWaitTimeMs now map onto the local broker’s field names instead of being silently ignored.
  • Asset serving fixes (#1121) — the asset MIME table covers the full extension set (avif, webp, woff2, … previously application/octet-stream), and asset hashes include the extension so identical bodies under different extensions keep distinct content types. One-time effect: all assets re-upload on your next deploy.
  • Docker containers receive their environment values (#1118) — environment entries resolved empty inside the container, breaking e.g. PostgreSQL bootstrap.
  • InternetGateway detach rides out lingering ENIs (#1125) — Fargate and Lambda ENIs can hold public IPs for 5–20 minutes after their owner is deleted; the detach now waits them out instead of leaving an IGW+VPC residue on destroy.
  • Auto-generated names avoid reserved prefixes (#1186) — stacks named aws-* produced physical names that Resource Groups, S3 Tables, and S3 Vectors reject; colliding prefixes are now escaped, and non-colliding names are byte-for-byte unchanged.
  • Dates persist correctly in state (#1164) — a Date-typed prop used to serialize as {} in durable state stores, churning a phantom update on every subsequent plan.