ci: smoke-test backup and restore

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:50:18 -04:00
1 parent a4439dddb1
commit f48da5e6c4
7 files changed
+77 -10

No files matched your search

+3
View File
@@ -42,6 +42,9 @@ jobs:
- name: Run tests
run: bunx turbo run test -- --coverage
- name: Backup/restore smoke test
run: bun run smoke:backup
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v7
with:
@@ -147,7 +147,8 @@ export function BackupSection() {
>
{backup.source === "scheduled" ? (
<IconClock aria-hidden={true} className="size-3.5" />
) : backup.source === "pre-restore" || backup.source === "pre-migration" ? (
) : backup.source === "pre-restore" ||
backup.source === "pre-migration" ? (
<IconShieldCheck aria-hidden={true} className="size-3.5" />
) : (
<IconPointer aria-hidden={true} className="size-3.5" />
+2 -1
View File
@@ -59,7 +59,8 @@
"generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs",
"i18n:extract": "lingui extract",
"i18n:claude": "bun packages/i18n/scripts/claude.ts",
"sync:providers": "bun scripts/sync-tmdb-providers.ts"
"sync:providers": "bun scripts/sync-tmdb-providers.ts",
"smoke:backup": "bun scripts/backup-restore-smoke.ts"
},
"devDependencies": {
"@lingui/cli": "catalog:",
+3 -1
View File
@@ -1045,7 +1045,9 @@ export const BackupSchema = z
createdAt: z.string().describe("When the backup was created (ISO 8601)"),
source: z
.enum(["manual", "scheduled", "pre-restore", "pre-migration"])
.describe("How the backup was created: manual, scheduled, automatic pre-restore, or automatic pre-migration"),
.describe(
"How the backup was created: manual, scheduled, automatic pre-restore, or automatic pre-migration",
),
})
.meta({ id: "Backup", description: "A database backup file" });
+6 -6
View File
@@ -127,14 +127,14 @@ describe("migrations on an older database", () => {
expect(client.pragma("foreign_key_check")).toEqual([]);
expect(client.pragma("integrity_check", { simple: true })).toBe("ok");
for (const table of SEEDED_TABLES) {
expect(count(client, table), table).toBe(before[table]);
}
const after = Object.fromEntries(SEEDED_TABLES.map((t) => [t, count(client, t)]));
expect(after).toEqual(before);
expect(count(client, "titleCast")).toBe(1);
const statuses = client
.prepare("SELECT titleId, status FROM userTitleStatus")
.all() as { titleId: string; status: string }[];
const statuses = client.prepare("SELECT titleId, status FROM userTitleStatus").all() as {
titleId: string;
status: string;
}[];
const byTitle = Object.fromEntries(statuses.map((s) => [s.titleId, s.status]));
expect(byTitle).toEqual({
"t-show": "in_progress",
+3 -1
View File
@@ -28,7 +28,9 @@ describe("hasPendingMigrations", () => {
test("is true when a migration has not been applied", () => {
removed = testClient
.prepare("SELECT id, hash, created_at, name FROM __drizzle_migrations ORDER BY id DESC LIMIT 1")
.prepare(
"SELECT id, hash, created_at, name FROM __drizzle_migrations ORDER BY id DESC LIMIT 1",
)
.get() as MigrationRow;
testClient.exec(
"DELETE FROM __drizzle_migrations WHERE id = (SELECT max(id) FROM __drizzle_migrations)",
+58
View File
@@ -0,0 +1,58 @@
// Real backup → modify → restore round trip on a throwaway DATA_DIR, under Bun (bun:sqlite).
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const dataDir = mkdtempSync(path.join(os.tmpdir(), "sofa-smoke-"));
process.env.DATA_DIR = dataDir;
process.env.LOG_LEVEL ??= "error";
try {
const { runMigrations } = await import("@sofa/db/migrate");
const { BACKUP_DIR } = await import("@sofa/config");
const { createBackup, restoreFromBackup } = await import("@sofa/core/backup");
const { getSetting, setSetting } = await import("@sofa/core/settings");
runMigrations();
setSetting("smokeMarker", "before");
const backup = await createBackup("sofa-manual");
setSetting("smokeMarker", "after");
await restoreFromBackup(readFileSync(path.join(BACKUP_DIR, backup.filename)));
const marker = getSetting("smokeMarker");
if (marker !== "before") throw new Error(`expected restored marker "before", got "${marker}"`);
if (!readdirSync(BACKUP_DIR).some((f) => f.startsWith("pre-restore-"))) {
throw new Error("no pre-restore safety backup was created");
}
// A backup from a newer Sofa (a migration this build doesn't know) must be rejected and the live
// database left untouched. validateBackupDatabase compares __drizzle_migrations.created_at with
// the local migrations' folderMillis (packages/db/src/client.ts).
setSetting("smokeMarker", "live");
const newer = await createBackup("sofa-manual");
const newerPath = path.join(BACKUP_DIR, newer.filename);
const { Database } = await import("bun:sqlite");
const file = new Database(newerPath);
file.run(
"UPDATE __drizzle_migrations SET created_at = 9999999999999 WHERE id = (SELECT max(id) FROM __drizzle_migrations)",
);
file.close();
let rejected = false;
try {
await restoreFromBackup(readFileSync(newerPath));
} catch {
rejected = true;
}
if (!rejected) throw new Error("a backup from a newer Sofa version was restored");
const liveMarker = getSetting("smokeMarker");
if (liveMarker !== "live") {
throw new Error(`live DB changed by a rejected restore: "${liveMarker}"`);
}
console.log("backup/restore smoke OK");
} finally {
const { closeDatabase } = await import("@sofa/db/client");
closeDatabase();
rmSync(dataDir, { recursive: true, force: true });
}