mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
ci: smoke-test backup and restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
7 files changed
+77
-10
No files matched your search
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user