From d2f39bfc358e1937a11caeb139af79298f50f58f Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sat, 6 Jun 2026 18:12:01 -0400 Subject: [PATCH] refactor: replace custom migrate.ts with standard Drizzle migrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline/bogus-entry logic was a one-time workaround for a db:push → migration transition that is long past. It required a per-migration isMigrationApplied case — missing cases caused today's invoiceId bug. Now both db:migrate (dev) and Docker startup use the same standard migrate() call. Drizzle's own tracking table handles idempotency. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/server/db/migrate.ts | 254 +-------------------------------------- 2 files changed, 3 insertions(+), 253 deletions(-) diff --git a/package.json b/package.json index b5af7ac..f535165 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "check": "eslint . && tsc --noEmit", "db:generate": "drizzle-kit generate", - "db:migrate": "bun drizzle-kit migrate", + "db:migrate": "bun src/server/db/migrate.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:clone": "./scripts/clone-local.sh", diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 18399a8..4d62fc2 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -1,19 +1,10 @@ /** - * Programmatic migration runner for production deployments. - * + * Production migration runner. Applies any pending Drizzle migrations. * Run with: bun src/server/db/migrate.ts - * - * This applies any pending migrations from the drizzle/ directory to the - * database specified by DATABASE_URL. It is safe to run multiple times — - * Drizzle tracks applied migrations in the __drizzle_migrations table. - * - * If the database was previously set up via `db:push` (no migration history), - * this script will baseline it: seed the migration history without re-running - * the SQL, so only future migrations are applied. + * Safe to run multiple times — Drizzle tracks applied migrations in drizzle.__drizzle_migrations. */ import * as dotenv from "dotenv"; -// Load env files before importing anything that reads process.env dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env" }); @@ -21,8 +12,6 @@ import { Pool } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; import { migrate } from "drizzle-orm/node-postgres/migrator"; import path from "path"; -import fs from "fs"; -import crypto from "crypto"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { @@ -43,248 +32,9 @@ const pool = new Pool({ const db = drizzle(pool); -/** - * 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) { - const hasMigrationsTable = await tableExists( - client, - "drizzle", - "__drizzle_migrations", - ); - - // 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 ( - id SERIAL PRIMARY KEY, - hash text NOT NULL, - created_at bigint - ) - `); - - 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 applied = await isMigrationApplied(client, entry.tag); - if (!applied) { - console.log(`[migrate] Not yet in schema, will run: ${entry.tag}`); - continue; - } - 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. - */ -async function isMigrationApplied(client: Pool, tag: string): Promise { - if (tag === "0000_glossy_magneto") { - return tableExists(client, "public", "beenvoice_account"); - } - if (tag === "0001_supreme_the_enforcers") { - // 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' - AND table_name = 'beenvoice_client' - AND column_name = 'currency' - `); - return parseInt(rows[0]?.count ?? "0") > 0; - } - if (tag === "0002_tax_deductible") { - // 0002 adds taxDeductible to beenvoice_expense - const { rows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'beenvoice_expense' - AND column_name = 'taxDeductible' - `); - return parseInt(rows[0]?.count ?? "0") > 0; - } - if (tag === "0003_appearance_preferences") { - // 0003 adds appearance preferences to beenvoice_user - const { rows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'beenvoice_user' - AND column_name = 'interfaceTheme' - `); - return parseInt(rows[0]?.count ?? "0") > 0; - } - if (tag === "0004_platform_appearance_controls") { - // 0004 adds platform-level appearance controls to beenvoice_user - const { rows } = await client.query<{ count: string }>(` - SELECT COUNT(*)::text AS count FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'beenvoice_user' - AND column_name = 'sidebarStyle' - `); - return parseInt(rows[0]?.count ?? "0") > 0; - } - if (tag === "0005_platform_settings_and_roles") { - const hasRole = await columnExists( - client, - "public", - "beenvoice_user", - "role", - ); - const hasPlatformSettings = await tableExists( - client, - "public", - "beenvoice_platform_setting", - ); - return hasRole && hasPlatformSettings; - } - if (tag === "0006_pdf_generation_settings") { - return columnExists( - client, - "public", - "beenvoice_platform_setting", - "pdfTemplate", - ); - } - if (tag === "0007_invoice_email_message") { - return columnExists(client, "public", "beenvoice_invoice", "emailMessage"); - } - if (tag === "0008_payments_recurring_public_links") { - return columnExists(client, "public", "beenvoice_invoice", "publicToken"); - } - if (tag === "0009_api_keys") { - return tableExists(client, "public", "beenvoice_api_key"); - } - if (tag === "0010_time_entries") { - return tableExists(client, "public", "beenvoice_time_entry"); - } - if (tag === "0011_time_entry_invoice_id") { - return columnExists(client, "public", "beenvoice_time_entry", "invoiceId"); - } - if (tag === "0012_verification_token_value_text") { - const { rows } = await client.query<{ data_type: string }>(` - SELECT data_type FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'beenvoice_verification_token' - AND column_name = 'value' - `); - return rows[0]?.data_type === "text"; - } - // Unknown migration — assume not applied so it runs - return false; -} - -async function columnExists( - client: Pool, - schema: string, - table: string, - column: string, -): Promise { - const { rows } = await client.query<{ count: string }>( - ` - SELECT COUNT(*)::text AS count FROM information_schema.columns - WHERE table_schema = $1 AND table_name = $2 AND column_name = $3 - `, - [schema, table, column], - ); - return parseInt(rows[0]?.count ?? "0") > 0; -} - console.log("[migrate] Running migrations from", migrationsFolder); try { - await baselineIfNeeded(pool); await migrate(db, { migrationsFolder }); console.log("[migrate] All migrations applied successfully"); } catch (err) {