refactor: replace custom migrate.ts with standard Drizzle migrator

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:12:01 -04:00
parent 960f0c2d17
commit d2f39bfc35
2 changed files with 3 additions and 253 deletions
+1 -1
View File
@@ -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",
+2 -252
View File
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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) {