| 1 | --- |
| 2 | title: 2.0.0-beta.43 - PlanetScale Provider |
| 3 | date: 2026-05-21T22:00:00Z |
| 4 | category: release |
| 5 | excerpt: v2.0.0-beta.43 includes a new `alchemy/Planetscale` provider, improvements to how Resources and Bindings are encapsulated in Layers, and a handful of other fixes. |
| 6 | --- |
| 7 | |
| 8 | `v2.0.0-beta.43` includes a new `alchemy/Planetscale` provider, |
| 9 | improvements to how Resources and Bindings are encapsulated in |
| 10 | Layers, and a handful of other fixes. |
| 11 | |
| 12 | The headline is **PlanetScale** — typed `MySQLDatabase` and |
| 13 | `PostgresDatabase` resources, branches, credentials, and a |
| 14 | Drizzle integration that plugs straight into Cloudflare |
| 15 | Hyperdrive, the same way every other Alchemy provider plugs |
| 16 | into the rest of the graph. |
| 17 | ([#113](https://github.com/alchemy-run/alchemy/pull/113)) |
| 18 | |
| 19 | We'll walk it end-to-end against Postgres. The MySQL deltas are |
| 20 | noted along the way — the shape is one-for-one. |
| 21 | |
| 22 | | MySQL | Postgres | |
| 23 | | --------------------------- | ------------------------------ | |
| 24 | | `Planetscale.MySQLDatabase` | `Planetscale.PostgresDatabase` | |
| 25 | | `Planetscale.MySQLBranch` | `Planetscale.PostgresBranch` | |
| 26 | | `Planetscale.MySQLPassword` | `Planetscale.PostgresRole` | |
| 27 | |
| 28 | ## Register the provider |
| 29 | |
| 30 | Add `Planetscale.providers()` to your stack. This registers the |
| 31 | deploy-time policy bindings and an auth step that, on next |
| 32 | `bun alchemy login`, either reads `PLANETSCALE_API_TOKEN_ID`, |
| 33 | `PLANETSCALE_API_TOKEN`, and `PLANETSCALE_ORGANIZATION` from the |
| 34 | environment or stores them under |
| 35 | `~/.alchemy/credentials/<profile>/planetscale-stored.json`. |
| 36 | |
| 37 | ```diff lang="typescript" |
| 38 | // alchemy.run.ts |
| 39 | import * as Alchemy from "alchemy"; |
| 40 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 41 | +import * as Planetscale from "alchemy/Planetscale"; |
| 42 | +import * as Layer from "effect/Layer"; |
| 43 | |
| 44 | export default Alchemy.Stack("App", { |
| 45 | - providers: Cloudflare.providers(), |
| 46 | + providers: Layer.mergeAll( |
| 47 | + Cloudflare.providers(), |
| 48 | + Planetscale.providers(), |
| 49 | + ), |
| 50 | state: Alchemy.localState(), |
| 51 | }, /* ... */); |
| 52 | ``` |
| 53 | |
| 54 | ## Cluster → branch → role |
| 55 | |
| 56 | The three database resources mirror PlanetScale's own model: the |
| 57 | **cluster** owns storage and region, **branches** are cheap |
| 58 | forks per environment, and **roles** mint credentials against a |
| 59 | branch. |
| 60 | |
| 61 | ```typescript |
| 62 | // src/Db.ts |
| 63 | import * as Planetscale from "alchemy/Planetscale"; |
| 64 | import * as Effect from "effect/Effect"; |
| 65 | |
| 66 | export const Db = Effect.gen(function* () { |
| 67 | const database = yield* Planetscale.PostgresDatabase("app-db", { |
| 68 | region: { slug: "us-east" }, |
| 69 | clusterSize: "PS_10", |
| 70 | }); |
| 71 | |
| 72 | const branch = yield* Planetscale.PostgresBranch("app-branch", { |
| 73 | database, |
| 74 | }); |
| 75 | |
| 76 | const role = yield* Planetscale.PostgresRole("app-role", { |
| 77 | database, |
| 78 | branch, |
| 79 | inheritedRoles: ["postgres"], |
| 80 | }); |
| 81 | |
| 82 | return { database, branch, role }; |
| 83 | }); |
| 84 | ``` |
| 85 | |
| 86 | ## Snap Hyperdrive on top |
| 87 | |
| 88 | Because `role.origin` is an `Output`, the deploy graph orders |
| 89 | itself: `PostgresDatabase` → `PostgresBranch` → `PostgresRole` → |
| 90 | `Hyperdrive` → `Worker`. No `dependsOn`, no `await` — the graph |
| 91 | follows the dataflow. |
| 92 | |
| 93 | ```typescript |
| 94 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 95 | |
| 96 | export const Hyperdrive = Effect.gen(function* () { |
| 97 | const { role } = yield* Db; |
| 98 | return yield* Cloudflare.Hyperdrive("app-hyperdrive", { |
| 99 | origin: role.origin, |
| 100 | }); |
| 101 | }); |
| 102 | ``` |
| 103 | |
| 104 | For MySQL, pass `password.origin`; Hyperdrive sees |
| 105 | `scheme: "mysql"` and provisions a MySQL pooler. |
| 106 | |
| 107 | ## Drizzle on Hyperdrive |
| 108 | |
| 109 | `Cloudflare.Hyperdrive.bind(Hyperdrive)` returns a binding whose |
| 110 | `connectionString` resolves at runtime to a `Redacted` pooled |
| 111 | endpoint. The new `Drizzle.postgres` helper takes that |
| 112 | connection string and gives you an Effect-native Drizzle client |
| 113 | — queries are `yield*`-able and errors land on the Effect error |
| 114 | channel. |
| 115 | |
| 116 | ```diff lang="typescript" |
| 117 | // src/Api.ts |
| 118 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 119 | +import * as Drizzle from "alchemy/Drizzle"; |
| 120 | import * as Effect from "effect/Effect"; |
| 121 | +import { relations, Users } from "./schema.ts"; |
| 122 | import { Hyperdrive } from "./Db.ts"; |
| 123 | |
| 124 | export default class Api extends Cloudflare.Worker<Api>()( |
| 125 | "Api", |
| 126 | { main: import.meta.filename }, |
| 127 | Effect.gen(function* () { |
| 128 | const conn = yield* Cloudflare.Hyperdrive.bind(Hyperdrive); |
| 129 | + const db = yield* Drizzle.postgres(conn.connectionString, { relations }); |
| 130 | |
| 131 | return { |
| 132 | fetch: Effect.gen(function* () { |
| 133 | + const users = yield* db.select().from(Users); |
| 134 | return yield* HttpServerResponse.json({ users }); |
| 135 | }), |
| 136 | }; |
| 137 | }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)), |
| 138 | ) {} |
| 139 | ``` |
| 140 | |
| 141 | (For MySQL there's no `Drizzle.mysql` helper yet — pair with |
| 142 | `mysql2` directly inside `fetch`.) |
| 143 | |
| 144 | ## Migrations as a resource |
| 145 | |
| 146 | `Drizzle.Schema` wraps drizzle-kit's programmatic API as an |
| 147 | Alchemy resource. On each deploy it diffs `./src/schema.ts` |
| 148 | against the latest snapshot and, if it drifted, writes a new |
| 149 | migration directory. Wire its `out` into the branch's |
| 150 | `migrationsDir` and the branch scans the directory and applies |
| 151 | new SQL files transactionally — schema → migration files → |
| 152 | applied migrations, in one deploy. |
| 153 | |
| 154 | ```diff lang="typescript" |
| 155 | export const Db = Effect.gen(function* () { |
| 156 | + const schema = yield* Drizzle.Schema("app-schema", { |
| 157 | + schema: "./src/schema.ts", |
| 158 | + out: "./migrations", |
| 159 | + }); |
| 160 | |
| 161 | const database = yield* Planetscale.PostgresDatabase("app-db", { /* ... */ }); |
| 162 | |
| 163 | const branch = yield* Planetscale.PostgresBranch("app-branch", { |
| 164 | database, |
| 165 | + migrationsDir: schema.out, |
| 166 | }); |
| 167 | // role / hyperdrive unchanged |
| 168 | }); |
| 169 | ``` |
| 170 | |
| 171 | Don't forget to add `Drizzle.providers()` alongside |
| 172 | `Planetscale.providers()` in the stack. |
| 173 | |
| 174 | ## `.ref()` — fork from a centralised staging database |
| 175 | |
| 176 | The walkthrough so far creates a fresh `PostgresDatabase` for |
| 177 | every stage. That's right for `dev_<user>` and `prod`, but wrong |
| 178 | for PR previews — spinning up a new Postgres cluster per PR is |
| 179 | slow, expensive, and out of step with how PlanetScale itself |
| 180 | thinks about branching. |
| 181 | |
| 182 | beta.43 ships `Resource.ref()` on every resource type. It reads |
| 183 | output attributes from another stage's state instead of |
| 184 | provisioning, returns a handle of the same type, and is |
| 185 | indistinguishable from the owned resource to anything |
| 186 | downstream. Combined with a two-tier stage layout — |
| 187 | `staging-*` owns long-lived databases, `pr-*` owns ephemeral |
| 188 | compute — the fork point collapses into one ternary: |
| 189 | |
| 190 | ```diff lang="typescript" |
| 191 | export const Db = Effect.gen(function* () { |
| 192 | + const { stage } = yield* Alchemy.Stack; |
| 193 | |
| 194 | - const database = yield* Planetscale.PostgresDatabase("app-db", { |
| 195 | - region: { slug: "us-east" }, |
| 196 | - clusterSize: "PS_10", |
| 197 | - }); |
| 198 | + const database = stage.startsWith("pr-") |
| 199 | + ? yield* Planetscale.PostgresDatabase.ref("app-db", { |
| 200 | + stage: `staging-${stage}`, |
| 201 | + }) |
| 202 | + : yield* Planetscale.PostgresDatabase("app-db", { |
| 203 | + region: { slug: "us-east" }, |
| 204 | + clusterSize: "PS_10", |
| 205 | + }); |
| 206 | // branch / role / hyperdrive stay the same |
| 207 | }); |
| 208 | ``` |
| 209 | |
| 210 | When `pr-123` deploys, `staging-pr-123` has already run and |
| 211 | owns the cluster; `pr-123` references it and provisions only |
| 212 | its own branch, role, Hyperdrive, and Worker. Tear-down on PR |
| 213 | close drops the per-PR resources without touching the cluster. |
| 214 | Same code, three deployment topologies. |
| 215 | |
| 216 | ## Also in this release |
| 217 | |
| 218 | A smaller but load-bearing change: **Infrastructure Layers no |
| 219 | longer leak `WorkerEnvironment`**. Every binding API method |
| 220 | (`kv.get`, `db.prepare(...).first()`, `Hyperdrive.bind`) used |
| 221 | to return an `Effect` whose `R` channel carried |
| 222 | `WorkerEnvironment`, which propagated up through every Layer |
| 223 | that wrapped it. `WorkerEnvironment` is now resolved once |
| 224 | inside each `*BindingLive` Layer and closed over, leaving only |
| 225 | `Alchemy.RuntimeContext` — the cloud-agnostic color for "runs |
| 226 | inside a deployed Function or Worker" — in the requirement |
| 227 | channel. |
| 228 | |
| 229 | ```diff lang="typescript" |
| 230 | -kv.get(key, "json"): Effect.Effect<Job | null, NamespaceError, WorkerEnvironment> |
| 231 | +kv.get(key, "json"): Effect.Effect<Job | null, NamespaceError, RuntimeContext> |
| 232 | ``` |
| 233 | |
| 234 | A `JobService` wrapping KV can now expose |
| 235 | `Effect<Job, _, RuntimeContext>` to consumers without |
| 236 | mentioning Cloudflare at all. See |
| 237 | [Concepts › Layers](/infrastructure-as-effects/layers) for |
| 238 | the underlying model. |
| 239 | ([#383](https://github.com/alchemy-run/alchemy/pull/383), |
| 240 | [#386](https://github.com/alchemy-run/alchemy/pull/386)) |
| 241 | |
| 242 | ## Where to go next |
| 243 | |
| 244 | - [Hyperdrive tutorial](/cloudflare/data/hyperdrive) — PlanetScale Postgres and MySQL tabs alongside Neon |
| 245 | - [Drizzle tutorial](/cloudflare/data/drizzle) — `Drizzle.Schema` and deploy-driven migrations |
| 246 | - [Branch from a shared database](/cloudflare/data/branch-from-shared-database) — `.ref()` as a guided tutorial |
| 247 | - [PlanetScale Postgres + Drizzle example](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-planetscale-postgres-drizzle) |
| 248 | - [PlanetScale MySQL + Drizzle example](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-planetscale-mysql-drizzle) |
| 249 | - [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md) · [v2.0.0-beta.42 → v2.0.0-beta.43](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.42...v2.0.0-beta.43) |