From 1f76cf38a73325fe6752530f481b76e81bf25b27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 03:11:43 +0000 Subject: [PATCH] Fix migrate: remove bogus tracking entries from broken baseline The previous baseline blindly recorded all migrations as applied. Now on startup the script validates every recorded migration against the actual schema; any entry whose schema changes don't exist is deleted so migrate() will re-run that migration. This unblocks the existing deployment where 0001 was recorded as done but beenvoice_client.currency was never actually added. https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi --- src/server/db/migrate.ts | 133 +++++++++++++++++++++------------------ 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index fe9e0d7..973929b 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -43,48 +43,16 @@ const pool = new Pool({ const db = drizzle(pool); /** - * Baseline: if the DB has existing tables but no migration history, seed the - * __drizzle_migrations table for only the migrations already reflected in the - * schema. Any migrations whose schema changes are NOT yet present will be left - * out so Drizzle runs them normally. + * Verify and repair the migration tracking table: + * 1. If no tracking table exists and DB has tables → baseline from db:push + * 2. If tracking table exists → scan for any entries that are recorded as + * applied but whose schema changes don't actually exist, and remove them + * so migrate() will re-run those migrations. */ async function baselineIfNeeded(client: Pool) { - // Check if migration tracking table exists and has entries - const { rows: migRows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count - FROM information_schema.tables - WHERE table_schema = 'drizzle' - AND table_name = '__drizzle_migrations' - `); - const hasMigrationsTable = parseInt(migRows[0]?.count ?? "0") > 0; + const hasMigrationsTable = await tableExists(client, "drizzle", "__drizzle_migrations"); - if (hasMigrationsTable) { - const { rows: entryRows } = await client.query<{ count: string }>( - `SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations` - ); - if (parseInt(entryRows[0]?.count ?? "0") > 0) { - // Migration history exists — normal flow - return; - } - } - - // No migration history. Check if the DB already has our core tables (was db:push'd). - const { rows: tableRows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'beenvoice_account' - `); - const dbAlreadyExists = parseInt(tableRows[0]?.count ?? "0") > 0; - - if (!dbAlreadyExists) { - // Fresh database — let migrate() run all SQL normally - return; - } - - console.log("[migrate] Existing database detected without migration history — baselining..."); - - // Create the drizzle schema + migrations table if needed + // Always ensure the drizzle schema + table exist await client.query(`CREATE SCHEMA IF NOT EXISTS drizzle`); await client.query(` CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( @@ -94,50 +62,96 @@ async function baselineIfNeeded(client: Pool) { ) `); - // For each migration, check whether its schema changes already exist in the DB. - // Only seed a record for migrations that are fully applied; leave the rest for - // migrate() to run. + const { rows: entryRows } = await client.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM drizzle.__drizzle_migrations` + ); + const hasEntries = parseInt(entryRows[0]?.count ?? "0") > 0; + + if (!hasMigrationsTable || !hasEntries) { + // No history at all — check if DB was previously set up via db:push + const dbAlreadyExists = await tableExists(client, "public", "beenvoice_account"); + if (!dbAlreadyExists) { + return; // Fresh DB — let migrate() run everything normally + } + + console.log("[migrate] Existing database detected without migration history — baselining..."); + await seedMigrationHistory(client); + return; + } + + // Migration history exists — validate that each recorded migration is + // actually reflected in the schema. Remove any bogus entries. + await removeBogusEntries(client); +} + +async function seedMigrationHistory(client: Pool) { const journal = JSON.parse( fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8") ) as { entries: { idx: number; tag: string; when: number }[] }; for (const entry of journal.entries) { - const alreadyApplied = await isMigrationApplied(client, entry.tag); - if (!alreadyApplied) { - console.log(`[migrate] Not yet applied, will run: ${entry.tag}`); + const applied = await isMigrationApplied(client, entry.tag); + if (!applied) { + console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`); continue; } - - const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`); - const sql = fs.readFileSync(sqlPath, "utf8"); + const sql = fs.readFileSync( + path.join(migrationsFolder, `${entry.tag}.sql`), "utf8" + ); const hash = crypto.createHash("sha256").update(sql).digest("hex"); - await client.query( `INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)`, [hash, entry.when] ); console.log(`[migrate] Baselined: ${entry.tag}`); } - console.log("[migrate] Baseline complete"); } +async function removeBogusEntries(client: Pool) { + // Get all recorded hashes + const { rows } = await client.query<{ id: number; hash: string }>( + `SELECT id, hash FROM drizzle.__drizzle_migrations ORDER BY id` + ); + + const journal = JSON.parse( + fs.readFileSync(path.join(migrationsFolder, "meta/_journal.json"), "utf8") + ) as { entries: { idx: number; tag: string; when: number }[] }; + + for (const entry of journal.entries) { + const sql = fs.readFileSync( + path.join(migrationsFolder, `${entry.tag}.sql`), "utf8" + ); + const expectedHash = crypto.createHash("sha256").update(sql).digest("hex"); + const recorded = rows.find((r) => r.hash === expectedHash); + if (!recorded) continue; // Not recorded yet — migrate() will run it + + // It's recorded — verify it's actually applied in the schema + const applied = await isMigrationApplied(client, entry.tag); + if (!applied) { + console.log(`[migrate] Removing bogus migration record for: ${entry.tag}`); + await client.query(`DELETE FROM drizzle.__drizzle_migrations WHERE id = $1`, [recorded.id]); + } + } +} + +async function tableExists(client: Pool, schema: string, table: string): Promise { + const { rows } = await client.query<{ count: string }>(` + SELECT COUNT(*)::text AS count FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 + `, [schema, table]); + return parseInt(rows[0]?.count ?? "0") > 0; +} + /** * Check whether a specific migration's schema changes already exist in the DB. - * Each migration tag maps to a sentinel check that uniquely identifies it. */ async function isMigrationApplied(client: Pool, tag: string): Promise { if (tag === "0000_glossy_magneto") { - // 0000 creates beenvoice_account — check it exists - const { rows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = 'beenvoice_account' - `); - return parseInt(rows[0]?.count ?? "0") > 0; + return tableExists(client, "public", "beenvoice_account"); } - if (tag === "0001_supreme_the_enforcers") { - // 0001 adds currency column to beenvoice_client — check it exists + // 0001 adds currency to beenvoice_client const { rows } = await client.query<{ count: string }>(` SELECT COUNT(*)::text AS count FROM information_schema.columns WHERE table_schema = 'public' @@ -146,7 +160,6 @@ async function isMigrationApplied(client: Pool, tag: string): Promise { `); return parseInt(rows[0]?.count ?? "0") > 0; } - // Unknown migration — assume not applied so it runs return false; }