Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/workspace-server/src/db/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
196 changes: 196 additions & 0 deletions packages/workspace-server/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -60,4 +62,198 @@ export function runMigrations(
});

applyPending();
ensureBrowserTabsSchema(sqlite, migrationsFolder);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Heal Can Skip Production Snapshots

runMigrations now depends on the passed migration folder containing meta/_journal.json, but the production database service path is db-migrations while the checked-in folder used by tests is migrations. If that production path does not include the Drizzle meta snapshots, latestSnapshotTables returns null, this new boot-time heal silently does nothing, and the reported browser-tabs schema drift still reaches the ORM load at startup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false positive, no fix: copyDrizzleMigrations (apps/code/vite-main-plugins.mts:483) does cpSync of the entire migrations dir recursively, meta/ included — confirmed present in .vite/build/db-migrations/meta. And readMigrationFiles already requires the same meta/_journal.json, so any folder migrations can run from has the snapshot too

}

/**
* 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<string, SnapshotColumn>;
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<string, SnapshotTable> | 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<string, SnapshotTable> };
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.
}
}
Loading