Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use PlanetScale with DaloyJS

PlanetScale offers two database engines:

  • PlanetScale MySQL, built on Vitess, with branching, deploy requests, and the fetch-based @planetscale/database HTTP driver. Because that driver uses plain fetch, it runs on Node, Bun, Deno, Lambda, and Cloudflare Workers. Most of this page covers it.
  • PlanetScale Postgres, which is Postgres: use a standard driver such as pg. @planetscale/database is the MySQL (Vitess) driver and is not used for Postgres. Jump to PlanetScale Postgres.
One HTTP driver, every runtime
DaloyJS routehandler reads state.db
app.decorate("db", db)db.execute(sql, params)
@planetscale/databasefetch-based HTTP driver
NodeBunDenoLambdaWorkers
PlanetScale MySQLmanaged MySQL on Vitess
branchesdeploy requests
Because @planetscale/database speaks plain fetch instead of a raw TCP socket, the same data-access code runs on every runtime DaloyJS targets, including Cloudflare Workers.

1. Provision and grab credentials

Create a database at app.planetscale.com, generate a password, and copy the host plus credentials. Set them as DATABASE_HOST, DATABASE_USERNAME, and DATABASE_PASSWORD. Give the app a password with the read/write role, not admin.

2. Install

bash
pnpm add @planetscale/database@^1

3. Create a PlanetScale plugin

src/db/planetscale.tsts
import { connect } from "@planetscale/database";
import type { App } from "@daloyjs/core";

export const db = connect({
  host: process.env.DATABASE_HOST!,
  username: process.env.DATABASE_USERNAME!,
  password: process.env.DATABASE_PASSWORD!,
});
export type Db = typeof db;

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export function planetscalePlugin(app: App) {
  app.decorate("db", db);
}

4. Augment app state

Add the declare module block to the same module that creates the client, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.

ts
// src/db/planetscale.ts (same module as the plugin above)
declare module "@daloyjs/core" {
  interface AppState {
    db: Db;
  }
}

5. Use it in a route

src/server.tsts
import { z } from "zod";
import { App, secureHeaders } from "@daloyjs/core";
import { planetscalePlugin } from "./db/planetscale.ts";

const app = new App();
app.use(secureHeaders());
planetscalePlugin(app);

const UserSchema = z.object({ id: z.string(), email: z.email() });

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: z.string() }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    const result = await state.db.execute(
      "select id, email from users where id = ? limit 1",
      [params.id],
    );
    const row = result.rows[0] as { id: string; email: string } | undefined;
    return row
      ? { status: 200, body: row }
      : { status: 404, body: { type: "about:blank", title: "Not found", status: 404 } };
  },
);

Cloudflare Workers

Store the credentials with pnpm wrangler secret put, run pnpm wrangler types so they are typed on env, construct the connection from env (imported from cloudflare:workers), and decorate the app before registering routes. Do not decorate inside the Worker fetch handler: decorate() throws on the second request, and a decoration added after the routes were registered never reaches them.

src/index.tsts
import { env } from "cloudflare:workers";
import { connect } from "@planetscale/database";
import { App } from "@daloyjs/core";
import { toFetchHandler } from "@daloyjs/core/cloudflare";

const app = new App({ production: true, behindProxy: { hops: 1 } });
app.decorate(
  "db",
  connect({
    host: env.DATABASE_HOST,
    username: env.DATABASE_USERNAME,
    password: env.DATABASE_PASSWORD,
  }),
);

// ...register routes that read state.db

export default toFetchHandler(app);

If you would rather use mysql2 with a regular MySQL connection string, put Hyperdrive in front of the database; Cloudflare lists PlanetScale among its Hyperdrive providers.

With Drizzle ORM

bash
pnpm add drizzle-orm
src/db/drizzle.tsts
import { drizzle } from "drizzle-orm/planetscale-serverless";

export const db = drizzle({
  connection: {
    host: process.env.DATABASE_HOST!,
    username: process.env.DATABASE_USERNAME!,
    password: process.env.DATABASE_PASSWORD!,
  },
});

With Prisma

Prisma 7 connects through a driver adapter, which is mandatory, and generates the client into your source tree. Use the PlanetScale adapter (current release: Prisma 7.10):

bash
pnpm add @prisma/client @prisma/adapter-planetscale
bash
pnpm add -D prisma

PlanetScale MySQL disables foreign-key constraints by default unless you enable them in the database settings. In the default no-FK mode, set relationMode = "prisma" and add your own indexes on relation fields:

prisma/schema.prismaprisma
datasource db {
  provider     = "mysql"
  relationMode = "prisma"
}

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}
src/db/prisma.tsts
import { PrismaPlanetScale } from "@prisma/adapter-planetscale";
import { PrismaClient } from "../generated/prisma/client.ts";

const adapter = new PrismaPlanetScale({ url: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({ adapter });

Prisma CLI commands read their connection URL from prisma.config.ts. See Prisma for the config file, plugin, and shutdown setup.

PlanetScale Postgres

PlanetScale Postgres is a Postgres database, so you use pg (or any Postgres driver or ORM) with the connection string from the dashboard. TLS is required. PlanetScale recommends port 6432 (PgBouncer, transaction pooling) for application traffic and port 5432 (direct) for migrations, long-running admin work, and session features.

bash
pnpm add pg
bash
pnpm add -D @types/pg

Node, Bun, Deno, and Lambda

src/db/pg.tsts
import { Pool } from "pg";
import type { App } from "@daloyjs/core";

// Module scope: reused across requests and warm Lambda invocations.
export const pool = new Pool({
  connectionString: process.env.DATABASE_URL!, // the :6432 PgBouncer string
  max: 10,
  idleTimeoutMillis: 30_000,
});

declare module "@daloyjs/core" {
  interface AppState {
    db: Pool;
  }
}

// Call with the root app, not through app.register().
export function pgPlugin(app: App) {
  app.decorate("db", pool);
  // Runs on the Node, Bun, and Deno adapters. Lambda has no shutdown hook,
  // so keep max small there and rely on the idle timeout.
  app.onClose(async () => {
    await pool.end();
  });
}

Query with parameters: await state.db.query("select id, email from users where id = $1", [params.id]). PgBouncer in transaction mode does not keep session state, so avoid SET, advisory locks, and LISTEN on the 6432 string.

Cloudflare Workers

Create a Hyperdrive config with the PlanetScale Postgres connection string and use pg with env.HYPERDRIVE.connectionString, one client per request. The Hyperdrive page has the full DaloyJS sample.

Branching & deploy requests

PlanetScale MySQL's schema workflow uses branches and deploy requests rather than ad-hoc ALTER TABLE. Pair this with your CI: run migrations against a development branch, open a deploy request, and merge to main. The same Daloy app code works against any branch. Swap the host.

See also Cloudflare Hyperdrive, Neon, Supabase, and the database hosting overview.

Edit this page on GitHub

Last updated