197 lines
6.5 KiB
JavaScript
197 lines
6.5 KiB
JavaScript
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 });
|
|
}
|