GenerateSaaS

Database

Work with the @repo/database Drizzle + Postgres layer - provider choice, the merged schema, and the row types every backend package reads.

@repo/database is the single backend data layer: Drizzle ORM over Postgres, shared by the Hono API and every backend package. Frontends never touch it directly.

packages/database/src/index.ts validates DATABASE_URL with Zod at module load (throwing on a bad value), then exports the ready db client, the merged schema, per-table row types, and the affectedRowCount helper.

Choosing a provider

All three providers are Drizzle over Postgres, so application code is identical whichever you pick. The CLI sets the driver and writes DATABASE_URL during init; switching later means swapping that one connection string.

Provider keyHostingDriverWhen to use
postgresSelf-hosted (Docker, VPS, RDS)pg - also exports poolYou want full control of provisioning, backups, and connection limits. The default
neonManaged, serverlessdrizzle-orm/neon-serverless over a WebSocket PoolServerless Postgres that scales to zero, pairing with serverless deploys
supabaseManagedpgManaged Postgres with the wider Supabase platform around it

Self-hosted Postgres is incompatible with serverless deployment. A Vercel function cannot pool connections to a self-hosted instance, so init never offers the pair and update refuses a manifest that records it - on Vercel, pick neon or supabase.

Neon uses the WebSocket driver, not the HTTP one, so interactive db.transaction(...) works exactly as on self-hosted Postgres. affectedRowCount(result) is the only other place driver differences surface - use it instead of result.rowCount so the same code runs on all three.

First-time setup

pnpm db:setup

This pushes the Drizzle schema to your local database, creating every table. It is idempotent, so re-run it after pulling schema changes. No Postgres extensions are required. Shared and production databases are shaped by migrations instead - see Schema changes.

import { db, schema, users } from "@repo/database";

Tables

The merged schema combines four sources. Auth tables are generated; the rest are hand-written.

SourceTablesHolds
db/auth.ts (generated)users, accounts, verifications, passkeys, twoFactors, apikeys, subscriptionsAccounts, credentials, second factors, programmatic keys, subscription state
db/auth.ts (generated)organizations, members, invitations, deviceCodes, oauth*Tenants and membership; device pairing and the outward MCP OAuth server
db/schema.tsauditLogs, billingLogs, chargeReversals, ownedProducts, notifications, announcements, processedWebhookEventsAudit trail, money/credit ledger, one-time purchases, in-app messaging, webhook idempotency
db/ai-*.tsaiProviderKeys, userAutomations, userAutomationRuns, integrationConnections, userAiSettingsUser AI keys, automated runs and their history, connected integrations, per-user AI preferences
db/email-analytics.tsemailSends, emailEventsPer-message send records and their open/click events

Tables are always created; turning a flag off hides the feature and stops the reads - it never drops the schema.

TableRead only when
apikeysconfig.apiKeys.enabled is true (default on)
notificationsconfig.notifications.enabled is true (default on)
organizations / members / invitationsconfig.tenancy.multiTenant is true

Row types and billing columns

Most tables export an $inferSelect row type named for the table (User, Organization, AuditLog, AiProviderKey, …); OwnedProductRow carries a Row suffix to avoid a name clash.

Only users carries billing columns, mirroring billingAdditionalFields from @repo/config: credits (numeric, default "0"), plan, planStartedAt, planExpiresAt, autoTopUpEnabled, autoTopUpThreshold, autoTopUpAmount, creditsLastGrantedAt. organizations holds no money at all - work inside an organization draws on its owner's row. See Organizations and Credits.

Frequently asked questions

Why is there no Subscription row type? The subscriptions table exists, but its rows are read through Better Auth's billing plugin rather than a raw Drizzle select, so no $inferSelect type is exported. processedWebhookEvents and chargeReversals are the same.

Why are credits columns numeric instead of integer? To support fractional credits. The numeric precision (16, 6) keeps credit math consistent across engines and drivers - metered debits are priced in micro-credits.

On this page