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:
@@ -45,6 +45,8 @@ const ICON_MAP = {
|
|||||||
plus: 'plus',
|
plus: 'plus',
|
||||||
filter: 'filter',
|
filter: 'filter',
|
||||||
download: 'download',
|
download: 'download',
|
||||||
|
upload: 'upload',
|
||||||
|
database: 'database',
|
||||||
folder: 'folder',
|
folder: 'folder',
|
||||||
docker: 'boxes-stacked',
|
docker: 'boxes-stacked',
|
||||||
package: 'box-open',
|
package: 'box-open',
|
||||||
|
|||||||
@@ -38,4 +38,47 @@ export const api = {
|
|||||||
runAction: (id: string, action: ActionType) =>
|
runAction: (id: string, action: ActionType) =>
|
||||||
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
|
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
|
||||||
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// --- Sauvegarde / restauration de la base ---
|
||||||
|
dbInfo: () => req<DbInfo>("/system/db/info"),
|
||||||
|
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
|
||||||
|
dbBackup: async (): Promise<void> => {
|
||||||
|
const res = await fetch("/api/system/db/backup");
|
||||||
|
if (!res.ok) throw new Error("Échec de la sauvegarde");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const filename = /filename="([^"]+)"/.exec(cd)?.[1] ?? "system-update-backup.db";
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
/** Envoie une archive `.db` à restaurer (appliquée au redémarrage). */
|
||||||
|
dbRestore: async (file: File): Promise<DbRestoreResult> => {
|
||||||
|
const res = await fetch("/api/system/db/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
const body = (await readJsonBody(res)) as DbRestoreResult & { error?: string };
|
||||||
|
if (!res.ok) throw new Error(body?.error ?? "Échec de la restauration");
|
||||||
|
return body;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface DbInfo {
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
restorePending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbRestoreResult {
|
||||||
|
ok: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
safetyBackup: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// client/src/panels/SettingsModal.tsx
|
// client/src/panels/SettingsModal.tsx
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Icon } from "../components/ui-kit.js";
|
import { Icon, Popup, Button } from "../components/ui-kit.js";
|
||||||
|
import { api, type DbInfo } from "../lib/api.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,7 +16,8 @@ type SettingsTab =
|
|||||||
| "scripts"
|
| "scripts"
|
||||||
| "hermes"
|
| "hermes"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "retention";
|
| "retention"
|
||||||
|
| "database";
|
||||||
|
|
||||||
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
||||||
{ id: "appearance", label: "Apparence", icon: "cog" },
|
{ id: "appearance", label: "Apparence", icon: "cog" },
|
||||||
@@ -26,6 +28,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
|||||||
{ id: "hermes", label: "Hermes", icon: "node" },
|
{ id: "hermes", label: "Hermes", icon: "node" },
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||||
{ id: "retention", label: "Nettoyage", icon: "logs" },
|
{ id: "retention", label: "Nettoyage", icon: "logs" },
|
||||||
|
{ id: "database", label: "Base de données", icon: "database" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsModal({ open, onClose }: Props) {
|
export function SettingsModal({ open, onClose }: Props) {
|
||||||
@@ -69,6 +72,7 @@ export function SettingsModal({ open, onClose }: Props) {
|
|||||||
{active === "hermes" && <HermesSettings />}
|
{active === "hermes" && <HermesSettings />}
|
||||||
{active === "terminal" && <TerminalSettings />}
|
{active === "terminal" && <TerminalSettings />}
|
||||||
{active === "retention" && <RetentionSettings />}
|
{active === "retention" && <RetentionSettings />}
|
||||||
|
{active === "database" && <DatabaseSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,6 +235,125 @@ function RetentionSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DatabaseSettings() {
|
||||||
|
const [info, setInfo] = useState<DbInfo | null>(null);
|
||||||
|
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
|
||||||
|
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function loadInfo() {
|
||||||
|
try {
|
||||||
|
setInfo(await api.dbInfo());
|
||||||
|
} catch {
|
||||||
|
setInfo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onBackup() {
|
||||||
|
setBusy("backup");
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
await api.dbBackup();
|
||||||
|
setMessage({ kind: "ok", text: "Archive téléchargée." });
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ kind: "error", text: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
if (file) setPendingFile(file);
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestore() {
|
||||||
|
if (!pendingFile) return;
|
||||||
|
setBusy("restore");
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await api.dbRestore(pendingFile);
|
||||||
|
setMessage({ kind: "ok", text: res.message });
|
||||||
|
void loadInfo();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ kind: "error", text: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
setPendingFile(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title="Base de données">
|
||||||
|
<div className="settings-fields">
|
||||||
|
<Field label="Taille actuelle">
|
||||||
|
<span className="mono">{info ? formatBytes(info.sizeBytes) : "--"}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Dernière modification">
|
||||||
|
<span className="mono">{info?.modifiedAt ? new Date(info.modifiedAt).toLocaleString("fr-FR") : "--"}</span>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{info?.restorePending && (
|
||||||
|
<p className="settings-note settings-note-warn">
|
||||||
|
<Icon name="alert" size={13} style={undefined} /> Une restauration est en attente : redémarrez le serveur pour l'appliquer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<Button icon="download" variant="primary" onClick={busy ? undefined : onBackup}>
|
||||||
|
{busy === "backup" ? "Sauvegarde…" : "Télécharger la sauvegarde"}
|
||||||
|
</Button>
|
||||||
|
<Button icon="upload" variant="default" onClick={busy ? undefined : () => fileRef.current?.click()}>
|
||||||
|
Restaurer une archive…
|
||||||
|
</Button>
|
||||||
|
<input ref={fileRef} type="file" accept=".db,application/octet-stream" hidden onChange={onPickFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="settings-note">
|
||||||
|
La sauvegarde produit un instantané cohérent <span className="mono">.db</span> (machines, credentials chiffrés, exécutions, rapports). La restauration remplace toute la base au prochain démarrage ; une sauvegarde de sécurité est créée automatiquement avant.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className={`settings-note ${message.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
open={pendingFile !== null}
|
||||||
|
onClose={() => setPendingFile(null)}
|
||||||
|
title="Confirmer la restauration"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" icon="close" onClick={() => setPendingFile(null)}>Annuler</Button>
|
||||||
|
<Button variant="danger" icon="upload" onClick={busy === "restore" ? undefined : confirmRestore}>
|
||||||
|
{busy === "restore" ? "Restauration…" : "Remplacer la base"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
La base actuelle sera <strong>entièrement remplacée</strong> par
|
||||||
|
<span className="mono">{pendingFile?.name}</span> au prochain démarrage du serveur.
|
||||||
|
</p>
|
||||||
|
<p>Une sauvegarde de sécurité de la base actuelle est créée automatiquement.</p>
|
||||||
|
</Popup>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} o`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
|
|||||||
@@ -285,6 +285,31 @@ body {
|
|||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0 4px;
|
||||||
|
}
|
||||||
|
.settings-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.settings-note .mono { color: var(--ink-1); }
|
||||||
|
.settings-note-ok { color: var(--ok); }
|
||||||
|
.settings-note-err { color: var(--err); }
|
||||||
|
.settings-note-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
background: var(--bg-1);
|
||||||
|
}
|
||||||
.settings-checks {
|
.settings-checks {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+14
-2
@@ -1,15 +1,27 @@
|
|||||||
// server/db/client.ts
|
// server/db/client.ts
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { drizzle } from "drizzle-orm/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 { dirname } from "node:path";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
mkdirSync(dirname(env.dbPath), { recursive: true });
|
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);
|
const sqlite = new Database(env.dbPath);
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
export { schema };
|
export { schema, sqlite };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|||||||
import { machinesRoutes } from "./machines.js";
|
import { machinesRoutes } from "./machines.js";
|
||||||
import { actionsRoutes } from "./actions.js";
|
import { actionsRoutes } from "./actions.js";
|
||||||
import { actionRequestsRoutes } from "./actionRequests.js";
|
import { actionRequestsRoutes } from "./actionRequests.js";
|
||||||
|
import { dbRoutes } from "./db.js";
|
||||||
import { getServerCapabilities } from "../services/capabilities.js";
|
import { getServerCapabilities } from "../services/capabilities.js";
|
||||||
import { getSystemMetrics, getSystemStatus } from "../services/system.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("/capabilities", (c) => c.json(getServerCapabilities()));
|
||||||
api.get("/system/status", (c) => c.json(getSystemStatus()));
|
api.get("/system/status", (c) => c.json(getSystemStatus()));
|
||||||
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
|
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
|
||||||
|
api.route("/system/db", dbRoutes);
|
||||||
api.route("/machines", machinesRoutes);
|
api.route("/machines", machinesRoutes);
|
||||||
api.route("/machines", actionsRoutes);
|
api.route("/machines", actionsRoutes);
|
||||||
api.route("/", actionRequestsRoutes);
|
api.route("/", actionRequestsRoutes);
|
||||||
|
|||||||
@@ -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