fix(backup): migrate restored files before swapping them in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 22:41:02 -04:00
1 parent 45667f3540
commit 1dc38f91b9
5 files changed
+108 -7

No files matched your search

+17 -4
View File
@@ -1,4 +1,4 @@
import { renameSync, unlinkSync } from "node:fs";
import { copyFileSync, renameSync, unlinkSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
@@ -12,7 +12,7 @@ import {
validateBackupDatabase,
withDatabaseAccessBlocked,
} from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { migrateDatabaseFile, runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
function formatTimestamp(date: Date): string {
@@ -169,6 +169,16 @@ export async function readBackupFile(filename: string): Promise<Buffer | null> {
return Buffer.from(await Bun.file(filePath).arrayBuffer());
}
function moveFileSync(from: string, to: string) {
try {
renameSync(from, to);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
copyFileSync(from, to);
unlinkSync(from);
}
}
export async function restoreFromBackup(source: Buffer | string): Promise<void> {
await withBackupLock(async () => {
await ensureBackupDir();
@@ -181,12 +191,15 @@ export async function restoreFromBackup(source: Buffer | string): Promise<void>
try {
if (typeof source === "string") {
renameSync(source, tempPath);
moveFileSync(source, tempPath);
} else {
await Bun.write(tempPath, source);
}
validateBackupDatabase(tempPath);
log.info("Migrating backup before restore...");
await migrateDatabaseFile(tempPath);
log.info("Creating pre-restore safety backup...");
await createBackupInternal("pre-restore");
@@ -198,7 +211,7 @@ export async function restoreFromBackup(source: Buffer | string): Promise<void>
unlinkIfExistsSync(`${DATABASE_URL}-wal`);
unlinkIfExistsSync(`${DATABASE_URL}-shm`);
// Ensure restored backups from older app versions are brought up-to-date.
// Safety net: the file was already migrated before the swap, so this is normally a no-op.
runMigrations();
});
log.info("Database restored successfully");
+27 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { findMissingBackupTables, REQUIRED_BACKUP_TABLES } from "@sofa/db/backup-tables";
import {
findMissingBackupTables,
isFromNewerVersion,
REQUIRED_BACKUP_TABLES,
} from "@sofa/db/backup-tables";
import { testClient } from "@sofa/test/db";
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
@@ -119,3 +123,25 @@ describe("findMissingBackupTables", () => {
expect(REQUIRED_BACKUP_TABLES.filter((t) => !current.includes(t))).toEqual([]);
});
});
describe("isFromNewerVersion", () => {
test("older backup is accepted", () => {
expect(isFromNewerVersion([1000, 2000], [1000, 2000, 3000])).toBe(false);
});
test("same version is accepted", () => {
expect(isFromNewerVersion([1000, 2000, 3000], [1000, 2000, 3000])).toBe(false);
});
test("newer backup is rejected", () => {
expect(isFromNewerVersion([1000, 4000], [1000, 3000])).toBe(true);
});
test("empty applied list is accepted", () => {
expect(isFromNewerVersion([], [3000])).toBe(false);
});
test("numeric strings are coerced", () => {
expect(isFromNewerVersion(["4000"], [3000])).toBe(true);
});
});
+15
View File
@@ -40,3 +40,18 @@ export function findMissingBackupTables(tableNames: Iterable<string>): string[]
const present = new Set(tableNames);
return REQUIRED_BACKUP_TABLES.filter((table) => !present.has(table));
}
/**
* True when the backup has applied a migration newer than any this build ships —
* i.e. it was taken on a newer Sofa version and would run under code that doesn't know its schema.
*/
export function isFromNewerVersion(
appliedCreatedAt: Iterable<number | string>,
localFolderMillis: Iterable<number>,
): boolean {
const newestLocal = Math.max(0, ...localFolderMillis);
for (const createdAt of appliedCreatedAt) {
if (Number(createdAt) > newestLocal) return true;
}
return false;
}
+16 -1
View File
@@ -1,14 +1,16 @@
import { Database } from "bun:sqlite";
import { AsyncLocalStorage } from "node:async_hooks";
import { closeSync, openSync, readSync } from "node:fs";
import path from "node:path";
import type { Logger } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { readMigrationFiles } from "drizzle-orm/migrator";
import { DATABASE_URL } from "@sofa/config";
import { createLogger } from "@sofa/logger";
import { findMissingBackupTables } from "./backup-tables";
import { findMissingBackupTables, isFromNewerVersion } from "./backup-tables";
const log = createLogger("drizzle");
@@ -155,6 +157,19 @@ export function validateBackupDatabase(filePath: string): void {
if (missing.length > 0) {
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
}
const applied = (
validationDb.query("SELECT created_at FROM __drizzle_migrations").all() as {
created_at: number | string;
}[]
).map((row) => Number(row.created_at));
// Same folder as getMigrationsFolder() in migrate.ts (not importable here: circular).
const local = readMigrationFiles({
migrationsFolder: path.join(import.meta.dir, "../drizzle"),
}).map((m) => m.folderMillis);
if (isFromNewerVersion(applied, local)) {
throw new Error("Backup is from a newer version of Sofa");
}
} finally {
validationDb.close();
}
+33 -1
View File
@@ -1,3 +1,4 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
@@ -8,8 +9,39 @@ import { db } from "./client";
const log = createLogger("db");
export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) {
// A function, not a constant: import.meta.dir is undefined when this module is loaded under Vitest/Node.
export const getMigrationsFolder = () => path.join(import.meta.dir, "../drizzle");
export function runMigrations(migrationsFolder = getMigrationsFolder()) {
log.debug("Running database migrations...");
migrate(db, { migrationsFolder });
log.debug("Database migrations complete");
}
/**
* Apply pending migrations to a SQLite file other than the live database (e.g. a backup
* about to be restored). Throws — leaving the live database untouched — if any fails.
*/
export async function migrateDatabaseFile(
filePath: string,
migrationsFolder = getMigrationsFolder(),
): Promise<void> {
// Imported lazily so this module stays loadable under Vitest/Node (no bun:sqlite there).
const { Database } = await import("bun:sqlite");
const { drizzle } = await import("drizzle-orm/bun-sqlite");
const client = new Database(filePath);
const fileDb = drizzle({ client });
try {
// The file is renamed into place afterwards: force a rollback journal so a backup
// whose header says WAL can't leave -wal/-shm side files next to it.
client.run("PRAGMA journal_mode = DELETE");
client.run("PRAGMA foreign_keys = ON"); // same as the live connection (client.ts getClient)
migrate(fileDb, { migrationsFolder });
} finally {
client.close();
// Opening a WAL-header file can leave an empty -shm behind even after switching modes;
// the connection is closed and the journal is DELETE-mode, so nothing in them matters.
rmSync(`${filePath}-wal`, { force: true });
rmSync(`${filePath}-shm`, { force: true });
}
}