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:
2026-08-04 14:35:26 +00:00
co-authored by Claude Opus 5
parent 0799ec7325
commit a9b6494f56
13 changed files with 83 additions and 67 deletions
@@ -1,4 +1,4 @@
import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const dashboards = pgTable(
@@ -17,7 +17,7 @@ export const dashboards = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_dashboards_user_id').on(table.userId, table.id)],
(table) => [uniqueIndex('uq_dashboards_user_id').on(table.userId, table.id)],
);
export const screens = pgTable(
@@ -33,7 +33,7 @@ export const screens = pgTable(
hostTerminals: jsonb('host_terminals').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_screens_user_name').on(table.userId, table.name)],
(table) => [uniqueIndex('uq_screens_user_name').on(table.userId, table.name)],
);
export const dashboardDefaults = pgTable('dashboard_defaults', {
+2 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const emailAccounts = pgTable(
@@ -22,5 +22,5 @@ export const emailAccounts = pgTable(
syncMeta: jsonb('sync_meta').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_email_accounts_user_email').on(table.userId, table.email)],
(table) => [uniqueIndex('uq_email_accounts_user_email').on(table.userId, table.email)],
);
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -38,7 +38,7 @@ export const headscaleServers = pgTable(
},
(t) => [
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
unique('uq_headscale_servers_user_url').on(t.userId, t.url),
uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url),
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two servers active.
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -47,7 +47,7 @@ export const invoiceshelfAccounts = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label),
uniqueIndex('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label),
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two active and the UI picking one.
+4 -4
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, real, timestamp, unique, index, primaryKey } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
@@ -17,7 +17,7 @@ export const musicFavorites = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_music_favorites_user_kind_key').on(t.userId, t.kind, t.key),
uniqueIndex('uq_music_favorites_user_kind_key').on(t.userId, t.kind, t.key),
index('idx_music_favorites_user_kind').on(t.userId, t.kind),
],
);
@@ -36,7 +36,7 @@ export const musicPlaylists = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique('uq_music_playlists_user_name').on(t.userId, t.name)],
(t) => [uniqueIndex('uq_music_playlists_user_name').on(t.userId, t.name)],
);
// Ordered track entries of a playlist. `position` is 0-based; deletes cascade from the playlist.
@@ -76,5 +76,5 @@ export const musicNowPlaying = pgTable(
positionSec: real('position_sec').notNull().default(0),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.device] })],
(t) => [primaryKey({ name: 'pk_music_now_playing', columns: [t.userId, t.device] })],
);
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, timestamp, unique, index, check } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, timestamp, index, check, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -42,7 +42,7 @@ export const pushDevices = pgTable(
(table) => [
// One row per token per app. The app re-registers on every launch because tokens rotate silently, so
// registration is an upsert on this pair rather than an insert.
unique('uq_push_devices_token_bundle').on(table.token, table.bundleId),
uniqueIndex('uq_push_devices_token_bundle').on(table.token, table.bundleId),
index('idx_push_devices_user').on(table.userId),
// The schema is the source of truth for what may exist, not just its shape — see
// src/databases/CLAUDE.md. sql.raw because an interpolated string binds as a parameter, which
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -38,7 +38,7 @@ export const photosConfig = pgTable(
(t) => [
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
// unique on url: several keys against one instance is the whole point.
unique('uq_photos_config_user_label').on(t.userId, t.label),
uniqueIndex('uq_photos_config_user_label').on(t.userId, t.label),
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, timestamp, unique } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
// Where a self-hosted service lives, and what it takes to talk to it — for services the owner has exactly
@@ -45,5 +45,5 @@ export const serviceConnections = pgTable(
},
// One connection per service per owner. This is what makes the whole "no active flag" simplification safe:
// there is never a second row to choose between, so nothing can be ambiguous about which one is in use.
(t) => [unique('uq_service_connections_user_service').on(t.userId, t.service)],
(t) => [uniqueIndex('uq_service_connections_user_service').on(t.userId, t.service)],
);
@@ -5,10 +5,10 @@ import {
bigint,
text,
timestamp,
unique,
index,
jsonb,
foreignKey,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from './auth';
@@ -31,7 +31,7 @@ export const soulseekFavorites = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix.
(t) => [unique('uq_soulseek_favorites_user_username').on(t.userId, t.username)],
(t) => [uniqueIndex('uq_soulseek_favorites_user_username').on(t.userId, t.username)],
);
// ── Cached share trees ──
@@ -62,7 +62,7 @@ export const soulseekBrowseSnapshots = pgTable(
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(t) => [unique('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)],
(t) => [uniqueIndex('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)],
);
// One row per FOLDER, with that folder's files inlined as jsonb — not a row per file, which would be
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, timestamp, jsonb, unique, check } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, timestamp, jsonb, check, uniqueIndex, foreignKey } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { sql } from 'drizzle-orm';
import { serverIntegrations } from './server';
@@ -30,15 +30,22 @@ export const userIntegrations = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(),
serverIntegrationId: integer('server_integration_id').references(() => serverIntegrations.id, {
onDelete: 'set null',
}),
// Named explicitly. Drizzle's generated name would be
// `user_integrations_server_integration_id_server_integrations_id_fk` — 65 characters, and Postgres
// truncates identifiers at 63. It stores the truncated form, drizzle keeps comparing against its
// own 65-char version, and every `db:push` drops and re-adds the constraint forever.
serverIntegrationId: integer('server_integration_id'),
config: jsonb('config').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_user_integrations_user_provider').on(table.userId, table.provider),
foreignKey({
name: 'fk_user_integrations_server_integration',
columns: [table.serverIntegrationId],
foreignColumns: [serverIntegrations.id],
}).onDelete('set null'),
uniqueIndex('uq_user_integrations_user_provider').on(table.userId, table.provider),
check(
'ck_user_integrations_provider',
// sql.raw — see the note on ck_server_integrations_provider; an interpolated JS string becomes a
@@ -1,4 +1,4 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
@@ -55,7 +55,7 @@ export const walletWallets = pgTable(
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_wallet_wallets_user_name').on(t.userId, t.name),
uniqueIndex('uq_wallet_wallets_user_name').on(t.userId, t.name),
// At most one active wallet per owner, enforced by the DB rather than convention — a partial unique
// index over active rows only, mirroring uq_headscale_servers_one_active.
uniqueIndex('uq_wallet_wallets_one_active')
@@ -80,7 +80,7 @@ export const walletLabels = pgTable(
label: text('label').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
(t) => [uniqueIndex('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
);
// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
@@ -102,7 +102,7 @@ export const walletFrozenUtxos = pgTable(
reason: text('reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
(t) => [uniqueIndex('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
);
/**