#!/usr/bin/env bun /** * migrate-db.ts — Migrate AGOR data from a Tauri (source) database to an * Electrobun (target) database using the canonical schema. * * Usage: * bun tools/migrate-db.ts --from --to * bun tools/migrate-db.ts --from ~/.local/share/agor/sessions.db \ * --to ~/.config/agor/settings.db * * Behavior: * - Opens source DB read-only (never modifies it). * - Creates/opens target DB, applies canonical.sql if schema_version absent. * - Copies rows for every table present in BOTH source and target. * - Wraps all inserts in a single transaction (atomic rollback on failure). * - Reports per-table row counts. * - Writes migration fence to schema_version in target. */ import { Database } from "bun:sqlite"; import { readFileSync, existsSync } from "fs"; import { join, resolve } from "path"; // ── CLI args ────────────────────────────────────────────────────────────────── function usage(): never { console.error("Usage: bun tools/migrate-db.ts --from --to "); process.exit(1); } const args = process.argv.slice(2); let fromPath = ""; let toPath = ""; for (let i = 0; i < args.length; i++) { if (args[i] === "--from" && args[i + 1]) fromPath = args[++i]; else if (args[i] === "--to" && args[i + 1]) toPath = args[++i]; else if (args[i] === "--help" || args[i] === "-h") usage(); } if (!fromPath || !toPath) usage(); fromPath = resolve(fromPath); toPath = resolve(toPath); if (!existsSync(fromPath)) { console.error(`Source database not found: ${fromPath}`); process.exit(1); } // ── Load canonical DDL ──────────────────────────────────────────────────────── const schemaPath = join(import.meta.dir, "..", "schema", "canonical.sql"); let ddl: string; try { ddl = readFileSync(schemaPath, "utf-8"); } catch (err) { console.error(`Failed to read canonical schema: ${err}`); process.exit(1); } // ── Open databases ──────────────────────────────────────────────────────────── const sourceDb = new Database(fromPath, { readonly: true }); const targetDb = new Database(toPath); // Apply pragmas to target targetDb.exec("PRAGMA journal_mode = WAL"); targetDb.exec("PRAGMA foreign_keys = OFF"); // Disable during migration for insert order flexibility targetDb.exec("PRAGMA busy_timeout = 5000"); // Apply canonical schema to target if needed const hasVersion = (() => { try { const row = targetDb .query<{ cnt: number }, []>("SELECT COUNT(*) AS cnt FROM schema_version") .get(); return (row?.cnt ?? 0) > 0; } catch { return false; } })(); if (!hasVersion) { console.log("Applying canonical schema to target database..."); targetDb.exec(ddl); } // ── Discover migratable tables ──────────────────────────────────────────────── /** Get regular (non-virtual, non-internal) table names from a database. */ function getTableNames(db: Database): Set { const rows = db .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_content' AND name NOT LIKE '%_data' AND name NOT LIKE '%_idx' AND name NOT LIKE '%_config' AND name NOT LIKE '%_docsize' ORDER BY name`, ) .all() as Array<{ name: string }>; return new Set(rows.map((r) => r.name)); } const sourceTables = getTableNames(sourceDb); const targetTables = getTableNames(targetDb); // Only migrate tables present in both source and target const migratable = [...sourceTables].filter((t) => targetTables.has(t)); if (migratable.length === 0) { console.log("No overlapping tables found between source and target."); sourceDb.close(); targetDb.close(); process.exit(0); } console.log(`\nMigrating ${migratable.length} tables from:`); console.log(` source: ${fromPath}`); console.log(` target: ${toPath}\n`); // ── Migrate data ────────────────────────────────────────────────────────────── interface MigrationResult { table: string; rows: number; skipped: boolean; error?: string; } const results: MigrationResult[] = []; const migrate = targetDb.transaction(() => { for (const table of migratable) { // Skip schema_version — we write our own fence if (table === "schema_version") { results.push({ table, rows: 0, skipped: true }); continue; } try { // Read all rows from source const rows = sourceDb.prepare(`SELECT * FROM "${table}"`).all(); if (rows.length === 0) { results.push({ table, rows: 0, skipped: false }); continue; } // Get column names from the first row const columns = Object.keys(rows[0] as Record); const placeholders = columns.map(() => "?").join(", "); const colList = columns.map((c) => `"${c}"`).join(", "); const insertStmt = targetDb.prepare( `INSERT OR IGNORE INTO "${table}" (${colList}) VALUES (${placeholders})`, ); let count = 0; for (const row of rows) { const values = columns.map((c) => (row as Record)[c] ?? null); insertStmt.run(...values); count++; } results.push({ table, rows: count, skipped: false }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); results.push({ table, rows: 0, skipped: false, error: msg }); throw new Error(`Migration failed on table '${table}': ${msg}`); } } }); try { migrate(); } catch (err) { console.error(`\nMIGRATION ROLLED BACK: ${err}`); sourceDb.close(); targetDb.close(); process.exit(1); } // ── Write version fence ─────────────────────────────────────────────────────── const timestamp = new Date().toISOString(); targetDb.exec("DELETE FROM schema_version"); targetDb .prepare( "INSERT INTO schema_version (version, migration_source, migration_timestamp) VALUES (?, ?, ?)", ) .run(1, "migrate-db", timestamp); // Re-enable foreign keys targetDb.exec("PRAGMA foreign_keys = ON"); // ── Report ──────────────────────────────────────────────────────────────────── console.log("Table Rows Status"); console.log("─".repeat(50)); let totalRows = 0; for (const r of results) { const status = r.skipped ? "skipped" : r.error ? `ERROR: ${r.error}` : "ok"; const rowStr = String(r.rows).padStart(8); console.log(`${r.table.padEnd(25)}${rowStr} ${status}`); totalRows += r.rows; } console.log("─".repeat(50)); console.log(`Total: ${totalRows} rows migrated across ${results.filter((r) => !r.skipped && !r.error).length} tables`); console.log(`Version fence: v1 at ${timestamp}`); sourceDb.close(); targetDb.close();