feat(settings): backup/restore de la base de données (amelioration #4)
- service dbBackup : createBackup (VACUUM INTO → archive .db cohérente), validateSqlite (header + integrity_check + schéma), prepareRestore (sauvegarde de sécurité auto + dépôt <db>.incoming) - swap hors-ligne au démarrage (db/client.ts) : aucune corruption d'une base ouverte ; restauration appliquée au redémarrage - routes GET /system/db/info|backup, POST /system/db/restore - lib api : dbInfo / dbBackup (download navigateur) / dbRestore (upload) - SettingsModal : onglet « Base de données » (taille, télécharger, restaurer avec confirmation Popup), icônes database/upload, styles DS variables only Testé end-to-end : backup 184 Ko valide, restore + safety .bak + swap au boot, fichier invalide rejeté. tsc 0 erreur · 91 tests · build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// 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 `<db>.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`),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user