diff --git a/scripts/validate-migrations.ts b/scripts/validate-migrations.ts index f86aa65..a91e1cd 100644 --- a/scripts/validate-migrations.ts +++ b/scripts/validate-migrations.ts @@ -3,6 +3,10 @@ import { Database } from "bun:sqlite"; import { readFileSync } from "fs"; import path from "path"; +import { + repairDuplicateSsoColumns, + restoreSsoDataAfter0013, +} from "../src/lib/db/migration-repairs"; type JournalEntry = { idx: number; @@ -290,6 +294,100 @@ function verify0013Migration(db: any) { assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`); } +const MIGRATION_0012_TIMESTAMP = 1774062000000; +const MIGRATION_0013_TIMESTAMP = 1780377747526; + +/** + * Reproduce the issue #312 crash state — sso_providers already carries + * saml_config / domain_verified before migration 0013 runs (stranded on an + * intermediate build), with __drizzle_migrations recorded only through 0012 — + * and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the + * canonical 0013 run while preserving real SAML provider data. + */ +function validateBroken0013Repair() { + const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus"); + if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test. + + const db = new Database(":memory:"); + try { + runMigrations(db, migrations.slice(0, 13)); // 0000-0012 + + // A real upgraded instance has a __drizzle_migrations table recorded + // through 0012 but not 0013. + db.run( + "CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)", + ); + db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [ + MIGRATION_0012_TIMESTAMP, + ]); + + // Stranded columns from the intermediate build. + db.run("ALTER TABLE sso_providers ADD saml_config text"); + db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL"); + + db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')"); + const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}'; + db.run( + "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)", + ); + db.run( + "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)", + [samlJson], + ); + db.run( + "INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)", + ); + + const preserved = repairDuplicateSsoColumns(db); + + const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map( + (c) => c.name, + ); + assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column"); + assert( + !colsAfterRepair.includes("domain_verified"), + "Expected repair to drop stranded domain_verified column", + ); + const preservedIds = preserved.map((r) => r.id).sort(); + assert( + preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1", + `Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`, + ); + + // The canonical 0013 must now run without a duplicate-column error. + runMigration(db, migration0013); + restoreSsoDataAfter0013(db, preserved); + + const rows = db + .query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id") + .all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>; + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL"); + assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1"); + assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved"); + assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1"); + assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL"); + assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved"); + + // Idempotency: 0013 is now applied, so a re-run of the repair is a no-op. + db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [ + MIGRATION_0013_TIMESTAMP, + ]); + const secondPass = repairDuplicateSsoColumns(db); + assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded"); + const colsAfterSecondPass = ( + db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[] + ).map((c) => c.name); + assert( + colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"), + "Expected columns to remain intact on the no-op second pass", + ); + } finally { + db.close(); + } +} + const latestUpgradeFixtures: Record = { "0009_nervous_tyger_tiger": { seed: seedPre0009Database, @@ -361,8 +459,11 @@ function validateMigrations() { upgradeDb.close(); } + // Exercise the runtime repair for the issue #312 duplicate-column crash. + validateBroken0013Repair(); + console.log( - `Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`, + `Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair.`, ); } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 94d00b7..60f927d 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite"; import fs from "fs"; import path from "path"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { repairDuplicateSsoColumns, restoreSsoDataAfter0013 } from "./migration-repairs"; // Skip database initialization in test environment let db: ReturnType; @@ -95,9 +96,17 @@ if (process.env.NODE_ENV !== "test") { // Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug) repairFailedMigrations(); + // Fix the v3.17.0 duplicate-column crash: reconcile stranded sso_providers + // columns so migration 0013 can run (see #312). Returns data to re-apply + // once 0013 has re-added the columns. + const preservedSsoData = repairDuplicateSsoColumns(sqlite); + // Run migrations using Drizzle migrate function migrate(db, { migrationsFolder: "./drizzle" }); + // Re-apply any SSO provider data preserved by the repair above. + restoreSsoDataAfter0013(sqlite, preservedSsoData); + console.log("✅ Database migrations completed successfully"); } catch (error) { console.error("❌ Error running migrations:", error); diff --git a/src/lib/db/migration-repairs.ts b/src/lib/db/migration-repairs.ts new file mode 100644 index 0000000..afdd988 --- /dev/null +++ b/src/lib/db/migration-repairs.ts @@ -0,0 +1,117 @@ +import type { Database } from "bun:sqlite"; + +/** + * Pre-migration repairs that reconcile a database into the exact shape Drizzle's + * migrator expects, so a previously-failed migration can complete on the next + * boot. These run BEFORE `migrate()` and are deliberately defensive: any failure + * is logged and swallowed so they never make a recoverable database worse. + */ + +/** Migration 0013 journal timestamp (from drizzle/meta/_journal.json, idx 13). */ +const MIGRATION_0013_TIMESTAMP = 1780377747526; + +export type PreservedSsoRow = { + id: string; + saml_config?: string | null; + domain_verified?: number; +}; + +/** + * Repair the v3.17.0 (PR #307) "duplicate column name: saml_config" crash loop + * reported in issue #312. + * + * Some instances ended up with `sso_providers.saml_config` / `domain_verified` + * already present BEFORE migration 0013 ran — the columns were declared in + * schema.ts and entered the DB via `db:push` or an SSO-register round-trip on an + * intermediate build, while `__drizzle_migrations` never recorded a 0013 row. + * + * Migration 0013 runs as a single transaction (organizations rebuild + the two + * `ALTER TABLE sso_providers ADD ...`). The ADD hits the pre-existing column, + * throws "duplicate column", and rolls back the ENTIRE transaction — so 0013 is + * never recorded and is retried, failing identically, on every boot. + * + * This is the mirror image of the 0009 repair in index.ts (record present, + * column missing): here the column is present but the record is missing. We + * reconcile `sso_providers` back to its true pre-0013 shape so the canonical + * 0013 can run in full (the organizations rebuild MUST NOT be skipped), + * preserving any real SAML provider config across the drop/re-add. + * + * Returns the rows whose values must be re-applied by {@link restoreSsoDataAfter0013} + * once 0013 has re-added the columns. Returns an empty array when there is + * nothing to do (fresh install, clean upgrade, or genuine pre-0013 shape). + */ +export function repairDuplicateSsoColumns(sqlite: Database): PreservedSsoRow[] { + try { + const migrationsTableExists = sqlite + .query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'") + .get(); + + // Fresh install — no migrations recorded yet, vanilla migrate() handles it. + if (!migrationsTableExists) return []; + + // 0013 already recorded (clean upgrade / already healed) — nothing to do. + const alreadyApplied = sqlite + .query("SELECT 1 FROM __drizzle_migrations WHERE created_at >= ? LIMIT 1") + .get(MIGRATION_0013_TIMESTAMP); + if (alreadyApplied) return []; + + const ssoExists = sqlite + .query("SELECT name FROM sqlite_master WHERE type='table' AND name='sso_providers'") + .get(); + if (!ssoExists) return []; + + const cols = sqlite.query("PRAGMA table_info(sso_providers)").all() as { name: string }[]; + const hasSaml = cols.some((c) => c.name === "saml_config"); + const hasDomainVerified = cols.some((c) => c.name === "domain_verified"); + + // Genuine pre-0013 shape — let migration 0013 add the columns as-is. + if (!hasSaml && !hasDomainVerified) return []; + + console.log( + "🔧 Detected stranded SSO columns (migration 0013 not recorded). Reconciling sso_providers so 0013 can run...", + ); + + // Preserve any real data before dropping. SAML providers store JSON config + // in saml_config; domain_verified may have been explicitly set to false. + const selectCols = ["id"]; + if (hasSaml) selectCols.push("saml_config"); + if (hasDomainVerified) selectCols.push("domain_verified"); + const preserved = sqlite + .query(`SELECT ${selectCols.join(", ")} FROM sso_providers`) + .all() as PreservedSsoRow[]; + + // SQLite >= 3.35 (bun:sqlite ships much newer) supports DROP COLUMN. + if (hasSaml) sqlite.run("ALTER TABLE sso_providers DROP COLUMN saml_config"); + if (hasDomainVerified) sqlite.run("ALTER TABLE sso_providers DROP COLUMN domain_verified"); + + // Only rows whose values differ from the 0013 defaults (saml_config NULL, + // domain_verified true/1) need restoring after the columns are re-added. + return preserved.filter( + (r) => (hasSaml && r.saml_config != null) || (hasDomainVerified && r.domain_verified === 0), + ); + } catch (error) { + console.warn("⚠️ SSO column repair check failed (non-fatal):", error); + return []; + } +} + +/** + * Re-apply the SSO provider values preserved by {@link repairDuplicateSsoColumns} + * once migration 0013 has re-added saml_config / domain_verified with their + * defaults (saml_config NULL, domain_verified = 1). No-op when nothing was + * preserved (the common OIDC-only case). + */ +export function restoreSsoDataAfter0013(sqlite: Database, preserved: PreservedSsoRow[]): void { + if (preserved.length === 0) return; + try { + const stmt = sqlite.prepare( + "UPDATE sso_providers SET saml_config = ?, domain_verified = ? WHERE id = ?", + ); + for (const r of preserved) { + stmt.run(r.saml_config ?? null, r.domain_verified ?? 1, r.id); + } + console.log(`✅ Restored ${preserved.length} preserved SSO provider value(s) after migration 0013.`); + } catch (error) { + console.warn("⚠️ Failed to restore preserved SSO data (non-fatal):", error); + } +}