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>
This commit is contained in:
+31
-17
@@ -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:**
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user