db: stop push re-creating every composite key on every run
`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>
This commit is contained in:
+40
-32
@@ -49,45 +49,53 @@ check(
|
||||
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.
|
||||
|
||||
### `push` is interactive, and it asks the same question every time. Do not "fix" it.
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Read this before running `bun db:push`.** It plans 16 statements on a database that already matches the
|
||||
schema, and it will plan them again on the next push, and the one after. This is a drizzle-kit diffing
|
||||
bug, not drift you introduced and not something your change caused.
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
It drops and re-adds **every named composite unique constraint** — all 14 of them, `uq_screens_user_name`
|
||||
through `uq_wallet_labels_wallet_kind_ref`. Single-column `.unique()` is diffed correctly and left alone;
|
||||
only the `unique('name').on(a, b)` form is affected. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1:
|
||||
the names, columns and column order in the database are identical to what the schema declares.
|
||||
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.
|
||||
|
||||
It also drops and re-adds one foreign key, for a different and fully understood reason —
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` is **65 characters**, Postgres
|
||||
truncates identifiers at **63**, so drizzle compares its generated name against the stored, truncated one
|
||||
and always sees a difference.
|
||||
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.
|
||||
|
||||
**The rules, in order of how much damage getting them wrong does:**
|
||||
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.
|
||||
|
||||
1. **Never answer "Yes, truncate the table."** The prompt appears because `ADD UNIQUE` against a
|
||||
populated table is a data-risk statement. Truncating destroys the rows AND does not help: the
|
||||
constraint is dropped and re-added on the next push regardless of whether the table is empty.
|
||||
Always take `No, add the constraint without truncating the table` — it is the highlighted default,
|
||||
and it succeeds whenever the data has no duplicates.
|
||||
2. **Never delete a constraint from the schema to silence the prompt.** The schema is right; the diff is
|
||||
wrong. Removing `unique(...)` to make push quiet would drop a real constraint that upserts depend on —
|
||||
`onConflictDoUpdate({ target: [...] })` requires it to exist.
|
||||
3. **Do not reach for `--force`.** It auto-accepts data-loss statements, and which branch it takes at the
|
||||
truncate prompt has not been established here. Find out on a scratch database before ever pointing it
|
||||
at `officer_dev`.
|
||||
4. **Check what it is actually planning** before answering anything:
|
||||
**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
|
||||
16 above. Anything else is your change, and worth reading.
|
||||
two `pk_music_now_playing` lines. Anything else is your change.
|
||||
|
||||
Because of all this, push cannot currently be automated or run unattended — it needs a TTY. Piping input
|
||||
does not work; the prompt reads the terminal directly.
|
||||
|
||||
**If you are fixing this properly**, the leads are: name the over-long foreign key explicitly so it fits
|
||||
in 63 characters, and try `uniqueIndex('name').on(a, b)` in place of `unique('name').on(a, b)` — indexes
|
||||
are diffed on a different code path and may sidestep the bug. Test on a scratch database, not this one.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user