# 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: one `export * from './<feature>'` per line
│   ├── schema.ts       # what db:push creates — see below
│   ├── types.ts        # every type export (Select / Insert / extended)
│   ├── crypto.ts       # at-rest encryption, one key per purpose
│   ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│   └── <feature>/
│       ├── index.ts    # what this feature exports — declared here, not in a list three levels up
│       ├── schema.ts   # its tables
│       └── queries.ts  # everything that reads or writes them
└── package.json        # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```

**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.

**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.

One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)

**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.

## 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 `queries.ts` per feature directory, importing tables from `./schema` beside it 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<UserSelect[]> { … }
```

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.
