diff --git a/src/databases/CLAUDE.md b/src/databases/CLAUDE.md index 7a9a10d5..d9c1e30f 100644 --- a/src/databases/CLAUDE.md +++ b/src/databases/CLAUDE.md @@ -62,8 +62,11 @@ 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`, `)})`, -) + 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 @@ -77,7 +80,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro 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 +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 @@ -87,7 +90,7 @@ 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 +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 @@ -99,22 +102,33 @@ longer version and re-creates the constraint on every push. **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. +table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it. -**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing -`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of -them is: +**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. - command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64 - command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65 - bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66 - bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65 +The six, and where each lives: -The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full -length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no -prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than -a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet -plugin's, in its own repository. + 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:** diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts index a44e2805..e5a9cdca 100644 --- a/src/databases/officer_db/src/command-hq/schema.ts +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -8,6 +8,7 @@ import { text, timestamp, uniqueIndex, + foreignKey, } from 'drizzle-orm/pg-core'; import { users } from '../auth/schema'; @@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable( 'command_hq_watcher_fires', { id: serial('id').primaryKey(), - sessionRowId: integer('session_row_id') - .notNull() - .references(() => commandHqSessions.id, { onDelete: 'cascade' }), + sessionRowId: integer('session_row_id').notNull(), /** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */ taskId: text('task_id').notNull(), firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)], + (table) => [ + // Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 — + // so it stores the short form, drizzle keeps comparing against its own long one, and every push drops + // and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a + // deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts. + foreignKey({ + name: 'fk_command_hq_watcher_fires_session', + columns: [table.sessionRowId], + foreignColumns: [commandHqSessions.id], + }).onDelete('cascade'), + index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt), + ], ); /** @@ -198,14 +208,20 @@ export const commandHqTags = pgTable( export const commandHqSessionTags = pgTable( 'command_hq_session_tags', { - sessionRowId: integer('session_row_id') - .notNull() - .references(() => commandHqSessions.id, { onDelete: 'cascade' }), + sessionRowId: integer('session_row_id').notNull(), tagId: integer('tag_id') .notNull() .references(() => commandHqTags.id, { onDelete: 'cascade' }), }, - (table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)], + (table) => [ + // 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`. + foreignKey({ + name: 'fk_command_hq_session_tags_session', + columns: [table.sessionRowId], + foreignColumns: [commandHqSessions.id], + }).onDelete('cascade'), + uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId), + ], ); /**