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 key | Hosting | Driver | When to use |
|---|---|---|---|
postgres | Self-hosted (Docker, VPS, RDS) | pg - also exports pool | You want full control of provisioning, backups, and connection limits. The default |
neon | Managed, serverless | drizzle-orm/neon-serverless over a WebSocket Pool | Serverless Postgres that scales to zero, pairing with serverless deploys |
supabase | Managed | pg | Managed 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:setupThis 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.
| Source | Tables | Holds |
|---|---|---|
db/auth.ts (generated) | users, accounts, verifications, passkeys, twoFactors, apikeys, subscriptions | Accounts, 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.ts | auditLogs, billingLogs, chargeReversals, ownedProducts, notifications, announcements, processedWebhookEvents | Audit trail, money/credit ledger, one-time purchases, in-app messaging, webhook idempotency |
db/ai-*.ts | aiProviderKeys, userAutomations, userAutomationRuns, integrationConnections, userAiSettings | User AI keys, automated runs and their history, connected integrations, per-user AI preferences |
db/email-analytics.ts | emailSends, emailEvents | Per-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.
| Table | Read only when |
|---|---|
apikeys | config.apiKeys.enabled is true (default on) |
notifications | config.notifications.enabled is true (default on) |
organizations / members / invitations | config.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.
API Keys
Issue per-user API keys for programmatic access, authenticated by the x-api-key header and rate-limited from the owner's plan.
Schema changes
Generate, review, and apply Drizzle migrations - the scripts, the db:deploy step every deploy runs, and the checks to make before narrowing a money column.