| 1 | --- |
| 2 | title: 2.0.0-beta.71 - Effect beta.105 & Better Auth |
| 3 | date: 2026-08-12T09:00:00Z |
| 4 | category: release |
| 5 | excerpt: v2.0.0-beta.71 moves to effect 4.0.0-beta.105 and drizzle 1.0.0-rc.5-ab785fc in lockstep (effect's TaggedErrorClass → TaggedError rename broke drizzle rc.4), gives @alchemy.run/better-auth an effectful API and a database Layer for every platform, lets Worker props be an Effect, and fixes the ways alchemy dev could serve the wrong app on a configured port. |
| 6 | --- |
| 7 | |
| 8 | Alchemy now runs on **effect 4.0.0-beta.105**, with |
| 9 | **drizzle 1.0.0-rc.5-ab785fc** bumped in lockstep to survive |
| 10 | effect's `TaggedErrorClass` → `TaggedError` rename. On top of that, `@alchemy.run/better-auth` gains an |
| 11 | effectful API and a database Layer for every platform, Worker |
| 12 | props can now be an Effect, and a batch of `alchemy dev` fixes |
| 13 | makes configured ports, queue consumers, and pre-beta.66 state |
| 14 | behave. |
| 15 | |
| 16 | :::caution |
| 17 | **Breaking changes** |
| 18 | |
| 19 | - **effect `>=4.0.0-beta.105` is the new minimum peer** |
| 20 | ([#1132](https://github.com/alchemy-run/alchemy/pull/1132)). |
| 21 | effect renamed `Schema.TaggedErrorClass` to |
| 22 | `Schema.TaggedError`, so earlier betas can no longer resolve. |
| 23 | If you use Drizzle, upgrade `drizzle-orm`/`drizzle-kit` to |
| 24 | `1.0.0-rc.5-ab785fc` at the same time — see |
| 25 | [below](#effect-beta105-and-the-drizzle-rc5-lockstep). |
| 26 | - **Drizzle backends move out of the `alchemy/Drizzle` barrel** |
| 27 | ([#1119](https://github.com/alchemy-run/alchemy/pull/1119)). |
| 28 | Import `alchemy/Drizzle/Postgres`, `alchemy/Drizzle/MySQL`, and |
| 29 | `alchemy/Drizzle/Cloudflare` (Durable Object) directly; the |
| 30 | barrel keeps `Schema`, `providers()`, `D1`, and the errors. |
| 31 | - **`alchemy dev` now serves assets first** when |
| 32 | `assets.runWorkerFirst` is omitted |
| 33 | ([#1121](https://github.com/alchemy-run/alchemy/pull/1121)), |
| 34 | matching the deployed default. A worker that intercepts |
| 35 | asset-path requests in dev must opt in explicitly with |
| 36 | `assets: { runWorkerFirst: true }` — such apps already behaved |
| 37 | assets-first in production, so the old dev default was masking |
| 38 | a dev/prod divergence. |
| 39 | ::: |
| 40 | |
| 41 | ## effect beta.105 and the drizzle rc.5 lockstep |
| 42 | |
| 43 | effect `4.0.0-beta.105` renamed `Schema.TaggedErrorClass` to |
| 44 | `Schema.TaggedError`: |
| 45 | |
| 46 | ```diff lang="typescript" |
| 47 | -export class AuthError extends Schema.TaggedErrorClass<AuthError>()( |
| 48 | +export class AuthError extends Schema.TaggedError<AuthError>()( |
| 49 | "AuthError", |
| 50 | { message: Schema.String }, |
| 51 | ) {} |
| 52 | ``` |
| 53 | |
| 54 | drizzle-orm `1.0.0-rc.4` — which ships effect-native clients built |
| 55 | on that API — broke against it, which pinned the entire dependency |
| 56 | tree: you couldn't take the effect upgrade without breaking |
| 57 | drizzle, and vice versa. Drizzle's `1.0.0-rc.5-ab785fc` release |
| 58 | ships `TaggedError` natively, so beta.71 upgrades both in lockstep |
| 59 | ([#1132](https://github.com/alchemy-run/alchemy/pull/1132), |
| 60 | [#1184](https://github.com/alchemy-run/alchemy/pull/1184)): |
| 61 | |
| 62 | ```diff lang="json" |
| 63 | -"effect": ">=4.0.0-beta.102", |
| 64 | -"drizzle-orm": "1.0.0-rc.4", |
| 65 | -"drizzle-kit": "1.0.0-rc.4", |
| 66 | +"effect": ">=4.0.0-beta.105", |
| 67 | +"drizzle-orm": "1.0.0-rc.5-ab785fc", |
| 68 | +"drizzle-kit": "1.0.0-rc.5-ab785fc", |
| 69 | ``` |
| 70 | |
| 71 | When you upgrade, pin drizzle exactly — a `^1.0.0-rc.4` range can |
| 72 | still hand you the rc.4 that breaks against effect beta.105. |
| 73 | |
| 74 | Alongside the version bump, the optional Drizzle drivers now load |
| 75 | lazily ([#1119](https://github.com/alchemy-run/alchemy/pull/1119)) |
| 76 | — importing the Postgres or MySQL client no longer requires |
| 77 | `@effect/sql-mysql2`/`@effect/sql-pg` to be installed unless you |
| 78 | actually connect: |
| 79 | |
| 80 | ```diff lang="typescript" |
| 81 | -import * as Drizzle from "alchemy/Drizzle"; |
| 82 | +import * as Drizzle from "alchemy/Drizzle/Postgres"; |
| 83 | const db = yield* Drizzle.Postgres(conn.connectionString, { relations }); |
| 84 | ``` |
| 85 | |
| 86 | Docs: [SQL & Drizzle](/sql). |
| 87 | |
| 88 | ## Better Auth: every database, effectful API |
| 89 | |
| 90 | `@alchemy.run/better-auth` now supports every storage backend and |
| 91 | wraps Better Auth's async API in an effectful interface |
| 92 | ([#1157](https://github.com/alchemy-run/alchemy/pull/1157)): |
| 93 | `yield* BetterAuth(options)` gives you a fully-typed auth |
| 94 | instance, and the database is a Layer you pick per platform. |
| 95 | Plugin typing flows end-to-end — every `auth.api.*` endpoint is an |
| 96 | Effect with a tagged `BetterAuthApiError` — and schema migrations |
| 97 | run automatically at deploy as an alchemy Action, input-hash |
| 98 | diffed and tree-shaken out of runtime bundles. |
| 99 | |
| 100 | ```typescript |
| 101 | const auth = yield* BetterAuth({ |
| 102 | basePath: "/auth", |
| 103 | emailAndPassword: { enabled: true }, |
| 104 | plugins: [anonymous()], // auth.api.signInAnonymous is now typed |
| 105 | }); |
| 106 | |
| 107 | // serve the Better Auth routes on Workers and Lambda unchanged |
| 108 | if (request.url.startsWith("/auth")) return yield* auth.fetch; |
| 109 | |
| 110 | // or call endpoints directly — typed errors, set-cookie preserved |
| 111 | yield* auth.api.signInEmail({ body: { email, password } }).pipe( |
| 112 | Effect.catchTag("BetterAuthApiError", (e) => ...), // e.statusCode, e.body.code |
| 113 | ); |
| 114 | ``` |
| 115 | |
| 116 | The database Layers cover every environment → target pair: |
| 117 | `CloudflareD1` (native binding + migrations over the D1 HTTP API, |
| 118 | local simulator included), `Neon` (serverless driver over |
| 119 | WebSocket — no Hyperdrive, no `pg`), `AuroraDataApi` (SQL over |
| 120 | HTTPS with IAM — no VPC attachment), `CloudflareHyperdrive`, |
| 121 | generic `Postgres` and `MySQL` TCP fallbacks (PlanetScale, RDS, |
| 122 | any connection string), `SQLite` for local dev, `Memory` for |
| 123 | tests, and `Drizzle` to bring your own db via better-auth's |
| 124 | official adapter. |
| 125 | |
| 126 | Because the database is a Layer, your auth code never changes — |
| 127 | you swap the provided Layer. On D1: |
| 128 | |
| 129 | ```typescript |
| 130 | import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 131 | |
| 132 | export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 133 | |
| 134 | export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()( |
| 135 | "AuthWorker", |
| 136 | { main: import.meta.url }, |
| 137 | Effect.gen(function* () { |
| 138 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 139 | return { |
| 140 | fetch: Effect.gen(function* () { |
| 141 | const request = yield* HttpServerRequest; |
| 142 | if (request.url.startsWith("/auth")) return yield* auth.fetch; |
| 143 | return HttpServerResponse.text("ok"); |
| 144 | }), |
| 145 | }; |
| 146 | }).pipe(Effect.provide(CloudflareD1(AuthDb))), |
| 147 | ) {} |
| 148 | ``` |
| 149 | |
| 150 | Moving to PlanetScale Postgres is only a Layer swap — the |
| 151 | `BetterAuth` program is untouched: |
| 152 | |
| 153 | ```diff lang="typescript" |
| 154 | -import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; |
| 155 | +import { Postgres } from "@alchemy.run/better-auth/Postgres"; |
| 156 | |
| 157 | -export const AuthDb = Cloudflare.D1.Database("AuthDb"); |
| 158 | +export const AuthDb = Planetscale.PostgresDatabase("AuthDb"); |
| 159 | +export const AuthRole = Planetscale.PostgresRole("AuthRole", { database: AuthDb }); |
| 160 | + |
| 161 | +const AuthDatabase = Layer.unwrap( |
| 162 | + Effect.map(AuthRole, (role) => Postgres(role.connectionUrl)), |
| 163 | +); |
| 164 | |
| 165 | export default class AuthWorker extends Cloudflare.Worker<AuthWorker>()( |
| 166 | "AuthWorker", |
| 167 | { main: import.meta.url }, |
| 168 | Effect.gen(function* () { |
| 169 | const auth = yield* BetterAuth({ emailAndPassword: { enabled: true } }); |
| 170 | return { |
| 171 | fetch: ..., |
| 172 | }; |
| 173 | - }).pipe(Effect.provide(CloudflareD1(AuthDb))), |
| 174 | + }).pipe(Effect.provide(AuthDatabase)), |
| 175 | ) {} |
| 176 | ``` |
| 177 | |
| 178 | The API reference also learned to document Layers as first-class |
| 179 | pages ([#1159](https://github.com/alchemy-run/alchemy/pull/1159)) |
| 180 | — each database Layer gets its own generated page declaring what |
| 181 | it provides and its peer dependencies. |
| 182 | |
| 183 | Docs: [Better Auth](/better-auth). |
| 184 | |
| 185 | ## `alchemy dev` fixes |
| 186 | |
| 187 | A configured dev port could serve the wrong app |
| 188 | ([#1129](https://github.com/alchemy-run/alchemy/pull/1129)): the |
| 189 | proxy bound only `127.0.0.1`, browsers prefer `::1`, and any |
| 190 | framework dev server hunting from its default port could squat |
| 191 | the IPv6 half of `localhost`: |
| 192 | |
| 193 | ```sh |
| 194 | proxy listening on 127.0.0.1:3000 |
| 195 | vite dev server binds [::1]:3000 |
| 196 | http://localhost:3000 → the wrong app |
| 197 | ``` |
| 198 | |
| 199 | The proxy now owns its port on both address families, and a dev |
| 200 | restart no longer cascades every configured port in the stack up |
| 201 | by one. |
| 202 | |
| 203 | `Alchemy.remote()` bindings no longer go stale |
| 204 | ([#1139](https://github.com/alchemy-run/alchemy/pull/1139)) — |
| 205 | Cloudflare's edge preview was pinning the remote-binding proxy to |
| 206 | sessions pointing at destroyed resources, 400ing every proxied |
| 207 | KV/R2/D1 call for up to 50 minutes. |
| 208 | |
| 209 | Framework production builds run in disposable child processes |
| 210 | with `NODE_ENV=production` pinned |
| 211 | ([#1139](https://github.com/alchemy-run/alchemy/pull/1139), |
| 212 | [#1178](https://github.com/alchemy-run/alchemy/pull/1178)), so |
| 213 | user config and plugins can `chdir`, mutate `process.env`, or |
| 214 | crash without taking the engine with them. |
| 215 | |
| 216 | And every local worker announces where it's serving as it comes |
| 217 | up: |
| 218 | |
| 219 | ```text |
| 220 | [api-worker] ready at http://localhost:1337 |
| 221 | ``` |
| 222 | |
| 223 | ## State from older versions recovers automatically |
| 224 | |
| 225 | Stacks that mixed `alchemy dev` and `alchemy deploy` on versions |
| 226 | before beta.66 could end up with state the engine no longer knew |
| 227 | how to handle — deploys and destroys failing forever with errors |
| 228 | like: |
| 229 | |
| 230 | ```text |
| 231 | BadRequest: There is a malformed parameter in the request |
| 232 | |
| 233 | Cloudflare queue "jobs" already has a worker consumer for script |
| 234 | "worker-t2po...", but this resource is configured for "worker-qwh3..." |
| 235 | ``` |
| 236 | |
| 237 | beta.71 recognizes these situations and repairs them on your next |
| 238 | run: old dev-mode state is routed to the local simulator instead |
| 239 | of the real Cloudflare API |
| 240 | ([#1130](https://github.com/alchemy-run/alchemy/pull/1130)), |
| 241 | resources deployed before beta.66 are properly replaced — not |
| 242 | silently abandoned — when you run dev |
| 243 | ([#1110](https://github.com/alchemy-run/alchemy/pull/1110)), and |
| 244 | queue/consumer wiring left behind by old runs is reattached or |
| 245 | cleaned up |
| 246 | ([#1133](https://github.com/alchemy-run/alchemy/pull/1133)). |
| 247 | |
| 248 | ## Also in this release |
| 249 | |
| 250 | - **Cloudflare tooling moves into the monorepo** |
| 251 | ([#1122](https://github.com/alchemy-run/alchemy/pull/1122)) — |
| 252 | the runtime and framework adapters are now |
| 253 | `@alchemy.run/cloudflare-runtime` and |
| 254 | `@alchemy.run/cloudflare-frameworks`, developed, tested, and |
| 255 | released with alchemy itself. Thanks |
| 256 | [Rahul Mishra](https://github.com/BlankParticle)! |
| 257 | - **Queue consumers work on `Website.Vite` workers in dev** |
| 258 | ([#1113](https://github.com/alchemy-run/alchemy/pull/1113)) — |
| 259 | consumer wiring survives the Vite child's boot order, and |
| 260 | `send()` to a queue with no running consumer resolves instead |
| 261 | of hanging forever. |
| 262 | - **Local queue consumers honor batch settings** |
| 263 | ([#1126](https://github.com/alchemy-run/alchemy/pull/1126)) — |
| 264 | `batchSize`/`maxWaitTimeMs` now map onto the local broker's |
| 265 | field names instead of being silently ignored. |
| 266 | - **Asset serving fixes** |
| 267 | ([#1121](https://github.com/alchemy-run/alchemy/pull/1121)) — |
| 268 | the asset MIME table covers the full extension set (avif, webp, |
| 269 | woff2, … previously `application/octet-stream`), and asset |
| 270 | hashes include the extension so identical bodies under |
| 271 | different extensions keep distinct content types. One-time |
| 272 | effect: all assets re-upload on your next deploy. |
| 273 | - **Docker containers receive their environment values** |
| 274 | ([#1118](https://github.com/alchemy-run/alchemy/pull/1118)) — |
| 275 | `environment` entries resolved empty inside the container, |
| 276 | breaking e.g. PostgreSQL bootstrap. |
| 277 | - **InternetGateway detach rides out lingering ENIs** |
| 278 | ([#1125](https://github.com/alchemy-run/alchemy/pull/1125)) — |
| 279 | Fargate and Lambda ENIs can hold public IPs for 5–20 minutes |
| 280 | after their owner is deleted; the detach now waits them out |
| 281 | instead of leaving an IGW+VPC residue on destroy. |
| 282 | - **Auto-generated names avoid reserved prefixes** |
| 283 | ([#1186](https://github.com/alchemy-run/alchemy/pull/1186)) — |
| 284 | stacks named `aws-*` produced physical names that Resource |
| 285 | Groups, S3 Tables, and S3 Vectors reject; colliding prefixes |
| 286 | are now escaped, and non-colliding names are byte-for-byte |
| 287 | unchanged. |
| 288 | - **Dates persist correctly in state** |
| 289 | ([#1164](https://github.com/alchemy-run/alchemy/pull/1164)) — |
| 290 | a `Date`-typed prop used to serialize as `{}` in durable state |
| 291 | stores, churning a phantom update on every subsequent plan. |
| 292 | |
| 293 | ## Where to go next |
| 294 | |
| 295 | - [Better Auth](/better-auth) |
| 296 | - [SQL & Drizzle](/sql) |
| 297 | - [Local development](/environments/local-development) |
| 298 | - [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta71) |
| 299 | - [Compare v2.0.0-beta.70 → v2.0.0-beta.71](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.70...v2.0.0-beta.71) |