Quickback Docs

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

quickback/quickback.config.ts
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 rolePurposeDeployed?
quickback_ownerNOLOGIN owner of the app and auth schemas and their objectsno
quickback_adminroutine migration actor — drizzle-kit and Better Auth migrationsno
quickbackCloudflare Worker runtime role, NOINHERIT NOBYPASSRLSyes, 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:

SettingValueEnds
statement_timeout30sa runaway statement
idle_in_transaction_session_timeout15san open transaction the Worker stopped talking to
transaction_timeout60sany 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:

ValueWhere it appears
SQL rolequickbackCREATE ROLE, GRANT, ALTER ROLE, every policy's TO clause
Connection usernamequickback.<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 deploy

quickback 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:

Not shared:

  • Control plane. Branch creation, canaries and the NEON_* API path are Neon-specific. quickback canary refuses a PlanetScale project even when NEON_API_KEY and NEON_PROJECT_ID are set; PlanetScale branch operations are manual today.
  • Setup output. .env.planetscale.example and docs/planetscale-postgres-setup.md replace their Neon equivalents.
  • Dependencies. No @neondatabase/serverless; the runtime is postgres (postgres.js) with drizzle-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:

FeatureStatus
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 pluginsupported — 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 / webhooksDatabaseNamerejected — 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 NOBYPASSRLS runs verbatim from the generated journal as the vendor default role. No vendor role API is needed.
  • Ownership transfer with ALTER … OWNER TO quickback_owner works, including ALTER SCHEMA public.
  • SECURITY DEFINER with a pinned search_path, GRANT … WITH SET TRUE and REVOKE CREATE ON SCHEMA public FROM PUBLIC are all accepted.
  • SET LOCAL ROLE quickback from 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, and quickback_admin holds SET on it for routine migrations. Do not work around it by granting the default role more; it is the setup actor only and carries BYPASSRLS.
  • Connection username is <role>.<branch_id>; SQL role names stay bare. Create the Hyperdrive origin credential as quickback.<branch_id>.
  • Extensions: the journal needs only plpgsql. pgcrypto, uuid-ossp, vector, pg_trgm, citext and pg_cron are 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.dev

Projects with providers.runtime.config.routes or domains get both from the configured hostnames automatically.

See also

On this page