/** * Sicherung des Lernstands: schreiben, prüfen, beziffern. * * Alles hier ist reine Datei- und Datenbankarbeit ohne Electron – damit es * gegen echte SQLite-Dateien prüfbar bleibt. Die Dialoge und der Ablauf des * Einspielens stehen in `sicherung-dialoge.ts`. * * ## Warum `VACUUM INTO` und nicht `db.backup()` * * Beides erzeugt eine stimmige Kopie einer laufenden Datenbank. Nachgemessen * an better-sqlite3 13.0.3 und SQLite 3.53.4 dieses Projekts unterscheiden * sie sich an drei Stellen, und alle drei sprechen für `VACUUM INTO`: * * - `db.backup()` liefert eine Datei im **WAL-Modus**. Wer sie später auch * nur ansieht, erzeugt daneben `-wal` und `-shm` – und die bleiben nach dem * Schließen liegen. Eine Sicherung, von der man zwei Dateien vergisst, ist * genau der Fehler, den sie verhüten soll. `VACUUM INTO` schreibt eine * einzelne Datei im `delete`-Modus; sie bleibt eine einzelne Datei. * - `VACUUM INTO` bricht ab, wenn das Ziel schon existiert. Ein * versehentliches Überschreiben ist damit technisch ausgeschlossen und * nicht bloß durch Sorgfalt. * - `VACUUM INTO` ist **synchron**. Der Hauptprozess ist einfädig; solange * nichts abgewartet wird, kann sich kein anderer Kanal dazwischenschieben * und die Datei in genau dem Augenblick öffnen, in dem sie ersetzt wird. * `db.backup()` arbeitet über eine `setImmediate`-Schleife und risse dieses * Fenster wieder auf. * * ## Warum eine blosse Dateikopie nicht genügt * * Der Lernstand läuft im WAL-Modus. Nachgemessen: Bei offener Verbindung und * 5 000 geschriebenen Zeilen war `lernstand.db` **4 096 Byte** gross und das * Schreibprotokoll `lernstand.db-wal` **1,1 MB**. Eine Kopie nur der * Hauptdatei liess sich anstandslos öffnen, bestand `integrity_check` mit * „ok“ – und enthielt **keine einzige Tabelle**. Nicht ein Teil fehlte, * sondern alles. Wer so sichert, merkt es an dem Tag, an dem er die Sicherung * braucht. */ import { closeSync, openSync, readSync, renameSync, statSync, unlinkSync } from 'node:fs'; import type BetterSqlite3 from 'better-sqlite3'; import { entschaerft } from './eingaben'; import { SCHEMA_VERSION } from './schema'; import { EINSTELLUNGEN_REISEN, type Einstellungen, type ReisendeEinstellungen, } from '../shared/ipc'; import type { Kennzahlen } from '../shared/sicherung'; /** * Kennung im Dateikopf, an der eine Sicherung dieser Anwendung zu erkennen * ist – „WSKL“ als 32-Bit-Zahl. * * Ausschliesslich als Auskunft, **nie** als Ablehnungsgrund: Eine von Hand * kopierte `lernstand.db` trägt hier 0 und muss trotzdem einspielbar bleiben. */ export const ANWENDUNGSKENNUNG = 0x57534b4c; /** Die ersten Bytes jeder SQLite-Datei. */ const SQLITE_KOPF = 'SQLite format 3\0'; /** Kleiner als das kann keine Datenbank sein: eine SQLite-Seite. */ const MINDESTGROESSE = 512; /** * Grösser als das ist kein Lernstand, sondern ein Versehen. * * Exportiert, weil `sicherung-dialoge.ts` die Grenze schon an der **Quelle** * anlegt: Sonst wanderte ein versehentlich gewählter Film erst vollständig * ins Programmverzeichnis und würde danach abgelehnt. */ export const HOECHSTGROESSE = 512 * 1024 * 1024; /** * Tabellen, die es seit Schemafassung 1 gibt und die jeder Lernstand hat. * * `pruefung_lauf` und `pruefung_offen` fehlen hier mit Absicht – sie kamen * erst mit Fassung 2 und 5. Eine echte alte Sicherung soll nicht daran * scheitern, dass sie alt ist. */ const PFLICHTTABELLEN = ['schema_version', 'profil', 'frage_stand', 'antwort_log'] as const; export type Pruefbefund = | { readonly art: 'brauchbar'; readonly kennzahlen: Kennzahlen; /** * Die Profilnummern der Datei, in derselben Reihenfolge wie * `kennzahlen.jeProfil`. Sie verlassen den Hauptprozess nie – der * Renderer wählt über den Index. */ readonly profilIds: readonly number[]; } | { readonly art: 'abgelehnt'; readonly grund: string }; /** Konstruktor von better-sqlite3, so weit hier gebraucht. */ export type DatenbankKonstruktor = new ( pfad: string, optionen?: BetterSqlite3.Options, ) => BetterSqlite3.Database; /** * Schreibt eine Sicherung der laufenden Datenbank. * * Erst nach `.teil`, dann umbenennen: Ein Abbruch mittendrin hinterlässt * damit nie eine halbe Datei unter dem richtigen Namen. Das Umbenennen im * selben Verzeichnis ist der einzige Schritt, den das Betriebssystem * unteilbar ausführt. * * @returns Grösse der geschriebenen Datei in Byte. */ export function sicherungSchreiben( db: BetterSqlite3.Database, ziel: string, Datenbank: DatenbankKonstruktor, /** Die mitreisenden Einstellungen; ohne sie enthält die Datei keine. */ einstellungen?: ReisendeEinstellungen, ): number { const teil = `${ziel}.teil`; aufraeumen(teil); /* Gebundener Parameter statt eingesetztem Pfad – nachgemessen, dass SQLite das bei VACUUM INTO annimmt. Ein Pfad mit Anführungszeichen im Namen hätte sonst die Anweisung zerlegt. */ db.prepare('VACUUM INTO ?').run(teil); /* Die Kennung wird auf einer SCHREIBENDEN Verbindung gesetzt; auf einer nur lesenden wirft jedes schreibende Pragma. Gegengeprüft wird danach in einer zweiten, ausdrücklich lesenden Verbindung – sonst prüfte dieselbe Verbindung ihr eigenes Werk. */ const schreibend = new Datenbank(teil); try { schreibend.pragma(`application_id = ${String(ANWENDUNGSKENNUNG)}`); schreibend.pragma(`user_version = ${String(SCHEMA_VERSION)}`); /* Die mitreisenden Einstellungen in dieselbe Datei, auf derselben schreibenden Verbindung. Eine eigene Tabelle statt einer Spalte am Profil: Sie gelten für die Anwendung, nicht für ein Profil, und eine Sicherung enthält mehrere Profile. `IF NOT EXISTS` und ein Löschen davor: `VACUUM INTO` kopiert die laufende Datenbank – die Tabelle kann aus einer früheren Sicherung schon dastehen, wenn jemand eine Sicherung eingespielt hat. */ if (einstellungen !== undefined) { schreibend.exec('CREATE TABLE IF NOT EXISTS einstellungen_kopie (inhalt TEXT NOT NULL)'); schreibend.prepare('DELETE FROM einstellungen_kopie').run(); schreibend .prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)') .run(JSON.stringify(einstellungen)); } } finally { schreibend.close(); } const befund = dateiPruefen(teil, Datenbank); if (befund.art === 'abgelehnt') { aufraeumen(teil); throw new Error( `Die Sicherung wurde geschrieben, hielt der Gegenprobe aber nicht stand: ${befund.grund}`, ); } const bytes = statSync(teil).size; renameSync(teil, ziel); return bytes; } /** * Die Prüfkette, von billig nach teuer. * * Jede Stufe hat ihren Grund, und die wichtigste ist die fünfte: Eine Datei * von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen. * Sie durchliefe anschliessend die vollständige Migrationskette und stünde * als tadelloser, **leerer** Lernstand da. Das Einspielen meldete Erfolg, und * die Arbeit von Wochen wäre fort. * * Jeder Ablehnungsgrund endet auf denselben Satz: „Es wurde nichts * verändert.“ Wer eine Fehlermeldung liest, will zuerst das wissen. */ export function dateiPruefen(pfad: string, Datenbank: DatenbankKonstruktor): Pruefbefund { const schluss = ' Es wurde nichts verändert.'; // ── Stufe 1: überhaupt eine Datei dieser Größenordnung? ────────────── let groesse: number; try { const stand = statSync(pfad); if (!stand.isFile()) { return { art: 'abgelehnt', grund: `Das ist keine Datei.${schluss}` }; } groesse = stand.size; } catch { return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen.${schluss}` }; } if (groesse < MINDESTGROESSE) { return { art: 'abgelehnt', grund: `Diese Datei ist leer oder viel zu klein. Sie enthält keinen Lernstand.${schluss}`, }; } if (groesse > HOECHSTGROESSE) { return { art: 'abgelehnt', grund: `Diese Datei ist ${megabyte(groesse)} groß und kann kein Lernstand sein – ` + `ein Lernstand ist wenige Megabyte groß.${schluss}`, }; } // ── Stufe 2: überhaupt SQLite? ─────────────────────────────────────── /* Nötig, weil eine Textdatei sich readonly ÖFFNEN lässt – nachgemessen; erst die erste Abfrage wirft dann SQLITE_NOTADB. Ohne diese Stufe bekäme ein umbenanntes Foto eine englische Datenbankmeldung. */ if (!hatSqliteKopf(pfad)) { return { art: 'abgelehnt', grund: 'Diese Datei ist keine Datenbank, sondern etwas anderes – vielleicht ein Bild oder ' + `ein Dokument. Sicherungen dieser Anwendung enden auf .wsklernstand.${schluss}`, }; } // ── Stufe 3 bis 8: auf einer nur lesenden Verbindung ───────────────── let db: BetterSqlite3.Database; try { db = new Datenbank(pfad, { readonly: true, fileMustExist: true }); } catch { return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen. Bitte prüfen Sie die Zugriffsrechte.${schluss}`, }; } try { // Stufe 4: heil? `integrity_check` WIRFT bei Beschädigung, statt einen // Wert zu liefern – nachgemessen. Beide Wege müssen behandelt werden. let heil = false; try { const zeilen = db.pragma('integrity_check') as { integrity_check: string }[]; heil = zeilen.length === 1 && zeilen[0]?.integrity_check === 'ok'; } catch { heil = false; } if (!heil) { return { art: 'abgelehnt', grund: 'Diese Datei ist unvollständig oder beschädigt. Möglicherweise ist das Herunterladen ' + `oder das Kopieren abgebrochen.${schluss}`, }; } // Stufe 5: ein Lernstand DIESER Anwendung? const tabellen = new Set( db .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") .all() .map((zeile) => zeile.name), ); const fehlend = PFLICHTTABELLEN.filter((name) => !tabellen.has(name)); if (fehlend.length > 0) { return { art: 'abgelehnt', grund: 'Diese Datei ist zwar eine SQLite-Datenbank, aber kein Lernstand dieser Anwendung – ' + `es fehlen die Tabellen ${fehlend.join(' und ')}.${schluss}`, }; } const profile = db .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil') .get(); if ((profile?.anzahl ?? 0) < 1) { return { art: 'abgelehnt', grund: 'Diese Datei enthält kein einziges Lernprofil und kann deshalb kein Lernstand ' + `dieser Anwendung sein.${schluss}`, }; } // Stufe 6: Schemafassung. const fassung = db .prepare<[], { version: number | null }>( 'SELECT MAX(version) AS version FROM schema_version', ) .get()?.version ?? 0; if (fassung < 1) { return { art: 'abgelehnt', grund: 'Diese Datei nennt keine Schemafassung und ist damit kein vollständiger ' + `Lernstand.${schluss}`, }; } if (fassung > SCHEMA_VERSION) { return { art: 'abgelehnt', grund: `Diese Sicherung stammt aus einer neueren Fassung des Programms (Schema ${String(fassung)}, ` + `dieses Programm kennt ${String(SCHEMA_VERSION)}). Bitte zuerst das Programm ` + `aktualisieren.${schluss}`, }; } // Stufe 7: hängt es zusammen? /* `integrity_check` prüft die Baumstruktur, nicht die Beziehungen. Die laufende Datenbank arbeitet mit `foreign_keys = ON`; eine Datei mit verwaisten Zeilen fiele später an beliebiger Stelle auf. */ const verwaist = db.pragma('foreign_key_check') as unknown[]; if (verwaist.length > 0) { return { art: 'abgelehnt', grund: 'Diese Datei ist beschädigt: Sie enthält Einträge, die auf ein Profil verweisen, ' + `das es darin nicht gibt.${schluss}`, }; } // Stufe 8: Zahlen für die Rückfrage – kein Ablehnungsgrund mehr. return { art: 'brauchbar', kennzahlen: kennzahlenLesen(db, fassung, tabellen), profilIds: db .prepare<[], { id: number }>('SELECT id FROM profil ORDER BY id') .all() .map((z) => z.id), }; } finally { // Stufe 9: sonst bleibt die Datei unter Windows gesperrt und die // Arbeitskopie liesse sich weder löschen noch umbenennen. db.close(); } } /** * Die Zahlen, die in der Rückfrage stehen. * * Profilnamen sind Fremdeingabe und laufen deshalb durch `entschaerft()`: * Zeichen zur Schreibrichtung könnten in einer Rückfrage sonst das Gegenteil * dessen anzeigen, was dort steht. */ /** Spaltennamen einer Tabelle – ältere Sicherungen haben nicht alle. */ function spalten(db: BetterSqlite3.Database, tabelle: string): ReadonlySet { const zeilen = db.prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`).all(); return new Set(zeilen.map((z) => z.name)); } export function kennzahlenLesen( db: BetterSqlite3.Database, schemafassung: number, tabellen: ReadonlySet, ): Kennzahlen { const namen = db .prepare<[], { name: string }>('SELECT name FROM profil ORDER BY id') .all() .map((zeile) => entschaerft(zeile.name)); /* Zeilen im Antwortprotokoll – und ausdrücklich nur die, die jemand wirklich beantwortet hat. `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie aufgeschlagen wurden. Sie gehören in die Historie – sie standen im Bogen –, aber nicht in eine Zahl, die „beantwortete Fragen“ heisst und über die jemand eine nicht rücknehmbare Entscheidung trifft. Derselbe Befund wie docs/stand.md 7.3, nur an einer zweiten Stelle. Ältere Sicherungen haben die Spalte nicht; dort zählt alles, und das ist richtig so – rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt wurde. */ const hatNurHistorie = spalten(db, 'antwort_log').has('nur_historie'); const antworten = db .prepare<[], { anzahl: number }>( hatNurHistorie ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0' : 'SELECT COUNT(*) AS anzahl FROM antwort_log', ) .get()?.anzahl ?? 0; const letzte = db .prepare<[], { zeitpunkt: string | null }>( 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log', ) .get()?.zeitpunkt ?? null; const gemerkt = db .prepare<[], { anzahl: number }>( 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE gemerkt = 1', ) .get()?.anzahl ?? 0; /* Nur ab Schemafassung 2 beziehungsweise 5 – ältere Sicherungen haben die Tabellen nicht, und ihr Fehlen ist kein Fehler. */ const pruefungslaeufe = tabellen.has('pruefung_lauf') ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_lauf').get() ?.anzahl ?? 0) : 0; const offenerBogen = tabellen.has('pruefung_offen') ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_offen').get() ?.anzahl ?? 0) > 0 : false; /* Je Profil, damit sich vergleichen lässt statt nur zu summieren. Die Namen sind der einzige Anker: Die Nummern werden auf jedem Gerät unabhängig vergeben und sagen über die Zugehörigkeit nichts. */ const jeProfil = db .prepare<[], { id: number; name: string }>('SELECT id, name FROM profil ORDER BY id') .all() .map((profil) => { const zaehle = (sql: string): number => db.prepare<[number], { anzahl: number }>(sql).get(profil.id)?.anzahl ?? 0; return { name: entschaerft(profil.name), antworten: zaehle( hatNurHistorie ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ? AND nur_historie = 0' : 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ?', ), gemerkt: zaehle( 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE profil_id = ? AND gemerkt = 1', ), pruefungslaeufe: tabellen.has('pruefung_lauf') ? zaehle('SELECT COUNT(*) AS anzahl FROM pruefung_lauf WHERE profil_id = ?') : 0, letzteAntwort: db .prepare<[number], { zeitpunkt: string | null }>( 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ?', ) .get(profil.id)?.zeitpunkt ?? null, }; }); return { profilnamen: namen, jeProfil, antworten, letzteAntwort: letzte, gemerkt, pruefungslaeufe, offenerBogen, schemafassung, }; } /** Dateiname einer Sicherung, mit Datum und Uhrzeit auf die Sekunde genau. */ export function sicherungsDateiname(jetzt: Date, vorsatz = 'Waffensachkunde-Lernstand'): string { const z = (wert: number, stellen = 2): string => String(wert).padStart(stellen, '0'); const stempel = `${z(jetzt.getFullYear(), 4)}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}` + `-${z(jetzt.getHours())}${z(jetzt.getMinutes())}${z(jetzt.getSeconds())}`; return `${vorsatz}-${stempel}.wsklernstand`; } /** * Löscht eine Datenbankdatei samt ihrer Nebendateien. * * `-wal` und `-shm` kamen bis Fassung 0.19.0 nicht mit. Nachgemessen ist das * im heutigen Ablauf **harmlos**: Die Arbeitskopie wird nur lesend geöffnet, * das zurückbleibende `-wal` ist 0 Byte gross, und der nächste Durchgang * überliest es folgenlos – gemessen liest er die richtige Datei mit den * richtigen Zahlen. * * Harmlos, aber nicht ungefährlich. Läge dort je ein **gefülltes** `-wal`, * bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, und * `integrity_check` meldete dazu „ok“ – nachgestellt und bestätigt: erwartet * wurden 900 Zeilen, gelesen wurden 5000 aus der anderen Datei. Im Durchgang * danach war die Datei unbrauchbar („database disk image is malformed“). * * Ein gefülltes `-wal` entsteht, sobald die Arbeitskopie **schreibend** * geöffnet wird – genau das braucht das Übernehmen eines Profils aus einer * älteren Sicherung. Diese Zeilen stehen deshalb hier, bevor der erste * schreibende Zugriff dazukommt, und nicht danach. */ export function aufraeumen(pfad: string): void { loeschen(pfad); nebendateienAufraeumen(pfad); } /** * Räumt **nur** `-wal` und `-shm` weg, nicht die Datei selbst. * * Für den einen Fall, in dem das Ziel stehen bleiben muss, bis sein Ersatz * vollständig geschrieben ist: beim Anlegen einer Sicherung über eine * vorhandene. Dort erledigt `renameSync` das Ersetzen unteilbar, und ein * vorheriges Löschen hätte im Fehlerfall beide Fassungen gekostet – siehe * `main/sicherung-dialoge.ts`. */ export function nebendateienAufraeumen(pfad: string): void { loeschen(`${pfad}-wal`); loeschen(`${pfad}-shm`); } function loeschen(datei: string): void { try { unlinkSync(datei); } catch { /* Nicht da, oder gesperrt. Beides ist hier kein Grund abzubrechen – der Aufrufer prüft anschliessend ohnehin, was er braucht. */ } } function hatSqliteKopf(pfad: string): boolean { let griff: number; try { griff = openSync(pfad, 'r'); } catch { return false; } try { const puffer = Buffer.alloc(SQLITE_KOPF.length); const gelesen = readSync(griff, puffer, 0, puffer.length, 0); return gelesen === puffer.length && puffer.toString('latin1') === SQLITE_KOPF; } finally { closeSync(griff); } } /** „1,5 MB“ – dieselbe Schreibweise in jeder Ablehnung. */ export function megabyte(bytes: number): string { return `${(bytes / (1024 * 1024)).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`; } /** * Liest die mitgereisten Einstellungen aus einer geöffneten Sicherung. * * Gibt `null` zurück, wenn die Datei keine enthält – Sicherungen aus Fassung * 0.26.7 und davor tun das, und eine Sicherung ohne Einstellungen ist kein * Fehler, sondern der Normalfall der Vergangenheit. Auch beschädigter Inhalt * führt zu `null`: Am Einspielen des Lernstands – der eigentlichen Sache – * darf eine unlesbare Nebensache nichts ändern. * * Geprüft wird hier nur die Form: JSON, Objekt, bekannter Schlüssel. Die * **Werte** prüft `einstellungenBereinigen` – auf dem Weg über * `einstellungenSchreiben` und `anzeigegroesseSetzen`. Es gibt genau einen * Reinigungsweg, und der bleibt dort. * * Der Rückgabetyp sagt deshalb weniger, als er aussieht: Er benennt die * erlaubten **Schlüssel**, nicht die erlaubten Werte. TypeScript lässt ein * `Record` an dieser Stelle durch (nachgemessen), weil alle * Felder wahlfrei sind. Wer den Rückgabewert irgendwo hinreicht, wo nicht * gereinigt wird, hat einen Fehler eingebaut – nicht der Typ hält ihn auf. */ export function einstellungenAusSicherung( db: BetterSqlite3.Database, ): ReisendeEinstellungen | null { let roh: string; try { const zeile = db .prepare<[], { inhalt: string }>('SELECT inhalt FROM einstellungen_kopie LIMIT 1') .get(); if (zeile === undefined) return null; roh = zeile.inhalt; } catch { /* Keine solche Tabelle: eine Sicherung von vor 0.27.0. */ return null; } let gelesen: unknown; try { gelesen = JSON.parse(roh); } catch { return null; } if (typeof gelesen !== 'object' || gelesen === null || Array.isArray(gelesen)) return null; const gefiltert: Record = {}; for (const schluessel of EINSTELLUNGEN_REISEN) { if (schluessel in gelesen) { gefiltert[schluessel] = (gelesen as Record)[schluessel]; } } /* Auch beim Lesen gefiltert, nicht nur beim Schreiben. Sonst brächte eine von Hand veränderte Sicherungsdatei `profilId` oder `fenster` mit – und genau die dürfen nicht mitreisen (siehe EINSTELLUNGEN_REISEN). */ return Object.keys(gefiltert).length === 0 ? null : gefiltert; } /** Die mitreisenden Einstellungen des laufenden Betriebs zusammenstellen. */ export function reisendeEinstellungen(alle: Einstellungen): ReisendeEinstellungen { const stueck: Record = {}; for (const schluessel of EINSTELLUNGEN_REISEN) { const wert = alle[schluessel]; if (wert !== undefined) stueck[schluessel] = wert; } return stueck; }