diff --git a/packages/workspace-server/src/db/migrate.test.ts b/packages/workspace-server/src/db/migrate.test.ts index 279aa23140..79bc038bb1 100644 --- a/packages/workspace-server/src/db/migrate.test.ts +++ b/packages/workspace-server/src/db/migrate.test.ts @@ -175,6 +175,77 @@ describe("runMigrations", () => { expect(hasColumn(sqlite, "browser_tabs", "app_view")).toBe(true); }); + // The one-shot repair can't help once it's in the ledger: a panes-branch + // build that runs AFTER 0020 rebuilds browser_tabs without its target + // columns, and nothing pending remains to heal it. The always-run + // ensureBrowserTabsSchema pass must restore the columns anyway. + it("heals a panes rebuild that postdates the 0020 repair", () => { + runMigrations(sqlite, MIGRATIONS_FOLDER); + // The panes build's table rebuild: targets move to browser_panes and + // the recreated browser_tabs drops every target column. + sqlite.exec(` + DROP TABLE browser_tabs; + CREATE TABLE browser_tabs (id text PRIMARY KEY NOT NULL, + window_id text NOT NULL, position integer NOT NULL, + created_at integer NOT NULL, last_active_at integer NOT NULL, + pane_id text, layout text, focused_pane_id text, + FOREIGN KEY (window_id) REFERENCES browser_windows(id) ON DELETE cascade); + CREATE TABLE browser_panes (id text PRIMARY KEY NOT NULL, + tab_id text NOT NULL, window_id text NOT NULL, dashboard_id text, + FOREIGN KEY (tab_id) REFERENCES browser_tabs(id) ON DELETE cascade); + `); + expect(hasColumn(sqlite, "browser_tabs", "dashboard_id")).toBe(false); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + for (const column of [ + "dashboard_id", + "task_id", + "channel_id", + "channel_section", + "app_view", + "scroll_state", + ]) { + expect(hasColumn(sqlite, "browser_tabs", column)).toBe(true); + } + // The other branch's additions survive at the schema level (its tables + // and columns are left alone; row-level fate is up to that branch). + expect(hasColumn(sqlite, "browser_tabs", "pane_id")).toBe(true); + expect(hasColumn(sqlite, "browser_panes", "tab_id")).toBe(true); + }); + + it("rebuilds a variant missing a structural NOT NULL column", () => { + runMigrations(sqlite, MIGRATIONS_FOLDER); + // A variant without window_id can't be healed additively — ALTER would + // have to fabricate a default, stamping rows with dangling '' FKs. + sqlite.exec(` + DROP TABLE browser_tabs; + CREATE TABLE browser_tabs (id text PRIMARY KEY NOT NULL, + position integer NOT NULL, created_at integer NOT NULL, + last_active_at integer NOT NULL); + `); + + expect(() => runMigrations(sqlite, MIGRATIONS_FOLDER)).not.toThrow(); + expect(hasColumn(sqlite, "browser_tabs", "window_id")).toBe(true); + expect(hasColumn(sqlite, "browser_tabs", "dashboard_id")).toBe(true); + const rows = sqlite + .prepare("SELECT COUNT(*) AS count FROM browser_tabs") + .get() as { count: number }; + expect(rows.count).toBe(0); + }); + + it("does not inject browser tables into a custom-folder database", () => { + const dir = writeTempMigration("CREATE TABLE `unrelated` (`id` text);"); + try { + expect(() => runMigrations(sqlite, dir)).not.toThrow(); + const table = sqlite + .prepare("SELECT type FROM sqlite_master WHERE name = 'browser_tabs'") + .get(); + expect(table).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("tolerates arbitrary statement failures in a best-effort migration", () => { // An unforeseen divergence must degrade to "still broken", never a // failed migration batch that kills boot. diff --git a/packages/workspace-server/src/db/migrate.ts b/packages/workspace-server/src/db/migrate.ts index ba6acc1b9d..6910d0eaf0 100644 --- a/packages/workspace-server/src/db/migrate.ts +++ b/packages/workspace-server/src/db/migrate.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; import type Database from "better-sqlite3"; import { readMigrationFiles } from "drizzle-orm/migrator"; @@ -60,4 +62,198 @@ export function runMigrations( }); applyPending(); + ensureBrowserTabsSchema(sqlite, migrationsFolder); +} + +/** + * Tables the post-migration heal covers. Deliberately only the browser-tab + * strip's tables: they hold recoverable UI state, so the worst outcome of a + * heal (or a rebuild) is an empty strip. Data-bearing tables are excluded on + * purpose — fabricating defaults or rebuilding them would destroy real data, + * and their drift should stay loud. + */ +const HEALED_TABLES = ["browser_windows", "browser_tabs"]; + +/** The subset of a drizzle meta snapshot the heal reads. */ +interface SnapshotColumn { + name: string; + type: string; + primaryKey: boolean; + notNull: boolean; + default?: unknown; +} +interface SnapshotTable { + name: string; + columns: Record; + indexes: Record< + string, + { name: string; columns: string[]; isUnique: boolean } + >; + foreignKeys: Record< + string, + { + tableFrom: string; + tableTo: string; + columnsFrom: string[]; + columnsTo: string[]; + onDelete?: string; + onUpdate?: string; + } + >; +} + +/** + * The expected shape of the healed tables comes from the migration folder's + * latest drizzle meta snapshot — the same machine-generated source of truth + * drizzle-kit maintains alongside every migration. Deriving it (rather than + * hardcoding DDL here) means a future migration that adds, drops, or moves a + * column updates the heal automatically: hardcoded DDL would silently stop + * covering new columns, and worse, would resurrect columns a later migration + * deliberately dropped, on the same boot that dropped them. + * + * Returns null when the folder has no snapshot (e.g. a caller migrating an + * unrelated DB with its own folder) — the heal then does nothing. + */ +function latestSnapshotTables( + migrationsFolder: string, +): Record | null { + try { + const journal = JSON.parse( + readFileSync( + path.join(migrationsFolder, "meta", "_journal.json"), + "utf8", + ), + ) as { entries: { idx: number }[] }; + const last = journal.entries.at(-1); + if (!last) return null; + const snapshot = JSON.parse( + readFileSync( + path.join( + migrationsFolder, + "meta", + `${String(last.idx).padStart(4, "0")}_snapshot.json`, + ), + "utf8", + ), + ) as { tables: Record }; + return snapshot.tables; + } catch { + return null; + } +} + +function columnDdl(column: SnapshotColumn): string { + let ddl = `\`${column.name}\` ${column.type}`; + if (column.primaryKey) ddl += " PRIMARY KEY"; + // Snapshot defaults are already SQL-ready (booleans/numbers literal, strings + // pre-quoted by drizzle-kit). + if (column.default !== undefined) ddl += ` DEFAULT ${column.default}`; + if (column.notNull) ddl += " NOT NULL"; + return ddl; +} + +function createTableDdl(table: SnapshotTable): string { + const columns = Object.values(table.columns).map(columnDdl); + const foreignKeys = Object.values(table.foreignKeys).map( + (fk) => + `FOREIGN KEY (${fk.columnsFrom.map((c) => `\`${c}\``).join(", ")}) ` + + `REFERENCES \`${fk.tableTo}\`(${fk.columnsTo.map((c) => `\`${c}\``).join(", ")})` + + ` ON UPDATE ${fk.onUpdate ?? "no action"} ON DELETE ${fk.onDelete ?? "no action"}`, + ); + return `CREATE TABLE IF NOT EXISTS \`${table.name}\` (\n\t${[...columns, ...foreignKeys].join(",\n\t")}\n)`; +} + +function createIndexDdls(table: SnapshotTable): string[] { + return Object.values(table.indexes).map( + (ix) => + `CREATE ${ix.isUnique ? "UNIQUE " : ""}INDEX IF NOT EXISTS \`${ix.name}\` ` + + `ON \`${table.name}\` (${ix.columns.map((c) => `\`${c}\``).join(", ")})`, + ); +} + +/** + * Boot-time schema invariant for the browser-tabs tables, run after every + * migration pass — deliberately NOT a one-shot migration. Dogfood DBs are + * shared across branches whose builds reshape these tables (the amended 0013 + * variants, the panes branch that rebuilds `browser_tabs` without its target + * columns), and a repair migration can only heal drift that predates it: once + * the ledger records it as applied it never runs again, so a later rebuild by + * another branch leaves every browser-tabs query throwing and the tab strip + * dead (a lone "+" that does nothing). + * + * The heal, per table and checked against sqlite_master/PRAGMA so a healthy + * DB does zero writes: + * - table missing → CREATE it (and its indexes) from the snapshot; + * - a nullable snapshot column missing → ALTER TABLE ADD COLUMN, leaving any + * extra columns another branch added alone, so that branch keeps working + * against the same DB; + * - a NOT NULL / primary-key snapshot column missing → the variant is + * structurally foreign; rebuild (DROP + CREATE) rather than fabricate a + * default that would stamp existing rows with dangling '' references; + * - a non-table (e.g. a VIEW) squatting on the name → skip; DDL can't heal it. + * + * Everything is best-effort: a statement (or the whole pass) that cannot + * apply degrades to the pre-heal status quo, never a boot failure. + */ +function ensureBrowserTabsSchema( + sqlite: SqliteDatabase, + migrationsFolder: string, +): void { + const bestEffort = (statement: string) => { + try { + sqlite.exec(statement); + } catch { + // Degrade to the pre-heal status quo, never a boot failure. + } + }; + + try { + const snapshotTables = latestSnapshotTables(migrationsFolder); + if (!snapshotTables) return; + const objectType = sqlite.prepare( + "SELECT type FROM sqlite_master WHERE name = ?", + ); + + for (const name of HEALED_TABLES) { + const table = Object.values(snapshotTables).find((t) => t.name === name); + if (!table) continue; + + const existing = objectType.get(name) as { type: string } | undefined; + if (existing && existing.type !== "table") continue; + + const createAll = () => { + bestEffort(createTableDdl(table)); + for (const ddl of createIndexDdls(table)) bestEffort(ddl); + }; + if (!existing) { + createAll(); + continue; + } + + const present = new Set( + sqlite + .prepare(`PRAGMA table_info(\`${name}\`)`) + .all() + .map((c) => (c as { name: string }).name), + ); + const missing = Object.values(table.columns).filter( + (c) => !present.has(c.name), + ); + if (missing.length === 0) continue; + + if (missing.some((c) => c.notNull || c.primaryKey)) { + // Structurally foreign variant — rebuild. Tabs are recoverable UI + // state; an empty strip beats rows with fabricated NOT NULL values. + bestEffort(`DROP TABLE IF EXISTS \`${name}\``); + createAll(); + continue; + } + for (const column of missing) { + bestEffort(`ALTER TABLE \`${name}\` ADD COLUMN ${columnDdl(column)}`); + } + for (const ddl of createIndexDdls(table)) bestEffort(ddl); + } + } catch { + // The heal must never turn a working boot into a failed one. + } }