Skip to content

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.

MySQLPostgres
Planetscale.MySQLDatabasePlanetscale.PostgresDatabase
Planetscale.MySQLBranchPlanetscale.PostgresBranch
Planetscale.MySQLPasswordPlanetscale.PostgresRole

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.

alchemy.run.ts
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(),
}, /* ... */);

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.

src/Db.ts
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:

  • region and arch on the database are stable — changing either replaces the cluster. clusterSize resizes in place.
  • For MySQL, swap to MySQLDatabase / MySQLBranch / MySQLPassword. Branches take one extra flag, isProduction: true | false, and the password takes role: "reader" | "writer" | "admin" | "readwriter".

The role exposes an origin shaped exactly like what Cloudflare.Hyperdrive accepts as input.

Because role.origin is an Output, the deploy graph orders itself: PostgresDatabasePostgresBranchPostgresRoleHyperdriveWorker. 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.

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.

src/Api.ts
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.)

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.

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)