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.