PlanetScale Postgres
Use PlanetScale Postgres with Quickback through Cloudflare Hyperdrive — roles, connection usernames, the cache-disabled binding, and named environments.
PlanetScale Postgres is the same Postgres target as Neon, on a different host. The compiler emits one Postgres implementation: the same schema, the same Better Auth wiring, the same RLS backstop, and the same migrations. For identical definitions the generated schema and security migrations are byte-identical between the two providers.
Proven live end to end (2026-09-07). The direct-connection contract, the
Hyperdrive transport, and a generated project's Worker were each run against a
real PlanetScale Postgres 18 branch. Against a real PlanetScale Postgres 18 branch, the
generated journal applied unchanged: role bootstrap with CREATE ROLE
verbatim, ownership transfer to quickback_owner, the RLS backstop through
the emitted client as the restricted role, interactive transaction commit and
rollback, incremental migration as quickback_admin, and a migration ledger
the runtime cannot read. The recruitment example, compiled with this provider,
applied its four-file upgrade journal, then served sign-up, sessions, organization
activation, tenant CRUD, a named view and an action through a cache-disabled
Hyperdrive config connected as quickback.<branch_id>, with every security
layer answering correctly. Not yet exercised there: outbound webhook delivery,
realtime traffic and the automated acceptance manifest. Sanitized record in the
repository's plans/postgres-acceptance-results.md.
Configuration
import { defineConfig, defineRuntime, defineDatabase, defineAuth } from "@quickback/compiler";
export default defineConfig({
name: "my-app",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("planetscale-postgres", {
connectionMode: "hyperdrive",
hyperdrive: { id: "ffffffffffffffffffffffffffffffff" },
}),
auth: defineAuth("better-auth"),
},
});Hyperdrive is the only transport. Omitting connectionMode resolves to
hyperdrive; http and websocket are Neon driver modes and are rejected at
compile time, as is any runtime other than cloudflare.
planetscale — without the suffix — remains the MySQL provider name and is not
registered. Only planetscale-postgres selects this provider.
Where the tables go
One PlanetScale branch hosts one app, and that app uses the branch's own
database. Quickback's migrations create schemas inside it — app for feature
tables, auth for Better Auth, plus webhooks, audit and drizzle when the
project uses them.
Do not CREATE DATABASE per app inside a branch. Roles are cluster-wide but
grants, ownership and the migration ledger are per-database, so an extra
database silently splits the bootstrap. Point the Hyperdrive config's
<database> at the branch's own database and let the schemas do the
separating. The same rule holds on Neon.
Roles: three SQL roles, one deployed
The first generated migration creates the same three roles it creates on Neon:
| SQL role | Purpose | Deployed? |
|---|---|---|
quickback_owner | NOLOGIN owner of the app and auth schemas and their objects | no |
quickback_admin | routine migration actor — drizzle-kit and Better Auth migrations | no |
quickback | Cloudflare Worker runtime role, NOINHERIT NOBYPASSRLS | yes, only this one |
They are created NOLOGIN because generated SQL cannot choose your passwords.
Enable login afterwards, and change nothing else:
ALTER ROLE quickback_admin LOGIN PASSWORD '<strong-admin-password>';
ALTER ROLE quickback LOGIN PASSWORD '<strong-password>';Worker-role timeouts
The role bootstrap sets three timeouts on quickback, so a Worker invocation
that dies mid-transaction cannot hold a connection (and its locks) open:
| Setting | Value | Ends |
|---|---|---|
statement_timeout | 30s | a runaway statement |
idle_in_transaction_session_timeout | 15s | an open transaction the Worker stopped talking to |
transaction_timeout | 60s | any transaction older than that, including one stuck mid-statement waiting on the client — PostgreSQL 17+ only |
Each is set only when the role does not already carry it, so a value you set
with ALTER ROLE quickback SET … survives every later migration. Existing
databases get them from the next appended quickback_rls migration, applied
with the setup-owner credential. If the applying user cannot alter the role,
the migration skips them with a NOTICE; set them as the owner instead.
SQL role vs connection username
PlanetScale routes a connection to a branch by a suffix on the username. The two names are not interchangeable:
| Value | Where it appears | |
|---|---|---|
| SQL role | quickback | CREATE ROLE, GRANT, ALTER ROLE, every policy's TO clause |
| Connection username | quickback.<branch_id> | the connection string, and nowhere else |
Generated SQL never contains a branch id. If a policy or grant is written
against quickback.<branch_id>, it is wrong.
Setup owner vs migration actor
The branch's default postgres role is the setup owner. It has
BYPASSRLS, so it is exactly the role that must never run application traffic:
- Setup owner (
postgres) — runs the role-bootstrap migration once, and any later migration that changes ownership or role membership. - Migration actor (
quickback_admin) — every routine schema and security migration afterwards. - Runtime (
quickback) — the Hyperdrive connection string. Nothing else.
Cloudflare's generic PlanetScale integration guide suggests granting a driver
role pg_read_all_data and pg_write_all_data. Do not grant those to
quickback: they hand the Worker blanket table access in place of the
generated grants. (Neither bypasses RLS, so this is a least-privilege
regression rather than an isolation hole — but the generated grants are the
contract this provider is tested against.)
The Hyperdrive binding
Point Hyperdrive at the branch's direct host over TLS. Hyperdrive is the connection pool; stacking a second vendor pooler in front of it adds a hop and another place for session state to be reused.
npx wrangler hyperdrive create my-app-db \
--caching-disabled \
--origin-connection-limit 60 \
--connection-string="postgres://quickback.<branch_id>:<password>@<host>:5432/<database>?sslmode=verify-full"--origin-connection-limit is the soft cap on connections Hyperdrive opens to
the branch (minimum 5; about 100 on a paid Workers plan). Each in-flight
request holds one origin connection per leased client until its handler
returns, so size it to your concurrency and raise it later with
npx wrangler hyperdrive update <id> --origin-connection-limit <n>.
--caching-disabled is mandatory. Quickback scopes RLS per request by writing
the caller's claims into transaction-local settings; a cached result set would
be replayed to a different caller, across tenants. Caching happens at the app
layer instead.
The compiler emits the binding into wrangler.toml:
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "ffffffffffffffffffffffffffffffff"Each Worker invocation shares one lease pool across every database handle it
opens, keeps one read-only claims transaction open per leased connection for
its reads, and closes everything when the invocation ends — see
Neon → Interactive transactions for the details.
Every response carries a Server-Timing header with the invocation's statement
count, claims transactions, cold connections and summed database time, and the
Worker logs one request.timing line per request — see
Measuring database cost.
No DATABASE_URL Worker secret
Feature queries and Better Auth both read env.HYPERDRIVE.connectionString.
The Worker holds no database password.
Migration credentials are compiler/CI inputs and never Worker secrets:
drizzle-kit reads DATABASE_MIGRATION_URL (falling back to DATABASE_URL)
from .env. Copy the generated .env.planetscale.example to .env and fill
in the quickback_admin connection string.
npm run db:migrate # schema + security migrations, as quickback_admin
npx wrangler deployquickback deploy applies D1 migrations only — on any Postgres provider it
prints a warning and leaves migrations to npm run db:migrate.
Named environments
Each target owns its own branch, Hyperdrive config and Worker script, exactly
as on Neon:
one environments.<name> entry per target, each with its own
worker.bindings.hyperdrive.id, and the local dev target additionally
carrying localConnectionString. Since every environment runs the same
bundle, a target that shares another target's Hyperdrive id shares its
database — the compiler requires each one to declare its own.
Set providers.runtime.config.placement: { region: "aws:us-east-2" } (your
branch's region) so the Worker runs next to the database — every statement is
several round-trips, so Worker-to-database distance dominates latency. A target
whose branch lives elsewhere overrides it with
environments.<name>.worker.placement. The compiler warns when a Hyperdrive
project sets neither. The rest of the tuning notes — the session cookie cache
and connection limits — are on
Neon → Tuning the Worker for Hyperdrive.
Public, tenant-free resources can opt into Hyperdrive's query cache with
read: { cache: "hyperdrive" } through a second binding and the read-only
quickback_reader role — see
Cached public reads.
On PlanetScale the reader's connection username is the role name plus the
branch id, quickback_reader.<branch_id>, like the other roles.
What is shared with Neon, and what is not
Shared — read the Neon page for the detail, it is the same generated output:
- Worker-scoped RLS and the claim model
- Row Level Security as a backstop
- Better Auth schema generation
- Migrations and the journaled security migration
- Interactive transactions, webhooks, encryption and background jobs — all available, as on Neon Hyperdrive
Not shared:
- Control plane. Branch creation, canaries and the
NEON_*API path are Neon-specific.quickback canaryrefuses a PlanetScale project even whenNEON_API_KEYandNEON_PROJECT_IDare set; PlanetScale branch operations are manual today. - Setup output.
.env.planetscale.exampleanddocs/planetscale-postgres-setup.mdreplace their Neon equivalents. - Dependencies. No
@neondatabase/serverless; the runtime ispostgres(postgres.js) withdrizzle-orm/postgres-js.
Capability limits (fail closed)
The same gates as Neon, and for the same reason — each is a compile-time error, never silently-broken output:
| Feature | Status |
|---|---|
Managed file storage (managed: true) | supported — metadata lives in a files schema of the same database; the file-serving Worker binds the same Hyperdrive config |
Better Auth subscriptions plugin | supported — auth.subscriptions in the same journal, service-role only (Subscriptions) |
Generated SQL triggers (sql:) | supported — PL/pgSQL bodies, SECURITY INVOKER (see Triggers) |
Delegated principals (auth.principals, resource.databaseAccess) | supported — the same lookup policy, <table>_principal_scope / <table>_principal_fence lanes and middleware as Neon Hyperdrive (Delegated principal RLS) |
webhooksDatabaseId / webhooksDatabaseName | rejected — D1-only keys; webhook tables live in the webhooks schema of the same database |
connectionMode of 'http' or 'websocket' | rejected — no such driver for this provider |
Live acceptance findings
Answered by the direct-connection spike on 2026-09-07 (PostgreSQL 18.6,
postgres.js 3.4.9, drizzle-orm 0.45.2; sanitized record in the repository's
plans/postgres-acceptance-results.md):
CREATE ROLE … NOLOGIN NOBYPASSRLSruns verbatim from the generated journal as the vendor default role. No vendor role API is needed.- Ownership transfer with
ALTER … OWNER TO quickback_ownerworks, includingALTER SCHEMA public. SECURITY DEFINERwith a pinnedsearch_path,GRANT … WITH SET TRUEandREVOKE CREATE ON SCHEMA public FROM PUBLICare all accepted.SET LOCAL ROLE quickbackfrom the vendor default role is refused (SQLSTATE 42501). PostgreSQL 16+ gives a role's creator ADMIN OPTION but not SET. This does not affect the shipped design: the Worker connects directly as the restricted role through Hyperdrive, andquickback_adminholds SET on it for routine migrations. Do not work around it by granting the default role more; it is the setup actor only and carriesBYPASSRLS.- Connection username is
<role>.<branch_id>; SQL role names stay bare. Create the Hyperdrive origin credential asquickback.<branch_id>. - Extensions: the journal needs only
plpgsql.pgcrypto,uuid-ossp,vector,pg_trgm,citextandpg_cronare available if a project declares them.
Still to prove, and the reason this page keeps a qualifier: the deployed Hyperdrive run.
Objects a routine db:migrate creates as quickback_admin (trigger functions,
later tables) are re-owned to quickback_owner by the next security migration.
That works when the setup owner applies it: the bootstrap grants the setup owner
quickback_admin WITH INHERIT TRUE (PostgreSQL 16+ gives a role's creator
membership without INHERIT, which used to fail the re-own loop with
must be owner of function qb_trgfn_…). A changed security migration still
needs the setup-owner credential because of the role timeouts it sets (see
Neon).
Deploying to workers.dev only
A project with no custom domain must tell the generated auth layer its own host, or the JWT issuer defaults to the local dev URL and browser mutations fail the origin check:
npx wrangler deploy \
--var EXTRA_APP_HOSTS:<name>.<account>.workers.dev \
--var BETTER_AUTH_URL:https://<name>.<account>.workers.devProjects with providers.runtime.config.routes or domains get both from
the configured hostnames automatically.
See also
- Neon — the same Postgres implementation, in full detail
- Cloudflare D1 — the SQLite alternative
- Database providers — configuring
providers.database