mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(backup): migrate restored files before swapping them in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+108
-7
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user