mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 07:25:44 -04:00
fix(backup): accept backups from older schema versions on restore
Validate only a baseline of tables present in every release since v0.1.0 (plus __drizzle_migrations) instead of the full current schema, so restore's post-swap runMigrations() can bring older backups up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+85
-9
No files matched your search
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { findMissingBackupTables, REQUIRED_BACKUP_TABLES } from "@sofa/db/backup-tables";
|
||||||
|
import { testClient } from "@sofa/test/db";
|
||||||
|
|
||||||
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
|
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
|
||||||
|
|
||||||
describe("getBackupSource", () => {
|
describe("getBackupSource", () => {
|
||||||
@@ -79,3 +82,40 @@ describe("isValidBackupFilename", () => {
|
|||||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(false);
|
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function currentTableNames(): string[] {
|
||||||
|
return (
|
||||||
|
testClient.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as {
|
||||||
|
name: string;
|
||||||
|
}[]
|
||||||
|
).map((r) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findMissingBackupTables", () => {
|
||||||
|
test("accepts a fully migrated database (current schema)", () => {
|
||||||
|
expect(findMissingBackupTables(currentTableNames())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a database from before v0.1.3 (no platformTmdbIds)", () => {
|
||||||
|
const names = currentTableNames().filter((n) => n !== "platformTmdbIds");
|
||||||
|
expect(findMissingBackupTables(names)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a database from v0.1.0 (no platform tables)", () => {
|
||||||
|
const newer = new Set(["platformTmdbIds", "platforms", "titleAvailability", "userPlatforms"]);
|
||||||
|
const names = currentTableNames().filter((n) => !newer.has(n));
|
||||||
|
expect(findMissingBackupTables(names)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a non-Sofa SQLite file", () => {
|
||||||
|
const missing = findMissingBackupTables(["foo"]);
|
||||||
|
expect(missing).toContain("user");
|
||||||
|
expect(missing).toContain("titles");
|
||||||
|
expect(missing).toContain("__drizzle_migrations");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every required backup table exists in the current schema", () => {
|
||||||
|
const current = currentTableNames();
|
||||||
|
expect(REQUIRED_BACKUP_TABLES.filter((t) => !current.includes(t))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./backup-tables": "./src/backup-tables.ts",
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
"./migrate": "./src/migrate.ts",
|
"./migrate": "./src/migrate.ts",
|
||||||
"./schema": "./src/schema.ts",
|
"./schema": "./src/schema.ts",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Tables a file must contain to be accepted as a Sofa backup.
|
||||||
|
*
|
||||||
|
* Only tables that have existed in every release since v0.1.0 belong here.
|
||||||
|
* Restore runs migrations after swapping the file in, which creates any newer
|
||||||
|
* tables — so requiring the *current* schema would reject every backup taken
|
||||||
|
* before the most recent table was added. `__drizzle_migrations` proves the file
|
||||||
|
* is a migrated Sofa database and is what lets those migrations run.
|
||||||
|
*
|
||||||
|
* Never add a newly created table to this list.
|
||||||
|
*/
|
||||||
|
export const REQUIRED_BACKUP_TABLES: readonly string[] = [
|
||||||
|
"__drizzle_migrations",
|
||||||
|
"account",
|
||||||
|
"appSettings",
|
||||||
|
"cronRuns",
|
||||||
|
"episodes",
|
||||||
|
"genres",
|
||||||
|
"importJobs",
|
||||||
|
"integrationEvents",
|
||||||
|
"integrations",
|
||||||
|
"personFilmography",
|
||||||
|
"persons",
|
||||||
|
"seasons",
|
||||||
|
"session",
|
||||||
|
"titleCast",
|
||||||
|
"titleGenres",
|
||||||
|
"titleRecommendations",
|
||||||
|
"titles",
|
||||||
|
"user",
|
||||||
|
"userEpisodeWatches",
|
||||||
|
"userMovieWatches",
|
||||||
|
"userRatings",
|
||||||
|
"userTitleStatus",
|
||||||
|
"verification",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Return the required backup tables that are absent from `tableNames`. */
|
||||||
|
export function findMissingBackupTables(tableNames: Iterable<string>): string[] {
|
||||||
|
const present = new Set(tableNames);
|
||||||
|
return REQUIRED_BACKUP_TABLES.filter((table) => !present.has(table));
|
||||||
|
}
|
||||||
@@ -3,14 +3,12 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|||||||
import { closeSync, openSync, readSync } from "node:fs";
|
import { closeSync, openSync, readSync } from "node:fs";
|
||||||
|
|
||||||
import type { Logger } from "drizzle-orm";
|
import type { Logger } from "drizzle-orm";
|
||||||
import { getTableName } from "drizzle-orm";
|
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
import { DATABASE_URL } from "@sofa/config";
|
import { DATABASE_URL } from "@sofa/config";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import * as schema from "./schema";
|
import { findMissingBackupTables } from "./backup-tables";
|
||||||
|
|
||||||
const log = createLogger("drizzle");
|
const log = createLogger("drizzle");
|
||||||
|
|
||||||
@@ -119,10 +117,6 @@ export function closeDatabase() {
|
|||||||
|
|
||||||
const SQLITE_MAGIC = "SQLite format 3\0";
|
const SQLITE_MAGIC = "SQLite format 3\0";
|
||||||
|
|
||||||
const REQUIRED_TABLES = Object.values(schema)
|
|
||||||
.filter((v) => v instanceof SQLiteTable)
|
|
||||||
.map((t) => getTableName(t as SQLiteTable));
|
|
||||||
|
|
||||||
export function validateBackupDatabase(filePath: string): void {
|
export function validateBackupDatabase(filePath: string): void {
|
||||||
// Check SQLite magic bytes before opening with Database() to avoid
|
// Check SQLite magic bytes before opening with Database() to avoid
|
||||||
// passing arbitrary files to the SQLite parser.
|
// passing arbitrary files to the SQLite parser.
|
||||||
@@ -154,8 +148,7 @@ export function validateBackupDatabase(filePath: string): void {
|
|||||||
const tableRows = validationDb
|
const tableRows = validationDb
|
||||||
.query("SELECT name FROM sqlite_master WHERE type='table'")
|
.query("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
const tableSet = new Set(tableRows.map((row) => row.name));
|
const missing = findMissingBackupTables(tableRows.map((row) => row.name));
|
||||||
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
|
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user