Toast
ContributorPatterns

Database Patterns

Database Patterns

Database schema conventions, migrations, and seeding for Toast.

Quick Reference

pnpm db:generate  # Generate migration from schema changes
pnpm db:migrate   # Apply pending migrations
pnpm db:studio    # Open Drizzle Studio to browse data
pnpm db:seed      # Seed development data

Schema Conventions

All tables in packages/db/src/schema.ts follow these conventions:

Required Fields

Every table should have:

export const myTable = pgTable('my_table', {
  id: uuid('id').defaultRandom().primaryKey(),
  siteId: uuid('site_id')
    .notNull()
    .references(() => sites.id),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

Multi-tenancy

Every table must have site_id for multi-tenancy. This is non-negotiable.

siteId: uuid('site_id')
  .notNull()
  .references(() => sites.id),

Always add an index on site_id for query performance:

export const myTable = pgTable(
  'my_table',
  {
    // ... columns
  },
  (table) => [index('idx_my_table_site_id').on(table.siteId)]
);

Naming

ElementConventionExample
Table namessnake_caseuser_sessions
Column namessnake_casecreated_at
TypeScriptcamelCasecreatedAt
Index namesidx_{table}_{column}idx_content_site_id

Types

Use CasePostgreSQL TypeDrizzle
Primary keyuuiduuid('id').defaultRandom().primaryKey()
Foreign keyuuiduuid('site_id').references(() => ...)
Timestampstimestamp with tztimestamp('...', { withTimezone: true })
Short textvarchar(n)varchar('name', { length: 255 })
Long texttexttext('body')
JSON datajsonbjsonb('metadata')
EnumsPostgreSQL enumpgEnum('status', ['draft', 'published'])

Enums

Define enums at the top of schema.ts:

export const contentStatusEnum = pgEnum('content_status', ['draft', 'published']);

export const content = pgTable('content', {
  status: contentStatusEnum('status').notNull().default('draft'),
});

Migrations

Creating Migrations

After editing schema.ts:

pnpm db:generate

This creates a new migration file in packages/db/migrations/.

Applying Migrations

# Local development
pnpm db:migrate

# Production (Railway)
# Runs automatically via preDeployCommand in railway.json

Migration Rules

  1. Never edit existing migration files - Create a new migration instead
  2. Make migrations additive - Add columns as nullable first, then backfill, then add NOT NULL
  3. Test with production-like data - Migrations that work on empty tables may fail on real data
  4. Keep migrations small - One logical change per migration

Rollback Strategy

Drizzle doesn't have automatic rollbacks. For breaking changes:

  1. Create a "down" migration manually if needed
  2. Test thoroughly in preview environments
  3. Have a recovery plan before deploying

Seeding

Development Data

Fixtures live in packages/db/fixtures/:

packages/db/fixtures/
└── content.json    # Sample content in TipTap JSON format

Run the seed script:

pnpm db:seed

The seed script:

  1. Creates a default site ("Default Site") if none exists
  2. Creates an admin user for that site if none exists (with Better Auth-compatible password hashing)
  3. Clears existing content for that site
  4. Inserts all fixtures

Admin User Seeding

The seed script creates a default admin user for authentication testing. Configure via environment variables:

VariableDefaultDescription
SEED_ADMIN_EMAILadmin@example.comEmail for the admin user
SEED_ADMIN_PASSWORD(generated)Password - printed to console if omitted
SEED_ADMIN_NAMEAdminDisplay name for the admin user

The script is idempotent - running it multiple times won't duplicate users:

# First run
pnpm db:seed
#   Site "Default Site" created (id: xxx)
#   Admin user created: admin@example.com
#   Password: <generated>

# Second run
pnpm db:seed
#   Site "Default Site" already exists (id: xxx)
#   Admin user admin@example.com already exists

Fixture Format

Content uses TipTap JSON structure:

{
  "title": "Example Post",
  "status": "published",
  "body": {
    "type": "doc",
    "content": [
      {
        "type": "paragraph",
        "content": [{ "type": "text", "text": "Hello world" }]
      }
    ]
  }
}

Querying

Using the Database Client

import { getDb } from '@toast/db';
import { content, sites } from '@toast/db';
import { eq, and } from 'drizzle-orm';

const db = getDb();

// Simple query
const items = await db.select().from(content);

// With conditions
const published = await db
  .select()
  .from(content)
  .where(and(eq(content.siteId, siteId), eq(content.status, 'published')));

// With joins
const withSite = await db.select().from(content).innerJoin(sites, eq(content.siteId, sites.id));

Repository Pattern

Don't access the database directly from services. Use repositories:

// Good - in repository
export async function findBySiteId(siteId: string) {
  const db = getDb();
  return db.select().from(content).where(eq(content.siteId, siteId));
}

// Bad - in service
const db = getDb(); // Don't do this in services

See ADR-004 for why.

Connection Management

The database client is initialized lazily:

// packages/db/src/client.ts
let db: PostgresJsDatabase | null = null;

export function getDb(): PostgresJsDatabase {
  if (!db) {
    db = drizzle(postgres(process.env.DATABASE_URL!));
  }
  return db;
}

For health checks:

import { checkConnection } from '@toast/db';

const isHealthy = await checkConnection(); // Returns boolean

On this page