PlanetScale Postgres adapter
PlanetScale Postgres over the standard pg wire protocol — mandatory verified TLS, direct and PgBouncer pooled modes with explicit guards, plus the service-token management adapter.
PlanetScale offers PostgreSQL databases (alongside
its Vitess/MySQL product). PlanetScale Postgres speaks the standard
PostgreSQL wire protocol, so this adapter reuses the same engine,
parameterized SQL, transaction, batch, and error semantics as the
postgres adapter — via the official pg driver,
which is an optional peer dependency you install as usual.
import { createDatabase, sql } from "dbsdk";
import { planetscale } from "dbsdk/planetscale";
const db = createDatabase({
adapter: planetscale({
connectionString: process.env.DATABASE_URL!, // from a role's connection info
connectionMode: "direct", // required: "direct" | "pooled"
}),
});
const userId = 1; // the value you're looking up
const { rows } = await db.query(sql`select id from users where id = ${userId}`);
await db.close();
Only PlanetScale Postgres is supported. Vitess/MySQL (different
placeholders, backtick identifiers, no RETURNING) and the Neki preview are
separate systems and are refused, not approximated — see the
management section for how the engine gate enforces this.
Two connection modes
PlanetScale Postgres exposes two ports with different session semantics, and the adapter makes the mode an explicit, validated choice:
| Mode | Port | Session semantics |
|---|---|---|
connectionMode: "direct" |
5432 | Full sessions: DDL, session variables, temp tables, long transactions |
connectionMode: "pooled" |
6432 | PgBouncer transaction pooling: guarded before dispatch |
The adapter does not rewrite endpoints: a direct connection string that
targets the pooler port (or vice versa) is refused before any connection is
made. If you intentionally point at a nonstandard endpoint, pass
allowModeMismatch: true.
Pooled mode guards
On pooled, session-level state does not survive between transactions, so the
same guards the Supabase transaction pooler uses apply before dispatch
(CAPABILITY, nothing is sent):
- session-state statements —
SET,RESET,LISTEN/NOTIFY,PREPARE/DEALLOCATE,CREATE TEMP,DECLARE ... WITH HOLD— including attempts hidden behind leading comments or inside multi-statement strings; - multi-statement query strings (a conservative refusal: one statement per call on the pooled path).
Plain queries, SET LOCAL inside a transaction, dollar-quoted bodies with
semicolons, and string literals containing “set” all pass. raw.pool remains
the native escape hatch — it bypasses the guard by design and is not a SQL
sandbox. On direct these statements run normally.
TLS
PlanetScale Postgres requires TLS with certificate verification
(sslmode=verify-full, system CA store). The adapter’s policy:
- Remote hosts default to verified TLS (
rejectUnauthorized: true, Node’s system trust store), and unencrypted remote connections are refused — including attempts to sneaksslmode=disablein through the URL orpool: { ssl: false }past the options. - Connection-string SSL directives are canonicalized, not passed through.
pg’s own URL parser would otherwise silently override the resolved policy
(verified against pg 8.x:
?sslmode=requirediscards a configured CA;?sslcert=...reads files from disk). The adapter strips the SSL parameters from the URL it hands topgand enforces the resolved policy itself:sslmode=require|verify-fullandssl=trueare accepted;prefer,allow,verify-ca, empty, and unknown values, file-loading parameters (sslcert/sslkey/sslrootcert),sslnegotiation,uselibpqcompat, and conflicting directives are refused before the pool exists. Non-SSL URL parameters (e.g.application_name) are preserved untouched. - A custom CA survives: an explicit
ssl: { rejectUnauthorized: true, ca }stays in the final driver config even when the URL also carriessslmode=require(where pg alone would have discarded it). - The one deliberate escape hatch is an explicit
ssl: { rejectUnauthorized: false }in the options — an intentional, documented override for tools that must skip verification; no URL directive can produce it, and remote plaintext (ssl: false) is still refused. Note this is weaker than libpqverify-full(system trust store, no CA pinning by default). - Localhost (including
*.localhost) defaults to no TLS for local testing, and explicit local plaintext is allowed.
Management
The management side is dbsdk/management/planetscale, driven by a PlanetScale
service token (tokenId + tokenSecret, sent as the official
Authorization: <id>:<secret> header — no Bearer scheme):
import { createManagement } from "dbsdk/management";
import { planetscaleManagement } from "dbsdk/management/planetscale";
const management = createManagement({
adapter: planetscaleManagement({
tokenId: process.env.PLANETSCALE_TOKEN_ID!,
tokenSecret: process.env.PLANETSCALE_TOKEN_SECRET!,
organization: "my-org-slug", // required owner for all CRUD
}),
});
const result = await management.create({
kind: "project",
name: "my-database",
providerOptions: { cluster_size: "PS-10-GP" },
});
const database = await management.wait(result);
How PlanetScale’s model maps onto the unified verbs:
| Unified kind | PlanetScale resource | Addressing |
|---|---|---|
project |
database (the managed, billable unit) | name slug |
branch |
database branch (scoped to the database slug) | name |
role (provider-defined) |
Postgres role credentials, scoped to { projectId, branchId } |
id (uid), not name |
Key behaviors, all enforced before dispatch:
- Organization-scoped CRUD. The factory
organizationis the required owner for every CRUD verb, includingcreate('project')— aspec.organizationIdthat differs from it is refused with zero HTTP. Discovery (organizations(),regions(),raw.clusterSizeSkus()) may target other orgs explicitly. - Engine gate. Every mutating verb first fetches the parent database (one
documented extra GET, never cached) and refuses a non-PostgreSQL parent
before the mutation is sent. Reads refuse non-PostgreSQL payloads too,
and
list()filters mixed-engine pages rather than surfacing them. cluster_sizeis required at create (official API) and passed throughproviderOptions; unknown provider options are refused. There is noplanfield and no create-time password — credentials come from roles only.- Discovery is unfiltered:
raw.clusterSizeSkus()always sendsengine=postgresql(the official default is mysql) and returns the official array exactly as sent, includingenabled: falseentries — filtering is your call, the adapter does not guess. - Pagination is page-based;
limitabove the official maximum of 100 is refused (no silent clamp), and the cursor carries the next page number. - Connection strings:
connection(ref)resolves the official host and role username forproject,branch, orrole. Role passwords are one-time secrets from create/reset — they surface only insecrets, are redacted fromraw, and are registered for later error-message redaction.connection()can never produce a password (nothing is fabricated from the service token). resetCredential(role)rotates a role’s password (server-generated; caller-supplied passwords are refused, as on the other providers).raw.renewRoleextends a role’s expiration without generating a password.- Waiting polls real status: PlanetScale has no operations endpoint, so
wait()polls the resource’s own readiness (project,branch, androleall expose it; role readiness respects terminal/deletion and expired/disabled states before anyready: true).
Refused rather than guessed (official endpoints exist but are not verified
this round): branch update (PATCH), backups/restore, deploy requests, IP
restrictions, bouncers, and any lifecycle actions — the adapter declares no
actions at all.
Evidence and limits
Behavior is covered by automated tests: adapter contract tests with
fixture pools and an injected management HTTP layer, plus live legs against a
real local PostgreSQL 17 for the wire protocol, TLS config resolution, and the
pooled guards. The control-plane behavior is verified against the official
PlanetScale API documentation (evidence level docs). No hosted PlanetScale
endpoint has been exercised by this project — TLS/SCRAM behavior against the
real service and provisioning are documented claims, not live-verified ones.
