postgres driver.
One database holds everything.
Schema files
Drizzle table definitions live in domain-scoped files.src/lib/schema.ts is
the entry point (drizzle.config.ts points at it) and re-exports the rest.
Fresh installs: db/
db/schema.sqlis the complete current schema, generated bybun run db:schema:dump(apg_dump --schema-onlymade deterministic and portable; it applies to PostgreSQL 16 and newer). Do not edit it by hand. Legacy tables that no code uses (LEGACY_UNUSEDin the tenancy registry) are left out.db/seed.sqlholds reference rows every install needs (CRM categories and status configuration). It usesON CONFLICT DO NOTHING.bun run db:setup(scripts/db/setup.ts) applies both in one transaction to an empty database, and refuses a database that already has tables. A failure leaves the database untouched.bun run db:checkaudits a database against the tenancy registry (see below).
Migration history: scripts/
scripts/*.ts and scripts/*.js are one-shot migrations, run by hand, each
loading .env.local and connecting with DATABASE_URL. Nothing in src/
imports them. drizzle.config.ts declares out: "./drizzle", but no
drizzle/ directory has ever been generated, so these scripts are the written
record of how the schema was built, and the way an existing install moves
forward. Do not delete them as dead code.
Conventions:
- Guard DDL with
IF NOT EXISTS/IF EXISTSso re-running is safe. - Seed scripts upsert, not blind-insert.
- New migrations are TypeScript, run with
bun run scripts/<name>.ts. - Adding a column means updating both the migration and the matching Drizzle schema; they are kept in sync by hand.
- After applying a migration, run
bun run db:schema:dumpand commitdb/schema.sqlwith it.
preflight-*, rollback-*, audit-* and
so on) belong to a one-time migration documented in design/.
drizzle-kit and OWNED_TABLES
drizzle.config.ts sets tablesFilter to an allowlist, OWNED_TABLES, of the
application-owned snake_case tables. Without a filter drizzle-kit treats every
table it does not know as drift and offers to drop it.
- When you add a snake_case table, add it to
OWNED_TABLES, or drizzle-kit will not manage it. - The lead database is the exception.
people,companies,entity_columnsandimport_runsare deliberately not in the list.peopleandcompaniesgain columns at runtime: the column settings UI runsALTER TABLE ... ADD COLUMN(src/lib/leads/columns.ts). Their Drizzle definitions therefore describe the core columns only. If they were in the allowlist, drizzle-kit would read that partial definition as the truth, see every user-added column as drift and offer to drop it, destroying user data on the nextpush. The other two are excluded so the rule stays one sentence. The lead database is hand-maintained throughscripts/create-lead-tables.ts. entity_columnsis the registry of those runtime columns. A column name reaches SQL only after matching a registry row, which is what makes dynamic column names safe.bun run scripts/check-lead-column-drift.tsverifies the registry and the physical schema agree.- The LinkedIn tables are also excluded, for the same reason: drizzle-kit
must have no way to alter live LinkedIn data. Change them with a one-shot
migration in
scripts/and updatesrc/lib/linkedin/schema.tsby hand. They came from a former Prisma schema and keep quoted PascalCase names, withtextids (cuid) rather thanuuid, to avoid anALTER TYPEacross every id and foreign key. New rows use$defaultFn(createId). The archived Prisma migrations are in prisma-history/; nothing there runs.
drizzle-kit push against a database you care about.
Use migration scripts.
Tenancy registry
src/lib/tenancy/registry.ts classifies every table: scoped (own
organization_id; "organizationId" on the LinkedIn tables), inherited
(scoped through a NOT NULL foreign key to a parent), global, auth, or
legacy. It also lists the uniques that are per organization
(PER_ORG_UNIQUES: people email and LinkedIn URL, company domain, suppression
email, pipeline name, grid_providers.key, and so on). The migration scripts,
db:check and the isolation test all read it, so they cannot disagree. A new
table that nobody classified fails db:check. The rules for queries are in
multi-tenancy/conventions.md.
Connection pool
max_connections on the server is shared by everything that points at it. The
app has one pool, DB_POOL_MAX connections (default 8), in src/lib/db.ts;
Better Auth reuses it, so never create another pool in app code. When the server
is full every query fails at once with remaining connection slots are reserved for roles with the SUPERUSER attribute: that is capacity, not a code bug.
Before raising DB_POOL_MAX, check pg_stat_activity, and remember a rolling
deploy briefly runs two containers. The connection uses TLS unless
DATABASE_SSL=disable.