Skip to content

Database

The db/ workspace manages the data layer with Drizzle ORM and Neon PostgreSQL. In production, Cloudflare Hyperdrive pools and caches connections at the edge.

Workspace Structure

bash
db/
├── schema/             # Table definitions and relations
├── migrations/         # Auto-generated SQL migrations
├── seeds/              # Seed data scripts
├── scripts/            # Utilities (seed runner, export)
├── drizzle.config.ts   # Drizzle Kit configuration
├── testing.ts          # createTestDatabase() – PGlite for tests
└── index.ts            # Re-exports schema, DatabaseSchema and Database types

Schema files are organized by domain – one file per entity group (e.g., user.ts contains the user, session, identity, and verification tables). All tables are re-exported from schema/index.ts, and that barrel is the only file Drizzle Kit reads: a table missing from it is invisible to bun db:generate, which then reports no changes rather than an error.

Connection Architecture

The API worker connects to Neon through Cloudflare Hyperdrive, which provides connection pooling and optional query caching at the edge.

Two Hyperdrive bindings are available:

BindingCacheUse for
HYPERDRIVE_CACHED60 s + 15 s stale by defaultRead-heavy queries where staleness is acceptable
HYPERDRIVE_UNCACHEDNoneWrites and anything requiring fresh data

Both are exposed in tRPC context as ctx.db (uncached) and ctx.dbCached (cached). Better Auth uses db, since a stale session or role row would outlive a sign-out or permission change:

ts
// apps/api/lib/db.ts (simplified)
export function createDb(hyperdrive: Hyperdrive) {
  const client = postgres(hyperdrive.connectionString, {
    max: 1, // two clients per request share the connection budget
  });
  return drizzle(client, { schema, casing: "snake_case" });
}

INFO

In development, Wrangler's getPlatformProxy() emulates the Hyperdrive bindings locally, resolving each from its own CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_* variable in .env – not from DATABASE_URL, which is read by the Drizzle tooling in db/ and nothing else. Local bindings connect straight to Postgres, so neither pooling nor query caching is active. Your code uses the same HYPERDRIVE_CACHED / HYPERDRIVE_UNCACHED bindings in both environments – no conditional connection logic needed.

Commands

Run from the repo root. Some take a :staging or :production suffix to target another environment – see Environment Targeting for which, and why the rest do not.

CommandDescription
bun db:generateGenerate migration SQL from schema changes
bun db:migrateApply pending migrations
bun db:pushPush schema directly (skips migration files)
bun db:studioOpen Drizzle Studio browser UI
bun db:seedRun seed scripts
bun db:checkCheck generated migration history for conflicts
bun db:exportExport database via pg_dump to db/backups/
bun db:typecheckRun TypeScript type-checking on the db/ workspace

Environment Targeting

Database scripts select the environment through the ENVIRONMENT variable (falls back to NODE_ENV). There are three: dev, staging and production. Development cascades through env files, first value wins:

.env.dev.local  →  .env.local  →  .env

Staging and production do not cascade. bun db:migrate:production reads .env.production.local and only that file, and those values override anything already exported. If the file is missing the command fails instead of falling through, so an environment-named command can never end up on another environment's database.

Not every command has :staging and :production variants, by design:

CommandRemote variantsWhy
db:migrate, db:studio, db:exportYesApplying migrations, inspecting and backing up are real remote operations
db:seed:staging onlySeeds create test accounts – they have no business in production
db:generateNoReads the schema and existing migrations; it never connects to a database
db:pushNoSyncs schema without a migration file – prototyping only, never deployed

The DATABASE_URL variable must be a valid postgres:// or postgresql:// connection string.

There is deliberately no test environment. Tests run against PGlite in-process, so they never resolve a connection string – and ENVIRONMENT=test fails loudly rather than falling through to whichever database .env.local points at.

See Environment Variables for full details.

Importing Schemas

The @repo/db package exports three entry points:

ts
import * as schema from "@repo/db"; // full schema + type exports
import { user, session } from "@repo/db/schema"; // individual tables
import { createTestDatabase } from "@repo/db/testing"; // test database

The root entry also exports Database, the schema-bound client type:

ts
import type { Database } from "@repo/db";

async function findMembership(db: Database, userId: string, orgId: string) {
  return db.query.member.findFirst({
    where: (m, { and, eq }) =>
      and(eq(m.userId, userId), eq(m.organizationId, orgId)),
  });
}

It names the schema, not the driver – postgres-js over Hyperdrive in production, PGlite in tests – so a helper written against it works in both without a cast.