Database
PostgreSQL database schema, Drizzle ORM configuration, and migration workflows.
Overview
ZeroStarter uses PostgreSQL with Drizzle ORM for type-safe database operations. The database package lives at packages/db/ and provides a shared database client and schema used by the auth and API packages.
Connection
The database client is initialized in packages/db/src/index.ts using Bun's native SQL driver:
import { env } from "@packages/env/db"
import { SQL } from "bun"
import { drizzle } from "drizzle-orm/bun-sql"
import * as schema from "@/schema"
let db
if (env.NODE_ENV === "production") {
const client = new SQL(env.POSTGRES_URL, {
connectionTimeout: 10,
idleTimeout: 30,
maxLifetime: 0,
tls: { rejectUnauthorized: true },
})
db = drizzle({ client, schema })
} else {
// reuse one client across hot reloads
if (!global.db) {
const client = new SQL(env.POSTGRES_URL, {
connectionTimeout: 10,
idleTimeout: 30,
maxLifetime: 0,
})
global.db = drizzle({ client, schema })
}
db = global.db
}
export { db }The connection string is passed positionally to new SQL(...), and the branch is keyed on env.NODE_ENV === "production" (TLS is only set in the production branch).
Connection Pooling
- Production: a single client created at module load, with TLS and certificate verification
- Non-production: singleton via
globalThis.dbto reuse connections across hot reloads - Docker: when
INTERNAL_API_URLis set,localhostinPOSTGRES_URLis replaced withhost.docker.internal(inpackages/env/src/db.ts)
Schema
The auth and organization schema is defined in packages/db/src/schema/auth.ts (9 tables); the public waitlist uses a standalone waitlist table in packages/db/src/schema/waitlist.ts.
Core Auth Tables
| Table | Purpose | Key Columns |
|---|---|---|
user | Application users | id, name, email (unique), emailVerified, image, role (console access), banned/banReason/banExpires |
session | Login sessions | id, token (unique), expiresAt, userId, ipAddress, userAgent, activeOrganizationId, activeTeamId, impersonatedBy |
account | OAuth provider links | id, accountId, providerId, userId, OAuth tokens |
verification | Email verification tokens | id, identifier, value, expiresAt |
The role/banned/banReason/banExpires columns on user and impersonatedBy on session are provided by the Better Auth Admin plugin and are part of the baseline schema.
Organization & Team Tables
| Table | Purpose | Key Columns |
|---|---|---|
organization | Workspaces | id, name, slug (unique), logo, metadata |
member | User↔Org membership | id, organizationId, userId, role (default: "member") |
team | Sub-groups in orgs | id, name, organizationId |
teamMember | User↔Team membership | id, teamId, userId |
invitation | Pending invites | id, organizationId, email, role, teamId, status, expiresAt, inviterId |
Tables include appropriate indexes on foreign key columns.
Foreign keys cascade on delete, so deleting an organization cascades to its members, teams, and invitations.
Waitlist
| Table | Purpose | Key Columns |
|---|---|---|
waitlist | Public signup captures | id, email (unique), createdAt |
The waitlist table is standalone (no foreign keys). The public /waitlist page and the POST /api/waitlist route write to it.
Relations
Drizzle relations are defined for type-safe query joins:
user→ many sessions, accounts, team memberships, org memberships, invitationsorganization→ many teams, members, invitationsteam→ one organization, many team memberssession→ one user
Migrations
Migrations are generated and managed with Drizzle Kit.
Creating a Migration
- Modify the schema in
packages/db/src/schema/ - Generate a migration:
bun run db:generate- Review the generated SQL in
packages/db/drizzle/ - Apply the migration:
bun run db:migrateViewing Data
Open Drizzle Studio to browse and edit data:
bun run db:studioExisting Migrations
| Migration | Description |
|---|---|
0000_zero_starter.sql | Baseline schema: all auth, organization, and team tables (user, session, account, verification, organization, member, team, teamMember, invitation), including Better Auth Admin plugin fields |
0001_waitlist.sql | Adds the waitlist table (public signup): id, email (unique), created_at |
Adding New Tables
- Create or edit a schema file in
packages/db/src/schema/:
import { pgTable, text, timestamp } from "drizzle-orm/pg-core"
export const project = pgTable("project", {
id: text("id").primaryKey(),
name: text("name").notNull(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
})- Export from
packages/db/src/schema/index.ts - Run
bun run db:generatethenbun run db:migrate
Configuration
Drizzle Kit configuration in packages/db/drizzle.config.ts:
export default defineConfig({
dialect: "postgresql",
dbCredentials: { url: env.POSTGRES_URL },
schema: "src/schema",
out: "drizzle",
})PostgreSQL Providers
For production, use a managed PostgreSQL service:
For local development, you can create a database instantly with:
bunx pglaunch -k