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 TaggedErrorClass → TaggedError 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 unchangedif (request.url.startsWith("/auth")) return yield* auth.fetch;
// or call endpoints directly — typed errors, set-cookie preservedyield* 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.
alchemy dev fixes
Section titled “alchemy dev fixes”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:
proxy listening on 127.0.0.1:3000vite dev server binds [::1]:3000http://localhost:3000 → the wrong appThe 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:1337State 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).
Also in this release
Section titled “Also in this release”- Cloudflare tooling moves into the monorepo
(#1122) —
the runtime and framework adapters are now
@alchemy.run/cloudflare-runtimeand@alchemy.run/cloudflare-frameworks, developed, tested, and released with alchemy itself. Thanks Rahul Mishra! - Queue consumers work on
Website.Viteworkers in dev (#1113) — consumer wiring survives the Vite child’s boot order, andsend()to a queue with no running consumer resolves instead of hanging forever. - Local queue consumers honor batch settings
(#1126) —
batchSize/maxWaitTimeMsnow 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) —
environmententries 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.