Refactor Study Desk application structure
This commit is contained in:
parent
faaccf8a7e
commit
089439ed90
145 changed files with 8087 additions and 3412 deletions
197
scripts/verify-backup-restore.mjs
Normal file
197
scripts/verify-backup-restore.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue