// server/services/dbBackup.ts import Database from "better-sqlite3"; import { readFileSync, writeFileSync, rmSync, existsSync, statSync } from "node:fs"; import { join, dirname } from "node:path"; import { sqlite } from "../db/client.js"; import { env } from "../env.js"; // En-tête SQLite : 15 octets ASCII + un octet nul terminal. const SQLITE_HEADER = "SQLite format 3"; function isSqliteHeader(buffer: Buffer): boolean { return ( buffer.length >= 16 && buffer.subarray(0, 15).toString("latin1") === SQLITE_HEADER && buffer[15] === 0 ); } function stamp(): string { return new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, ""); } /** Snapshot cohérent de la base courante (VACUUM INTO → fichier unique, sans WAL). */ export function createBackup(): { buffer: Buffer; filename: string } { const tmp = join(dirname(env.dbPath), `.backup-${Date.now()}.db`); rmSync(tmp, { force: true }); sqlite.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`); try { return { buffer: readFileSync(tmp), filename: `system-update-${stamp()}.db` }; } finally { rmSync(tmp, { force: true }); } } /** Vérifie qu'un buffer est une base SQLite intègre au schéma attendu. */ export function validateSqlite(buffer: Buffer): void { if (!isSqliteHeader(buffer)) { throw new Error("Fichier invalide : ce n'est pas une base SQLite."); } const tmp = join(dirname(env.dbPath), `.verify-${Date.now()}.db`); writeFileSync(tmp, buffer); try { const test = new Database(tmp, { readonly: true }); try { const integrity = test.pragma("integrity_check", { simple: true }); if (integrity !== "ok") throw new Error("Base corrompue (integrity_check)."); const hasMachines = test .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='machines'") .get(); if (!hasMachines) throw new Error("Archive non reconnue : table 'machines' absente."); } finally { test.close(); } } finally { rmSync(tmp, { force: true }); } } /** * Prépare une restauration : sauvegarde la base courante puis dépose la nouvelle base * en `.incoming`. Le swap réel a lieu au prochain démarrage (db/client.ts) pour * ne jamais écraser une base ouverte. Renvoie le chemin de la sauvegarde de sécurité. */ export function prepareRestore(buffer: Buffer): { safetyBackup: string } { validateSqlite(buffer); const safety = `${env.dbPath}.pre-restore-${stamp()}.bak`; writeFileSync(safety, createBackup().buffer); writeFileSync(`${env.dbPath}.incoming`, buffer); return { safetyBackup: safety }; } /** Métadonnées de la base courante (pour l'UI). */ export function dbInfo(): { sizeBytes: number; modifiedAt: string | null; restorePending: boolean } { const exists = existsSync(env.dbPath); const st = exists ? statSync(env.dbPath) : null; return { sizeBytes: st?.size ?? 0, modifiedAt: st ? st.mtime.toISOString() : null, restorePending: existsSync(`${env.dbPath}.incoming`), }; }