ZeroStarter

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.db to reuse connections across hot reloads
  • Docker: when INTERNAL_API_URL is set, localhost in POSTGRES_URL is replaced with host.docker.internal (in packages/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

TablePurposeKey Columns
userApplication usersid, name, email (unique), emailVerified, image, role (console access), banned/banReason/banExpires
sessionLogin sessionsid, token (unique), expiresAt, userId, ipAddress, userAgent, activeOrganizationId, activeTeamId, impersonatedBy
accountOAuth provider linksid, accountId, providerId, userId, OAuth tokens
verificationEmail verification tokensid, 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

TablePurposeKey Columns
organizationWorkspacesid, name, slug (unique), logo, metadata
memberUser↔Org membershipid, organizationId, userId, role (default: "member")
teamSub-groups in orgsid, name, organizationId
teamMemberUser↔Team membershipid, teamId, userId
invitationPending invitesid, 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

TablePurposeKey Columns
waitlistPublic signup capturesid, 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, invitations
  • organization → many teams, members, invitations
  • team → one organization, many team members
  • session → one user

Migrations

Migrations are generated and managed with Drizzle Kit.

Creating a Migration

  1. Modify the schema in packages/db/src/schema/
  2. Generate a migration:
bun run db:generate
  1. Review the generated SQL in packages/db/drizzle/
  2. Apply the migration:
bun run db:migrate

Viewing Data

Open Drizzle Studio to browse and edit data:

bun run db:studio

Existing Migrations

MigrationDescription
0000_zero_starter.sqlBaseline schema: all auth, organization, and team tables (user, session, account, verification, organization, member, team, teamMember, invitation), including Better Auth Admin plugin fields
0001_waitlist.sqlAdds the waitlist table (public signup): id, email (unique), created_at

Adding New Tables

  1. 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(),
})
  1. Export from packages/db/src/schema/index.ts
  2. Run bun run db:generate then bun 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