import { randomUUID } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { backupDatabase } from "./backup-database.mjs"; const root = path.join(process.cwd(), ".test-databases"); const directory = path.join(root, `backup-restore-${randomUUID()}`); if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) { throw new Error("Unsafe disposable backup verification path"); } mkdirSync(directory, { recursive: true }); const sourcePath = path.join(directory, "source.test.db"); const backupPath = path.join(directory, "backup.test.db"); const restoredPath = path.join(directory, "restored.test.db"); let source; function rowCounts(database) { const tables = database .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ) .all() .map((row) => row.name); return Object.fromEntries( tables.map((table) => [ table, database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); } try { source = new Database(sourcePath); source.pragma("foreign_keys = ON"); source.pragma("journal_mode = WAL"); source.pragma("wal_autocheckpoint = 0"); const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true, }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); for (const migration of migrations) { source.exec( readFileSync( path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8" ) ); } source.transaction(() => { source .prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)') .run("backup-class", "Representative content", "representative-content", 0); source .prepare( 'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)' ) .run("backup-group", "backup-class", "Backup group", "DECK", 0); source .prepare( 'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)' ) .run( "backup-deck", "backup-class", "backup-group", "Backup deck", "Restore drill content", 0 ); source .prepare( 'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)' ) .run("backup-card", "backup-deck", "Representative front", "Representative back", 0); source .prepare( 'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)' ) .run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0); source .prepare( 'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)' ) .run( "backup-question", "backup-quiz", "MULTIPLE_CHOICE", "Representative prompt", "Representative rationale", "backup", 0 ); source .prepare( 'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)' ) .run("backup-option", "backup-question", "Correct option", 1, 0); source .prepare( 'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)' ) .run("backup-srs", "backup-class", "Backup SRS", 0, Date.now()); source .prepare( 'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)' ) .run("backup-srs", "backup-deck", 0); source .prepare( 'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)' ) .run("backup-share", "GROUP", "backup-group"); })(); const walPath = `${sourcePath}-wal`; if (!existsSync(walPath) || statSync(walPath).size === 0) { throw new Error("Backup drill did not create an active WAL database"); } const sourceWalBytes = statSync(walPath).size; const sourceCounts = rowCounts(source); await backupDatabase(sourcePath, backupPath); copyFileSync(backupPath, restoredPath); source.close(); source = undefined; const restored = new Database(restoredPath, { readonly: true, fileMustExist: true }); try { const integrity = restored.pragma("integrity_check", { simple: true }); const foreignKeyErrors = restored.pragma("foreign_key_check"); const restoredCounts = rowCounts(restored); const representative = restored .prepare(` SELECT c.name AS className, g.name AS groupName, d.description, f.front, s.name AS setName, sl.targetType FROM "Class" c JOIN "MaterialGroup" g ON g.classId = c.id JOIN "Deck" d ON d.groupId = g.id JOIN "Flashcard" f ON f.deckId = d.id JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id JOIN "SpacedRepetitionSet" s ON s.id = sd.setId JOIN "ShareLink" sl ON sl.groupId = g.id WHERE d.id = ? `) .get("backup-deck"); const quizRelationship = restored .prepare(` SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect FROM "QuizSet" q JOIN "Question" question ON question.quizSetId = q.id JOIN "AnswerOption" option ON option.questionId = question.id WHERE q.id = ? `) .get("backup-quiz"); if ( integrity !== "ok" || foreignKeyErrors.length !== 0 || JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) || representative?.description !== "Restore drill content" || representative?.setName !== "Backup SRS" || representative?.targetType !== "GROUP" || quizRelationship?.isCorrect !== 1 ) { throw new Error("Restored database did not preserve integrity, counts, content, and relationships"); } console.info( JSON.stringify( { method: "better-sqlite3 online backup while the WAL source remained open", sourceWalBytes, integrity, foreignKeyErrors, counts: restoredCounts, representative, quizRelationship, }, null, 2 ) ); } finally { restored.close(); } } finally { source?.close(); rmSync(directory, { recursive: true, force: true }); }