diff --git a/docs/local-db-circuit-first-migration.md b/docs/local-db-circuit-first-migration.md index 3830662..0f1eb9b 100644 --- a/docs/local-db-circuit-first-migration.md +++ b/docs/local-db-circuit-first-migration.md @@ -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) diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 098c578..e6e8335 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -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 diff --git a/package.json b/package.json index db5804c..7da87f1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-backup.js b/scripts/db-backup.js deleted file mode 100644 index 2545473..0000000 --- a/scripts/db-backup.js +++ /dev/null @@ -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}`); diff --git a/scripts/db-backup.ts b/scripts/db-backup.ts new file mode 100644 index 0000000..7ceea95 --- /dev/null +++ b/scripts/db-backup.ts @@ -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); +}); diff --git a/src/db/database-backup.ts b/src/db/database-backup.ts new file mode 100644 index 0000000..9766df0 --- /dev/null +++ b/src/db/database-backup.ts @@ -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 { + 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); +} diff --git a/tests/database-backup.test.ts b/tests/database-backup.test.ts new file mode 100644 index 0000000..6c960bd --- /dev/null +++ b/tests/database-backup.test.ts @@ -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 }); + } + }); +});