Files
platform/src/databases/CLAUDE.md
T
pastilhasandClaude Opus 5 500c2f2179 name the six over-long foreign keys, so a push can honestly be a no-op
Drizzle generates foreign-key names past Postgres's 63-character identifier limit.
Postgres stores the truncated form, drizzle keeps comparing against its own longer
one, and every `db:push` on every host drops and re-adds constraints that are
already identical. Eight statements, for ever.

Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather
than a fault". What ended that was last night's deploy plan saying "expect no
changes, stop and report if it wants to alter something" — which this made
impossible to satisfy on any host, so the deploy agent had to overrule its own
instructions and say why. An annoyance had become an instruction nobody could
follow.

Two of the six are in this repo; the other four are in the atelier and
bitcoin-wallet plugin repos and are committed there. Verified the way the doc
prescribes: push twice against a scratch database, and the second run went from ten
statements to two.

The two that remain are `pk_music_now_playing`, and that one is tested rather than
assumed. Removing its explicit name produces the identical churn under drizzle's
generated name, so it is the named-composite mis-diff and not the truncation one,
and a primary key cannot escape to an index form. A push now prints exactly those
two lines — which is a far more useful baseline than ten, because anything else
really is your change.

Naming a foreign key means removing the inline `.references()` or drizzle creates
both, and `walletChainCache` had to move to the three-argument `pgTable` form
because there was nowhere else to put the declaration. Both noted in the doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:16:19 +00:00

11 KiB

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.

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. Neither naming it nor un-naming it helps — both were tested. Leave it.

All six over-long foreign keys are now named, 2026-08-23. They were deferred as "noise rather than a fault" for four days. What ended that was a deploy plan saying "expect no changes, stop and report if it wants to alter something" — which this made impossible to satisfy on any host, forcing the deploy agent to overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could follow.

The six, and where each lives:

fk_command_hq_watcher_fires_session        officer_db/src/command-hq/schema.ts
fk_command_hq_session_tags_session         officer_db/src/command-hq/schema.ts
fk_bitcoin_wallet_frozen_utxos_wallet      plugins/bitcoin-wallet/db/schema.ts
fk_bitcoin_wallet_chain_cache_wallet       plugins/bitcoin-wallet/db/schema.ts
fk_atelier_collection_items_collection     plugins/atelier/sidecar/db/collections/schema.ts
fk_atelier_collection_items_generation     plugins/atelier/sidecar/db/collections/schema.ts

Two things worth knowing if you add another. Naming it means REMOVING the inline .references() on the column, or drizzle creates both. And walletChainCache had to move from the two-argument pgTable form to the three-argument one, because there was nowhere else to put the declaration.

pk_music_now_playing still churns, and it cannot be fixed. Two statements per push. Tested on 2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under drizzle's generated name (music_now_playing_user_id_device_pk), so this is the named-composite mis-diff rather than the truncation one, and there is no index form to escape to because a primary key must be a constraint. A push is therefore expected to print exactly these two statements and nothing else — which is a far more useful baseline than ten, because anything else really is your change.

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 and the eight from the four over-long foreign keys above — ten in all. 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

// 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:

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

// ✅ 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.