From b2722df5aab630a0988c7004e8306a973d507f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 14:16:31 +0000 Subject: [PATCH] name the over-long foreign keys so db:push can be a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drizzle generates names past Postgres's 63-character identifier limit. Postgres stores the truncated form, drizzle compares against its own longer one, and every push on every host drops and re-adds constraints that are already identical. Deferred as harmless noise since 2026-08-19. What ended that was a deploy plan saying "expect no changes, stop if it wants to alter something" — which this made impossible to satisfy, so the deploy agent had to overrule its own instructions. Verified by pushing twice against a scratch database: the fleet-wide second-push churn went from ten statements to two, and the two that remain are a composite primary key that cannot be fixed either way. Naming a foreign key means removing the inline .references() or drizzle creates both. Co-Authored-By: Claude Opus 5 --- db/schema.ts | 62 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/db/schema.ts b/db/schema.ts index 1ef5369..489db05 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core'; +import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex, foreignKey} from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { users } from 'officerdb/auth/schema'; @@ -72,8 +72,7 @@ export const walletLabels = pgTable( { id: serial('id').primaryKey(), walletId: integer('wallet_id') - .notNull() - .references(() => walletWallets.id, { onDelete: 'cascade' }), + .notNull(), // 'address' | 'tx' kind: text('kind').notNull(), // The address string or txid being labelled. @@ -96,14 +95,22 @@ export const walletFrozenUtxos = pgTable( { id: serial('id').primaryKey(), walletId: integer('wallet_id') - .notNull() - .references(() => walletWallets.id, { onDelete: 'cascade' }), + .notNull(), // `txid:vout` outpoint: text('outpoint').notNull(), reason: text('reason'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, - (t) => [uniqueIndex('uq_bitcoin_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)], + (t) => [ + // Named, because drizzle's generated name exceeds Postgres's 63-character identifier limit. + // Postgres stores the truncated form, drizzle keeps comparing against its own longer one, and + // every `db:push` drops and re-adds an identical constraint — on every host, for ever. + foreignKey({ + name: 'fk_bitcoin_wallet_frozen_utxos_wallet', + columns: [t.walletId], + foreignColumns: [walletWallets.id], + }).onDelete('cascade'), + uniqueIndex('uq_bitcoin_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)], ); /** @@ -157,19 +164,30 @@ export type WalletChainSnapshot = { }[]; }; -export const walletChainCache = pgTable('bitcoin_wallet_chain_cache', { - // One row per wallet, so a refresh is a single atomic upsert with no partially-written window. - walletId: integer('wallet_id') - .primaryKey() - .references(() => walletWallets.id, { onDelete: 'cascade' }), - // Nullable: a wallet whose very first refresh failed has an error to report and no data to report it - // against, and that row still has to exist for the UI to say why the screen is empty. - snapshot: jsonb('snapshot').$type(), - /** When the chain was last read successfully — the age the UI shows. Not when the row was written. */ - syncedAt: timestamp('synced_at', { withTimezone: true }), - // Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would - // reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance - // instead of as a stale one. - lastError: text('last_error'), - lastErrorAt: timestamp('last_error_at', { withTimezone: true }), -}); +export const walletChainCache = pgTable( + 'bitcoin_wallet_chain_cache', + { + // One row per wallet, so a refresh is a single atomic upsert with no partially-written window. + walletId: integer('wallet_id').primaryKey(), + // Nullable: a wallet whose very first refresh failed has an error to report and no data to report it + // against, and that row still has to exist for the UI to say why the screen is empty. + snapshot: jsonb('snapshot').$type(), + /** When the chain was last read successfully — the age the UI shows. Not when the row was written. */ + syncedAt: timestamp('synced_at', { withTimezone: true }), + // Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would + // reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance + // instead of as a stale one. + lastError: text('last_error'), + lastErrorAt: timestamp('last_error_at', { withTimezone: true }), + }, + (t) => [ + // Named, because drizzle's generated name is 65 characters and Postgres truncates at 63 — it stores + // the short form, drizzle compares against its own long one, and every push drops and re-adds an + // identical constraint. Same fix as `fk_bitcoin_wallet_frozen_utxos_wallet` above. + foreignKey({ + name: 'fk_bitcoin_wallet_chain_cache_wallet', + columns: [t.walletId], + foreignColumns: [walletWallets.id], + }).onDelete('cascade'), + ], +);