`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.
A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.
Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.
Measured on a scratch database, pushing twice each time:
before 32 statements, interactive prompt
after uniqueIndex 4
after FK fix 2
The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.
The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.
Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.2 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: 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.
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:
- 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.
- 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.
- Do not use
--force. It auto-accepts data-loss statements and nobody has established which branch it takes at a truncate prompt. - Read the plan before applying it:
bunx drizzle-kit push --config=drizzle.config.ts --verboseprints every statement first. Expect the twopk_music_now_playinglines. 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 file per domain under src/queries/, importing tables from ../schema 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
serialfor ids on small tables;bigserialwithmode: 'number'where the row count is unbounded (chat_session_eventsis 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.