# Database Patterns ## Overview **One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was describing a different codebase. ``` src/databases/officer_db/ ├── src/ │ ├── db.ts # the connection │ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers │ ├── types.ts # every type export (Select / Insert / extended) │ └── schema/ │ ├── index.ts # re-exports all schema files │ └── *.ts # table definitions, grouped by domain └── package.json # exports "." and "./types" ``` Schema files are grouped by domain, not by table: `auth`, `chat-events`, `dashboards`, `email`, `headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`, `wallet`. ## Schema changes use `push`, not migrations `bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code as the source of truth, never those files. **The schema is the source of truth for what the database may contain**, not just for its shape. Where a column has a known set of legal values, say so with a `check()` rather than leaving it free `text` — otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the worked example: rows for deleted chat integrations kept their bot tokens long after the code that read them was gone, because nothing structural said they had become illegal. ```ts const SERVER_PROVIDERS = ['google', 'apify'] as const; check( 'ck_server_integrations_provider', // sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is // emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema. sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`, ) ``` Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the database has drifted, instead of quietly accepting it. Clean the rows, then push. ### Composite keys: use `uniqueIndex`, and name any long foreign key **Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.** drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares them against a schema that declares exactly the same name, columns and order, decides they differ, and emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made `db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt that could never be resolved by answering it. Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1. A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })` accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before adding one. **Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own longer version and re-creates the constraint on every push. `user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with `foreignKey({ name: 'fk_user_integrations_server_integration', … })`. **Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the table, data unaffected. Naming it explicitly does not help. Leave it. **Rules that still apply:** 1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes nothing — whatever is being re-created gets re-created next push regardless. 2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a uniqueness rule would break the upserts that depend on it. 3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which branch it takes at a truncate prompt. 4. **Read the plan before applying it:** `bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the two `pk_music_now_playing` lines. Anything else is your change. To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then `POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push --config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable. ## Type naming ```ts // types.ts — inferred from the schema, never hand-written export type UserSelect = typeof Schema.users.$inferSelect; export type UserInsert = typeof Schema.users.$inferInsert; // The bare name is the hydrated shape, when a table has relations worth carrying export type User = UserSelect & { passkeys: PasskeySelect[]; }; ``` Organise `types.ts` by domain with section comments, mirroring the schema files. ## Queries Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from `../types`: ```ts import { eq, and } from 'drizzle-orm'; import { db } from '../db'; import { users, passkeys } from '../schema'; import type { UserSelect } from '../types'; export async function getUsers(): Promise { … } ``` Every query is exported from `src/index.ts`, which is the only surface callers use. ## Importing ```ts // ✅ types from the types subpath import type { User } from 'officerdb/types'; // ✅ queries, schema and drizzle helpers from the package root import { getUserById, eq } from 'officerdb'; // ❌ never reach into src/schema or src/queries directly // ❌ never hand-write a type a table can infer ``` In app code import from `'types'`, which re-exports the database types. Only server and database code imports `officerdb/types` directly. ## Conventions - `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded (`chat_session_events` is the example — its id is also the replay cursor). - `timestamp('…', { withTimezone: true })`, always. - Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather than cast.