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:
2026-06-06 06:13:03 +02:00
parent edb22a59c7
commit 47fe952240
8 changed files with 330 additions and 5 deletions
+14 -2
View File
@@ -1,15 +1,27 @@
// server/db/client.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { mkdirSync } from "node:fs";
import { mkdirSync, existsSync, rmSync, renameSync } from "node:fs";
import { dirname } from "node:path";
import { env } from "../env.js";
import * as schema from "./schema.js";
mkdirSync(dirname(env.dbPath), { recursive: true });
// Restauration en attente : un fichier `<db>.incoming` déposé par /system/db/restore
// est appliqué au démarrage (swap hors-ligne = aucune corruption d'une base ouverte).
const incoming = `${env.dbPath}.incoming`;
if (existsSync(incoming)) {
for (const ext of ["", "-wal", "-shm"]) {
const p = `${env.dbPath}${ext}`;
if (existsSync(p)) rmSync(p, { force: true });
}
renameSync(incoming, env.dbPath);
}
const sqlite = new Database(env.dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export { schema };
export { schema, sqlite };
+37
View File
@@ -0,0 +1,37 @@
// server/routes/db.ts
import { Hono } from "hono";
import { createBackup, prepareRestore, dbInfo } from "../services/dbBackup.js";
export const dbRoutes = new Hono();
// Métadonnées de la base (taille, date, restauration en attente).
dbRoutes.get("/info", (c) => c.json(dbInfo()));
// Télécharge une archive cohérente de la base courante.
dbRoutes.get("/backup", () => {
const { buffer, filename } = createBackup();
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(buffer.length),
},
});
});
// Restaure depuis une archive uploadée (corps brut). Appliquée au prochain démarrage.
dbRoutes.post("/restore", async (c) => {
try {
const ab = await c.req.arrayBuffer();
if (!ab.byteLength) return c.json({ error: "Archive vide" }, 400);
const { safetyBackup } = prepareRestore(Buffer.from(ab));
return c.json({
ok: true,
restartRequired: true,
safetyBackup,
message: "Restauration préparée. Redémarrez le serveur pour l'appliquer.",
});
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
+2
View File
@@ -3,6 +3,7 @@ import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
import { actionRequestsRoutes } from "./actionRequests.js";
import { dbRoutes } from "./db.js";
import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
@@ -10,6 +11,7 @@ export const api = new Hono();
api.get("/capabilities", (c) => c.json(getServerCapabilities()));
api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/system/db", dbRoutes);
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
api.route("/", actionRequestsRoutes);
+81
View File
@@ -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`),
};
}