Verify SQLite backups and restores
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user