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
|
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
|
2. Apply pending schema migrations (includes `0008_circuit_first_model` and the additive
|
||||||
`0009_project_device_circuit_fields` transition)
|
`0009_project_device_circuit_fields` transition)
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,9 @@ Implemented foundation:
|
|||||||
- SQLite foreign-key enforcement is enabled explicitly for every context
|
- SQLite foreign-key enforcement is enabled explicitly for every context
|
||||||
- the distribution-board repository receives its database dependency explicitly
|
- the distribution-board repository receives its database dependency explicitly
|
||||||
- distribution-board setup has real in-memory SQLite commit and rollback coverage using production migrations
|
- 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
|
- 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
|
- 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
|
- 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:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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",
|
"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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"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:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js",
|
||||||
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
"db:backfill:sections": "tsx scripts/db-backfill-sections.ts",
|
||||||
"db:migrate:legacy-consumers": "tsx scripts/db-migrate-legacy-consumers.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