/* =================================================================== Sicherung und Zustand. Bis heute wurde nur EINMAL gesichert: direkt vor einer Umstellung der Datenbank. Faellt die Platte aus, ist alles weg -- die komplette Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko im ganzen Aufbau, das auf einen Schlag ALLES kostet. Zwei Dinge, die beim Nachsehen auf dem Server auffielen und die den Bauplan hier bestimmen: 1) Die Datei `workspace.db` war 4 KB gross, die Datei `workspace.db-wal` dagegen 2,1 MB. Im WAL-Betrieb landen Schreibvorgaenge zuerst im -wal und wandern erst beim "Checkpoint" in die Hauptdatei. Der lief seit dem ersten Tag nicht. Wer also die `.db` kopiert haette -- der naheliegendste Sicherungsweg ueberhaupt -- haette eine LEERE Datenbank gesichert und es nicht gemerkt. Deshalb: NIE die Datei kopieren. Immer `VACUUM INTO`. Das laesst SQLite selbst schreiben, liest das WAL mit und ergibt eine in sich stimmige, aufgeraeumte Datei -- auch waehrend geschrieben wird. 2) Vor jeder Sicherung wird zusaetzlich ein Checkpoint erzwungen. Das haelt die Hauptdatei aktuell und das WAL klein. Ohne das waechst es unbegrenzt weiter. Die Sicherung laeuft IM Programm, nicht als eigener Systemdienst. Gruende: keine Aenderung an Systemdateien noetig (die duerfte ich nicht ohne Filipe), der Prozess hat die Datenbank ohnehin offen, und sie kommt mit jedem `git pull` automatisch mit. Grundsatz wie ueberall hier: Eine gescheiterte Sicherung darf die Website nie mitreissen. Alles ist eingepackt, jeder Fehler wird vermerkt und ist auf der Automationen-Seite zu sehen. =================================================================== */ import express from "express"; import { readdirSync, statSync, unlinkSync, mkdirSync, existsSync, statfsSync, renameSync, } from "node:fs"; import { join, dirname } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; import { db, DB_PFAD, DATEN_ORDNER, einstellung, einstellungSetzen, istLeitung, protokolliere, } from "./workspace.js"; /* node:sqlite wird wie in workspace.js NACHTRAEGLICH geholt, nicht oben importiert. Ein `import` waere fest verdrahtet und wuerde beim Hochfahren den GESAMTEN Website-Prozess abbrechen, wenn das Modul fehlt -- also auch die oeffentliche Seite. So bleibt der Schaden auf die Sicherung beschraenkt. */ const require = createRequire(pathToFileURL(dirname(fileURLToPath(import.meta.url)) + "/")); const ORDNER = join(DATEN_ORDNER, "sicherungen"); /* Wie viele wovon aufgehoben werden. Grossvater-Vater-Sohn: viele frische fuer "ups, eben geloescht", wenige alte fuer "das war schon letzten Monat falsch". Bei 2 MB je Datei kostet das nichts. */ const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6 }; const zahl2 = (n) => String(n).padStart(2, "0"); /* Ortszeit, nicht UTC. Eine Sicherung, die im Namen den 30. traegt, aber am 31. lief, verwirrt genau dann, wenn man sie braucht. */ function heuteOrt(d = new Date()) { return `${d.getFullYear()}-${zahl2(d.getMonth() + 1)}-${zahl2(d.getDate())}`; } /* Welche Art ist heute faellig? Monatlich schlaegt woechentlich schlaegt taeglich -- am 1., der auf einen Sonntag faellt, entsteht sonst dieselbe Datei dreimal. */ function artFuer(d = new Date()) { if (d.getDate() === 1) return "monatlich"; if (d.getDay() === 0) return "woechentlich"; // Sonntag return "taeglich"; } function ordnerSicherstellen() { if (!existsSync(ORDNER)) mkdirSync(ORDNER, { recursive: true }); } export function sicherungenListe() { try { ordnerSicherstellen(); return readdirSync(ORDNER) .filter((n) => n.endsWith(".db")) .map((name) => { const s = statSync(join(ORDNER, name)); return { name, groesse: s.size, zeit: s.mtime.toISOString() }; }) .sort((a, b) => b.name.localeCompare(a.name)); } catch { return []; } } /* Alte wegraeumen -- je Art getrennt, damit eine Woche voller taeglicher Sicherungen nie die monatlichen verdraengt. */ function aufraeumen() { const alle = sicherungenListe(); let weg = 0; for (const [art, wieviele] of Object.entries(BEHALTEN)) { const dieser = alle.filter((s) => s.name.startsWith(art + "-")); for (const alt of dieser.slice(wieviele)) { try { unlinkSync(join(ORDNER, alt.name)); weg++; } catch { /* egal */ } } } return weg; } /* Eine frisch geschriebene Sicherung wird SOFORT wieder geoeffnet und geprueft. Eine Sicherung, die man nie aufgemacht hat, ist eine Vermutung -- und man merkt es erst an dem Tag, an dem man sie braucht. Geprueft wird beides: die Unversehrtheit der Datei UND ob die Personen ueberhaupt drinstehen (gegen genau den Fall, der hier drohte: eine technisch einwandfreie, aber leere Datei). */ function pruefen(pfad) { const { DatabaseSync } = require("node:sqlite"); const p = new DatabaseSync(pfad, { readOnly: true }); try { const heil = p.prepare("PRAGMA integrity_check").get(); const wert = heil ? Object.values(heil)[0] : ""; if (wert !== "ok") return { gut: false, grund: "integrity_check: " + wert }; const n = p.prepare("SELECT COUNT(*) AS n FROM personen").get()?.n ?? 0; if (n === 0) return { gut: false, grund: "keine Personen in der Sicherung" }; return { gut: true, personen: n }; } finally { try { p.close(); } catch { /* egal */ } } } export function sicherungJetzt(grund = "planmaessig") { const begonnen = Date.now(); const d = db(); ordnerSicherstellen(); /* Checkpoint zuerst: bringt das WAL in die Hauptdatei und macht es wieder klein. TRUNCATE statt PASSIVE, weil PASSIVE aufgibt, sobald jemand liest -- und dann waere das WAL nach Wochen immer noch da. */ let checkpoint = null; try { checkpoint = d.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get(); } catch (f) { checkpoint = { fehler: f?.message }; } const art = artFuer(); const ziel = join(ORDNER, `${art}-${heuteOrt()}.db`); /* Erst neben das Ziel schreiben, dann pruefen, dann umbenennen. Sonst stuende nach einem Abbruch eine halbe Datei unter dem richtigen Namen -- und die wuerde man fuer gut halten. */ const vorlaeufig = ziel + ".teil"; try { if (existsSync(vorlaeufig)) unlinkSync(vorlaeufig); } catch { /* egal */ } /* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am selben Tag zweimal zu sichern ist erlaubt (etwa von Hand vor einer groesseren Aenderung) -- die neue ersetzt dann die alte. */ try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ } d.exec(`VACUUM INTO '${vorlaeufig.replace(/'/g, "''")}'`); const geprueft = pruefen(vorlaeufig); if (!geprueft.gut) { try { unlinkSync(vorlaeufig); } catch { /* egal */ } throw new Error("Sicherung war unbrauchbar: " + geprueft.grund); } renameSync(vorlaeufig, ziel); const groesse = statSync(ziel).size; const weg = aufraeumen(); const ergebnis = { zeit: new Date().toISOString(), datei: `${art}-${heuteOrt()}.db`, art, groesse, personen: geprueft.personen, dauer_ms: Date.now() - begonnen, geloescht: weg, grund, checkpoint: checkpoint && !checkpoint.fehler ? `${checkpoint.busy ?? "?"}/${checkpoint.log ?? "?"}/${checkpoint.checkpointed ?? "?"}` : (checkpoint?.fehler || null), }; einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis)); einstellungSetzen("sicherung_fehler", ""); return ergebnis; } /* ---------- Zeitsteuerung --------------------------------------------- Kein setTimeout auf 24 Stunden: Ein Neustart -- und sei es nur ein Deploy -- setzt ihn zurueck, und dann faellt die Sicherung eines Tages still aus. Stattdessen wird viertelstuendlich geschaut, ob fuer HEUTE schon eine liegt. Das ueberlebt Neustarts, Zeitumstellung und Ausfaelle: Wird der Rechner drei Tage spaeter wieder angeschaltet, sichert er sofort statt bis Mitternacht zu warten. --------------------------------------------------------------------- */ const PRUEFTAKT_MS = 15 * 60 * 1000; const STUNDE_AB = 3; // nachts, wenn nichts los ist function faellig() { const jetzt = new Date(); const letzteRoh = einstellung("sicherung_letzte"); let letzte = null; try { letzte = letzteRoh ? JSON.parse(letzteRoh) : null; } catch { /* kaputt = keine */ } /* Liegt fuer heute schon eine? Geprueft wird die DATEI, nicht nur der Vermerk -- sonst haelt ein Eintrag ohne Datei die Sicherung auf. */ const heute = heuteOrt(jetzt); const daFuerHeute = sicherungenListe().some((s) => s.name.includes(heute)); if (daFuerHeute) return false; /* Vor drei Uhr nur dann, wenn seit ueber einem Tag gar nichts kam -- dann war die Maschine aus und es soll nicht bis zur naechsten Nacht dauern. */ if (jetzt.getHours() >= STUNDE_AB) return true; if (!letzte?.zeit) return true; return Date.now() - Date.parse(letzte.zeit) > 30 * 3600_000; } let laeuft = false; function versuchen() { if (laeuft) return; laeuft = true; try { if (!faellig()) return; const e = sicherungJetzt("planmaessig"); console.log(`[sicherung] ${e.datei} · ${Math.round(e.groesse / 1024)} KB · ` + `${e.personen} Personen · ${e.dauer_ms} ms` + (e.geloescht ? ` · ${e.geloescht} alte entfernt` : "")); } catch (fehler) { /* Laut, aber nicht toedlich. Der Fehler landet zusaetzlich in den Einstellungen, damit er auf der Automationen-Seite steht -- eine Fehlermeldung, die nur im Systemprotokoll steht, liest niemand. */ console.error("[sicherung] fehlgeschlagen:", fehler?.message); try { einstellungSetzen("sicherung_fehler", JSON.stringify({ zeit: new Date().toISOString(), text: String(fehler?.message || fehler) })); } catch { /* egal */ } } finally { laeuft = false; } } export function sicherungStarten() { /* Nicht sofort beim Hochfahren: Erst soll die Website antworten. Und die Datenbank oeffnet ohnehin erst beim ersten echten Zugriff -- ein Sicherungslauf im Startmoment wuerde sie nur unnoetig frueh oeffnen und einen Fehler dort zum Startfehler machen. */ const ersterLauf = setTimeout(versuchen, 60_000); const takt = setInterval(versuchen, PRUEFTAKT_MS); ersterLauf.unref?.(); takt.unref?.(); } /* ---------- Zustandsseite --------------------------------------------- */ export const sicherungRouter = express.Router(); function nurLeitung(req, res, next) { if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht gefunden" }); next(); } sicherungRouter.get("/workspace/api/zustand", nurLeitung, (req, res) => { try { const d = db(); const lies = (s) => { try { return JSON.parse(einstellung(s) || "null"); } catch { return null; } }; let platte = null; try { const s = statfsSync(DATEN_ORDNER); platte = { frei: s.bavail * s.bsize, gesamt: s.blocks * s.bsize }; } catch { /* nicht ueberall vorhanden */ } /* Die Groesse des WAL ist die Zahl, an der genau der Fehler aufgefallen waere, den es hier gab. Sie gehoert deshalb sichtbar auf die Seite und nicht nur in ein Protokoll. */ const dateien = {}; for (const [schluessel, pfad] of [["db", DB_PFAD], ["wal", DB_PFAD + "-wal"]]) { try { dateien[schluessel] = statSync(pfad).size; } catch { dateien[schluessel] = null; } } const heil = (() => { try { const r = d.prepare("PRAGMA quick_check").get(); return r ? Object.values(r)[0] : null; } catch { return null; } })(); const liste = sicherungenListe(); res.json({ letzte: lies("sicherung_letzte"), fehler: lies("sicherung_fehler"), sicherungen: liste.slice(0, 20), anzahl: liste.length, belegt: liste.reduce((n, s) => n + s.groesse, 0), behalten: BEHALTEN, dateien, platte, heil, journal: (() => { try { return Object.values(d.prepare("PRAGMA journal_mode").get() || {})[0]; } catch { return null; } })(), seit: Math.round(process.uptime()), stand: new Date().toISOString(), }); } catch (fehler) { console.error("[zustand]", fehler?.message); res.status(500).json({ fehler: "Zustand konnte nicht gelesen werden." }); } }); /* Von Hand ausloesen -- vor einer groesseren Aenderung will man nicht bis drei Uhr nachts warten. Nur die Leitung, und es wird protokolliert. */ sicherungRouter.post("/workspace/api/zustand/sichern", nurLeitung, (req, res) => { try { const e = sicherungJetzt("von Hand · " + (req.person?.name || "?")); protokolliere("sicherung_erstellt", { personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei, }); res.json(e); } catch (fehler) { res.status(500).json({ fehler: String(fehler?.message || fehler) }); } });