Verify SQLite backups and restores

This commit is contained in:
2026-07-23 19:12:14 +02:00
parent 4d5eb22e66
commit 030f18aeb0
7 changed files with 205 additions and 22 deletions
+6
View File
@@ -10,6 +10,12 @@ This project uses SQLite at `data/leistungsbilanz.db` and Drizzle migrations in
npm run db:backup
```
The command uses SQLite's online backup API, so committed WAL changes are included
even while the application is running. It opens the resulting standalone database
and requires both `PRAGMA integrity_check` and `PRAGMA foreign_key_check` to pass.
Database backups are operational recovery files and remain separate from the future
user-visible project version history.
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
`0009_project_device_circuit_fields` transition)
@@ -328,6 +328,9 @@ Implemented foundation:
- SQLite foreign-key enforcement is enabled explicitly for every context
- the distribution-board repository receives its database dependency explicitly
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
- database backups use SQLite's online backup API and include committed WAL data
- every backup is opened independently and checked for integrity and foreign-key violations
- an integration test restores the backup into a separate database and verifies its snapshot contents
- circuit-device-row creation/deletion and reserve-state changes use an explicitly injected transaction store
- a new circuit and all of its initial device rows use the same transaction store
- device-row moves, reserve-state updates and optional target creation share one transaction
+3 -3
View File
@@ -14,11 +14,11 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
"db:backup": "tsx scripts/db-backup.ts",
"db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.ts",
-19
View File
@@ -1,19 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const dataDir = path.resolve("data");
const source = path.join(dataDir, "leistungsbilanz.db");
if (!fs.existsSync(source)) {
console.error(`Database file not found: ${source}`);
process.exit(1);
}
const backupDir = path.join(dataDir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const target = path.join(backupDir, `leistungsbilanz-${timestamp}.db`);
fs.copyFileSync(source, target);
console.log(`Backup created: ${target}`);
+27
View File
@@ -0,0 +1,27 @@
import path from "node:path";
import { createVerifiedDatabaseBackup } from "../src/db/database-backup.js";
const dataDirectory = path.resolve("data");
const source = path.join(dataDirectory, "leistungsbilanz.db");
const backupDirectory = path.join(dataDirectory, "backups");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const destination = path.join(
backupDirectory,
`leistungsbilanz-${timestamp}.db`
);
async function run() {
const verification = await createVerifiedDatabaseBackup(source, destination);
console.log(`Backup created and verified: ${destination}`);
console.log(
`Integrity: ${verification.integrityCheck}; foreign-key violations: ${verification.foreignKeyViolations}`
);
}
run().catch((error) => {
console.error(
"Database backup failed:",
error instanceof Error ? error.message : error
);
process.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
export interface DatabaseVerification {
integrityCheck: "ok";
foreignKeyViolations: number;
}
function resolveDatabasePath(filename: string): string {
return path.resolve(filename);
}
export function verifyDatabaseFile(filename: string): DatabaseVerification {
const databasePath = resolveDatabasePath(filename);
const sqlite = new Database(databasePath, {
readonly: true,
fileMustExist: true,
});
try {
const integrityCheck = sqlite.pragma("integrity_check", {
simple: true,
});
if (integrityCheck !== "ok") {
throw new Error(
`SQLite integrity check failed for ${databasePath}: ${String(integrityCheck)}`
);
}
const foreignKeyCheck = sqlite.pragma("foreign_key_check") as unknown[];
const foreignKeyViolations = foreignKeyCheck.length;
if (foreignKeyViolations > 0) {
throw new Error(
`SQLite foreign-key check failed for ${databasePath}: ${foreignKeyViolations} violation(s)`
);
}
return {
integrityCheck: "ok",
foreignKeyViolations,
};
} finally {
sqlite.close();
}
}
export async function createVerifiedDatabaseBackup(
sourceFilename: string,
destinationFilename: string
): Promise<DatabaseVerification> {
const sourcePath = resolveDatabasePath(sourceFilename);
const destinationPath = resolveDatabasePath(destinationFilename);
if (sourcePath === destinationPath) {
throw new Error("Backup source and destination must be different files.");
}
if (!fs.existsSync(sourcePath)) {
throw new Error(`Database file not found: ${sourcePath}`);
}
if (fs.existsSync(destinationPath)) {
throw new Error(`Backup destination already exists: ${destinationPath}`);
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
const source = new Database(sourcePath, {
readonly: true,
fileMustExist: true,
});
try {
await source.backup(destinationPath);
} finally {
source.close();
}
return verifyDatabaseFile(destinationPath);
}
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
import Database from "better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createVerifiedDatabaseBackup,
verifyDatabaseFile,
} from "../src/db/database-backup.js";
import { createDatabaseContext } from "../src/db/database-context.js";
import { projects } from "../src/db/schema/projects.js";
describe("database backup and restore", () => {
it("backs up committed WAL data and restores it as an independent database", async () => {
const testDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "leistungsbilanz-db-backup-")
);
const sourcePath = path.join(testDirectory, "source.db");
const backupPath = path.join(testDirectory, "backup.db");
const restoredPath = path.join(testDirectory, "restored.db");
const source = createDatabaseContext(sourcePath);
try {
source.sqlite.pragma("journal_mode = WAL");
migrate(source.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
source.db
.insert(projects)
.values({ id: "project-in-backup", name: "Backup project" })
.run();
assert.equal(fs.existsSync(`${sourcePath}-wal`), true);
const backupVerification = await createVerifiedDatabaseBackup(
sourcePath,
backupPath
);
assert.deepEqual(backupVerification, {
integrityCheck: "ok",
foreignKeyViolations: 0,
});
source.db
.insert(projects)
.values({ id: "project-after-backup", name: "Later project" })
.run();
source.close();
await createVerifiedDatabaseBackup(backupPath, restoredPath);
const restored = new Database(restoredPath, {
readonly: true,
fileMustExist: true,
});
try {
const restoredProjects = restored
.prepare("SELECT id, name FROM projects ORDER BY id")
.all();
assert.deepEqual(restoredProjects, [
{ id: "project-in-backup", name: "Backup project" },
]);
} finally {
restored.close();
}
} finally {
if (source.sqlite.open) {
source.close();
}
fs.rmSync(testDirectory, { recursive: true, force: true });
}
});
it("rejects a corrupt database file", () => {
const testDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "leistungsbilanz-db-verify-")
);
const corruptPath = path.join(testDirectory, "corrupt.db");
try {
fs.writeFileSync(corruptPath, "not a sqlite database");
assert.throws(() => verifyDatabaseFile(corruptPath), /not a database/);
} finally {
fs.rmSync(testDirectory, { recursive: true, force: true });
}
});
});