alchemy@2.0.0-beta.43
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.
The headline is PlanetScale — typed MySQLDatabase and
PostgresDatabase resources, branches, credentials, and a
Drizzle integration that plugs straight into Cloudflare
Hyperdrive, the same way every other Alchemy provider plugs
into the rest of the graph.
(#113)
We’ll walk it end-to-end against Postgres. The MySQL deltas are noted along the way — the shape is one-for-one.
| MySQL | Postgres |
|---|---|
Planetscale.MySQLDatabase | Planetscale.PostgresDatabase |
Planetscale.MySQLBranch | Planetscale.PostgresBranch |
Planetscale.MySQLPassword | Planetscale.PostgresRole |
Register the provider
Section titled “Register the provider”Add Planetscale.providers() to your stack. This registers the
deploy-time policy bindings and an auth step that, on next
bun alchemy login, either reads PLANETSCALE_API_TOKEN_ID,
PLANETSCALE_API_TOKEN, and PLANETSCALE_ORGANIZATION from the
environment or stores them under
~/.alchemy/credentials/<profile>/planetscale-stored.json.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Layer from "effect/Layer";
export default Alchemy.Stack("App", { providers: Cloudflare.providers(), providers: Layer.mergeAll( Cloudflare.providers(), Planetscale.providers(), ), state: Alchemy.localState(),}, /* ... */);Cluster → branch → role
Section titled “Cluster → branch → role”The three database resources mirror PlanetScale’s own model: the cluster owns storage and region, branches are cheap forks per environment, and roles mint credentials against a branch.
import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { const database = yield* Planetscale.PostgresDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", });
const branch = yield* Planetscale.PostgresBranch("app-branch", { database, });
const role = yield* Planetscale.PostgresRole("app-role", { database, branch, inheritedRoles: ["postgres"], });
return { database, branch, role };});A few things worth knowing:
regionandarchon the database are stable — changing either replaces the cluster.clusterSizeresizes in place.- For MySQL, swap to
MySQLDatabase/MySQLBranch/MySQLPassword. Branches take one extra flag,isProduction: true | false, and the password takesrole: "reader" | "writer" | "admin" | "readwriter".
The role exposes an origin shaped exactly like what
Cloudflare.Hyperdrive accepts as input.
Snap Hyperdrive on top
Section titled “Snap Hyperdrive on top”Because role.origin is an Output, the deploy graph orders
itself: PostgresDatabase → PostgresBranch → PostgresRole →
Hyperdrive → Worker. No dependsOn, no await — the graph
follows the dataflow.
import * as Cloudflare from "alchemy/Cloudflare";
export const Hyperdrive = Effect.gen(function* () { const { role } = yield* Db; return yield* Cloudflare.Hyperdrive("app-hyperdrive", { origin: role.origin, });});For MySQL, pass password.origin; Hyperdrive sees
scheme: "mysql" and provisions a MySQL pooler.
Drizzle on Hyperdrive
Section titled “Drizzle on Hyperdrive”Cloudflare.Hyperdrive.bind(Hyperdrive) returns a binding whose
connectionString resolves at runtime to a Redacted pooled
endpoint. The new Drizzle.postgres helper takes that
connection string and gives you an Effect-native Drizzle client
— queries are yield*-able and errors land on the Effect error
channel.
import * as Cloudflare from "alchemy/Cloudflare";import * as Drizzle from "alchemy/Drizzle";import * as Effect from "effect/Effect";import { relations, Users } from "./schema.ts";import { Hyperdrive } from "./Db.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.filename }, Effect.gen(function* () { const conn = yield* Cloudflare.Hyperdrive.bind(Hyperdrive); const db = yield* Drizzle.postgres(conn.connectionString, { relations });
return { fetch: Effect.gen(function* () { const users = yield* db.select().from(Users); return yield* HttpServerResponse.json({ users }); }), }; }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)),) {}(For MySQL there’s no Drizzle.mysql helper yet — pair with
mysql2 directly inside fetch.)
Migrations as a resource
Section titled “Migrations as a resource”Drizzle.Schema wraps drizzle-kit’s programmatic API as an
Alchemy resource. On each deploy it diffs ./src/schema.ts
against the latest snapshot and, if it drifted, writes a new
migration directory. Wire its out into the branch’s
migrationsDir and the branch scans the directory and applies
new SQL files transactionally — schema → migration files →
applied migrations, in one deploy.
export const Db = Effect.gen(function* () { const schema = yield* Drizzle.Schema("app-schema", { schema: "./src/schema.ts", out: "./migrations", });
const database = yield* Planetscale.PostgresDatabase("app-db", { /* ... */ });
const branch = yield* Planetscale.PostgresBranch("app-branch", { database, migrationsDir: schema.out, }); // role / hyperdrive unchanged});Don’t forget to add Drizzle.providers() alongside
Planetscale.providers() in the stack.
.ref() — fork from a centralised staging database
Section titled “.ref() — fork from a centralised staging database”The walkthrough so far creates a fresh PostgresDatabase for
every stage. That’s right for dev_<user> and prod, but wrong
for PR previews — spinning up a new Postgres cluster per PR is
slow, expensive, and out of step with how PlanetScale itself
thinks about branching.
beta.43 ships Resource.ref() on every resource type. It reads
output attributes from another stage’s state instead of
provisioning, returns a handle of the same type, and is
indistinguishable from the owned resource to anything
downstream. Combined with a two-tier stage layout —
staging-* owns long-lived databases, pr-* owns ephemeral
compute — the fork point collapses into one ternary:
export const Db = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack;
const database = yield* Planetscale.PostgresDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", }); const database = stage.startsWith("pr-") ? yield* Planetscale.PostgresDatabase.ref("app-db", { stage: `staging-${stage}`, }) : yield* Planetscale.PostgresDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", }); // branch / role / hyperdrive stay the same});When pr-123 deploys, staging-pr-123 has already run and
owns the cluster; pr-123 references it and provisions only
its own branch, role, Hyperdrive, and Worker. Tear-down on PR
close drops the per-PR resources without touching the cluster.
Same code, three deployment topologies.
Also in this release
Section titled “Also in this release”A smaller but load-bearing change: Infrastructure Layers no
longer leak WorkerEnvironment. Every binding API method
(kv.get, db.prepare(...).first(), Hyperdrive.bind) used
to return an Effect whose R channel carried
WorkerEnvironment, which propagated up through every Layer
that wrapped it. WorkerEnvironment is now resolved once
inside each *BindingLive Layer and closed over, leaving only
Alchemy.RuntimeContext — the cloud-agnostic color for “runs
inside a deployed Function or Worker” — in the requirement
channel.
kv.get(key, "json"): Effect.Effect<Job | null, KVNamespaceError, WorkerEnvironment>kv.get(key, "json"): Effect.Effect<Job | null, KVNamespaceError, RuntimeContext>A JobService wrapping KV can now expose
Effect<Job, _, RuntimeContext> to consumers without
mentioning Cloudflare at all. See
Concepts › Layers for
the underlying model.
(#383,
#386)
Where to go next
Section titled “Where to go next”- Hyperdrive tutorial — PlanetScale Postgres and MySQL tabs alongside Neon
- Drizzle tutorial —
Drizzle.Schemaand deploy-driven migrations - Branch from a shared database —
.ref()as a guided tutorial - PlanetScale Postgres + Drizzle example
- PlanetScale MySQL + Drizzle example
- CHANGELOG · v2.0.0-beta.42 → v2.0.0-beta.43