From 72d9f8f2d1523497ca2d10702134ae50fe7e0753 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 4 Oct 2026 15:12:51 -0400 Subject: [PATCH] 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 --- packages/core/test/backup.test.ts | 40 +++++++++++++++++++++++++++++ packages/db/package.json | 1 + packages/db/src/backup-tables.ts | 42 +++++++++++++++++++++++++++++++ packages/db/src/client.ts | 11 ++------ 4 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 packages/db/src/backup-tables.ts diff --git a/packages/core/test/backup.test.ts b/packages/core/test/backup.test.ts index becf2f1..bb8784c 100644 --- a/packages/core/test/backup.test.ts +++ b/packages/core/test/backup.test.ts @@ -1,5 +1,8 @@ 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"; describe("getBackupSource", () => { @@ -79,3 +82,40 @@ describe("isValidBackupFilename", () => { 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([]); + }); +}); diff --git a/packages/db/package.json b/packages/db/package.json index 32d35e2..c92363e 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "exports": { + "./backup-tables": "./src/backup-tables.ts", "./client": "./src/client.ts", "./migrate": "./src/migrate.ts", "./schema": "./src/schema.ts", diff --git a/packages/db/src/backup-tables.ts b/packages/db/src/backup-tables.ts new file mode 100644 index 0000000..d6fd404 --- /dev/null +++ b/packages/db/src/backup-tables.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[] { + const present = new Set(tableNames); + return REQUIRED_BACKUP_TABLES.filter((table) => !present.has(table)); +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 00c6eb2..756c5e0 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -3,14 +3,12 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { closeSync, openSync, readSync } from "node:fs"; import type { Logger } from "drizzle-orm"; -import { getTableName } from "drizzle-orm"; import { drizzle } from "drizzle-orm/bun-sqlite"; -import { SQLiteTable } from "drizzle-orm/sqlite-core"; import { DATABASE_URL } from "@sofa/config"; import { createLogger } from "@sofa/logger"; -import * as schema from "./schema"; +import { findMissingBackupTables } from "./backup-tables"; const log = createLogger("drizzle"); @@ -119,10 +117,6 @@ export function closeDatabase() { 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 { // Check SQLite magic bytes before opening with Database() to avoid // passing arbitrary files to the SQLite parser. @@ -154,8 +148,7 @@ export function validateBackupDatabase(filePath: string): void { const tableRows = validationDb .query("SELECT name FROM sqlite_master WHERE type='table'") .all() as { name: string }[]; - const tableSet = new Set(tableRows.map((row) => row.name)); - const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table)); + const missing = findMissingBackupTables(tableRows.map((row) => row.name)); if (missing.length > 0) { throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`); }