waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src main sicherung.ts

18,6 KB Rohdatei
app/src/main/sicherung.ts — 506 Zeilen
1 /**
2 * Sicherung des Lernstands: schreiben, prüfen, beziffern.
3 *
4 * Alles hier ist reine Datei- und Datenbankarbeit ohne Electron – damit es
5 * gegen echte SQLite-Dateien prüfbar bleibt. Die Dialoge und der Ablauf des
6 * Einspielens stehen in `sicherung-dialoge.ts`.
7 *
8 * ## Warum `VACUUM INTO` und nicht `db.backup()`
9 *
10 * Beides erzeugt eine stimmige Kopie einer laufenden Datenbank. Nachgemessen
11 * an better-sqlite3 13.0.3 und SQLite 3.53.4 dieses Projekts unterscheiden
12 * sie sich an drei Stellen, und alle drei sprechen für `VACUUM INTO`:
13 *
14 * - `db.backup()` liefert eine Datei im **WAL-Modus**. Wer sie später auch
15 * nur ansieht, erzeugt daneben `-wal` und `-shm` – und die bleiben nach dem
16 * Schließen liegen. Eine Sicherung, von der man zwei Dateien vergisst, ist
17 * genau der Fehler, den sie verhüten soll. `VACUUM INTO` schreibt eine
18 * einzelne Datei im `delete`-Modus; sie bleibt eine einzelne Datei.
19 * - `VACUUM INTO` bricht ab, wenn das Ziel schon existiert. Ein
20 * versehentliches Überschreiben ist damit technisch ausgeschlossen und
21 * nicht bloß durch Sorgfalt.
22 * - `VACUUM INTO` ist **synchron**. Der Hauptprozess ist einfädig; solange
23 * nichts abgewartet wird, kann sich kein anderer Kanal dazwischenschieben
24 * und die Datei in genau dem Augenblick öffnen, in dem sie ersetzt wird.
25 * `db.backup()` arbeitet über eine `setImmediate`-Schleife und risse dieses
26 * Fenster wieder auf.
27 *
28 * ## Warum eine blosse Dateikopie nicht genügt
29 *
30 * Der Lernstand läuft im WAL-Modus. Nachgemessen: Bei offener Verbindung und
31 * 5 000 geschriebenen Zeilen war `lernstand.db` **4 096 Byte** gross und das
32 * Schreibprotokoll `lernstand.db-wal` **1,1 MB**. Eine Kopie nur der
33 * Hauptdatei liess sich anstandslos öffnen, bestand `integrity_check` mit
34 * „ok“ – und enthielt **keine einzige Tabelle**. Nicht ein Teil fehlte,
35 * sondern alles. Wer so sichert, merkt es an dem Tag, an dem er die Sicherung
36 * braucht.
37 */
38
39 import { closeSync, openSync, readSync, renameSync, statSync, unlinkSync } from 'node:fs';
40
41 import type BetterSqlite3 from 'better-sqlite3';
42
43 import { entschaerft } from './eingaben';
44 import { SCHEMA_VERSION } from './schema';
45 import type { Kennzahlen } from '../shared/sicherung';
46
47 /**
48 * Kennung im Dateikopf, an der eine Sicherung dieser Anwendung zu erkennen
49 * ist – „WSKL“ als 32-Bit-Zahl.
50 *
51 * Ausschliesslich als Auskunft, **nie** als Ablehnungsgrund: Eine von Hand
52 * kopierte `lernstand.db` trägt hier 0 und muss trotzdem einspielbar bleiben.
53 */
54 export const ANWENDUNGSKENNUNG = 0x57534b4c;
55
56 /** Die ersten Bytes jeder SQLite-Datei. */
57 const SQLITE_KOPF = 'SQLite format 3\0';
58
59 /** Kleiner als das kann keine Datenbank sein: eine SQLite-Seite. */
60 const MINDESTGROESSE = 512;
61
62 /**
63 * Grösser als das ist kein Lernstand, sondern ein Versehen.
64 *
65 * Exportiert, weil `sicherung-dialoge.ts` die Grenze schon an der **Quelle**
66 * anlegt: Sonst wanderte ein versehentlich gewählter Film erst vollständig
67 * ins Programmverzeichnis und würde danach abgelehnt.
68 */
69 export const HOECHSTGROESSE = 512 * 1024 * 1024;
70
71 /**
72 * Tabellen, die es seit Schemafassung 1 gibt und die jeder Lernstand hat.
73 *
74 * `pruefung_lauf` und `pruefung_offen` fehlen hier mit Absicht – sie kamen
75 * erst mit Fassung 2 und 5. Eine echte alte Sicherung soll nicht daran
76 * scheitern, dass sie alt ist.
77 */
78 const PFLICHTTABELLEN = ['schema_version', 'profil', 'frage_stand', 'antwort_log'] as const;
79
80 export type Pruefbefund =
81 | {
82 readonly art: 'brauchbar';
83 readonly kennzahlen: Kennzahlen;
84 /**
85 * Die Profilnummern der Datei, in derselben Reihenfolge wie
86 * `kennzahlen.jeProfil`. Sie verlassen den Hauptprozess nie – der
87 * Renderer wählt über den Index.
88 */
89 readonly profilIds: readonly number[];
90 }
91 | { readonly art: 'abgelehnt'; readonly grund: string };
92
93 /** Konstruktor von better-sqlite3, so weit hier gebraucht. */
94 export type DatenbankKonstruktor = new (
95 pfad: string,
96 optionen?: BetterSqlite3.Options,
97 ) => BetterSqlite3.Database;
98
99 /**
100 * Schreibt eine Sicherung der laufenden Datenbank.
101 *
102 * Erst nach `.teil`, dann umbenennen: Ein Abbruch mittendrin hinterlässt
103 * damit nie eine halbe Datei unter dem richtigen Namen. Das Umbenennen im
104 * selben Verzeichnis ist der einzige Schritt, den das Betriebssystem
105 * unteilbar ausführt.
106 *
107 * @returns Grösse der geschriebenen Datei in Byte.
108 */
109 export function sicherungSchreiben(
110 db: BetterSqlite3.Database,
111 ziel: string,
112 Datenbank: DatenbankKonstruktor,
113 ): number {
114 const teil = `${ziel}.teil`;
115 aufraeumen(teil);
116
117 /* Gebundener Parameter statt eingesetztem Pfad – nachgemessen, dass SQLite
118 das bei VACUUM INTO annimmt. Ein Pfad mit Anführungszeichen im Namen
119 hätte sonst die Anweisung zerlegt. */
120 db.prepare('VACUUM INTO ?').run(teil);
121
122 /* Die Kennung wird auf einer SCHREIBENDEN Verbindung gesetzt; auf einer
123 nur lesenden wirft jedes schreibende Pragma. Gegengeprüft wird danach in
124 einer zweiten, ausdrücklich lesenden Verbindung – sonst prüfte dieselbe
125 Verbindung ihr eigenes Werk. */
126 const schreibend = new Datenbank(teil);
127 try {
128 schreibend.pragma(`application_id = ${String(ANWENDUNGSKENNUNG)}`);
129 schreibend.pragma(`user_version = ${String(SCHEMA_VERSION)}`);
130 } finally {
131 schreibend.close();
132 }
133
134 const befund = dateiPruefen(teil, Datenbank);
135 if (befund.art === 'abgelehnt') {
136 aufraeumen(teil);
137 throw new Error(
138 `Die Sicherung wurde geschrieben, hielt der Gegenprobe aber nicht stand: ${befund.grund}`,
139 );
140 }
141
142 const bytes = statSync(teil).size;
143 renameSync(teil, ziel);
144 return bytes;
145 }
146
147 /**
148 * Die Prüfkette, von billig nach teuer.
149 *
150 * Jede Stufe hat ihren Grund, und die wichtigste ist die fünfte: Eine Datei
151 * von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen.
152 * Sie durchliefe anschliessend die vollständige Migrationskette und stünde
153 * als tadelloser, **leerer** Lernstand da. Das Einspielen meldete Erfolg, und
154 * die Arbeit von Wochen wäre fort.
155 *
156 * Jeder Ablehnungsgrund endet auf denselben Satz: „Es wurde nichts
157 * verändert.“ Wer eine Fehlermeldung liest, will zuerst das wissen.
158 */
159 export function dateiPruefen(pfad: string, Datenbank: DatenbankKonstruktor): Pruefbefund {
160 const schluss = ' Es wurde nichts verändert.';
161
162 // ── Stufe 1: überhaupt eine Datei dieser Größenordnung? ──────────────
163 let groesse: number;
164 try {
165 const stand = statSync(pfad);
166 if (!stand.isFile()) {
167 return { art: 'abgelehnt', grund: `Das ist keine Datei.${schluss}` };
168 }
169 groesse = stand.size;
170 } catch {
171 return { art: 'abgelehnt', grund: `Diese Datei lässt sich nicht lesen.${schluss}` };
172 }
173
174 if (groesse < MINDESTGROESSE) {
175 return {
176 art: 'abgelehnt',
177 grund: `Diese Datei ist leer oder viel zu klein. Sie enthält keinen Lernstand.${schluss}`,
178 };
179 }
180 if (groesse > HOECHSTGROESSE) {
181 return {
182 art: 'abgelehnt',
183 grund:
184 `Diese Datei ist ${megabyte(groesse)} groß und kann kein Lernstand sein – ` +
185 `ein Lernstand ist wenige Megabyte groß.${schluss}`,
186 };
187 }
188
189 // ── Stufe 2: überhaupt SQLite? ───────────────────────────────────────
190 /* Nötig, weil eine Textdatei sich readonly ÖFFNEN lässt – nachgemessen;
191 erst die erste Abfrage wirft dann SQLITE_NOTADB. Ohne diese Stufe bekäme
192 ein umbenanntes Foto eine englische Datenbankmeldung. */
193 if (!hatSqliteKopf(pfad)) {
194 return {
195 art: 'abgelehnt',
196 grund:
197 'Diese Datei ist keine Datenbank, sondern etwas anderes – vielleicht ein Bild oder ' +
198 `ein Dokument. Sicherungen dieser Anwendung enden auf .wsklernstand.${schluss}`,
199 };
200 }
201
202 // ── Stufe 3 bis 8: auf einer nur lesenden Verbindung ─────────────────
203 let db: BetterSqlite3.Database;
204 try {
205 db = new Datenbank(pfad, { readonly: true, fileMustExist: true });
206 } catch {
207 return {
208 art: 'abgelehnt',
209 grund: `Diese Datei lässt sich nicht lesen. Bitte prüfen Sie die Zugriffsrechte.${schluss}`,
210 };
211 }
212
213 try {
214 // Stufe 4: heil? `integrity_check` WIRFT bei Beschädigung, statt einen
215 // Wert zu liefern – nachgemessen. Beide Wege müssen behandelt werden.
216 let heil = false;
217 try {
218 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
219 heil = zeilen.length === 1 && zeilen[0]?.integrity_check === 'ok';
220 } catch {
221 heil = false;
222 }
223 if (!heil) {
224 return {
225 art: 'abgelehnt',
226 grund:
227 'Diese Datei ist unvollständig oder beschädigt. Möglicherweise ist das Herunterladen ' +
228 `oder das Kopieren abgebrochen.${schluss}`,
229 };
230 }
231
232 // Stufe 5: ein Lernstand DIESER Anwendung?
233 const tabellen = new Set(
234 db
235 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
236 .all()
237 .map((zeile) => zeile.name),
238 );
239 const fehlend = PFLICHTTABELLEN.filter((name) => !tabellen.has(name));
240 if (fehlend.length > 0) {
241 return {
242 art: 'abgelehnt',
243 grund:
244 'Diese Datei ist zwar eine SQLite-Datenbank, aber kein Lernstand dieser Anwendung – ' +
245 `es fehlen die Tabellen ${fehlend.join(' und ')}.${schluss}`,
246 };
247 }
248
249 const profile = db
250 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
251 .get();
252 if ((profile?.anzahl ?? 0) < 1) {
253 return {
254 art: 'abgelehnt',
255 grund:
256 'Diese Datei enthält kein einziges Lernprofil und kann deshalb kein Lernstand ' +
257 `dieser Anwendung sein.${schluss}`,
258 };
259 }
260
261 // Stufe 6: Schemafassung.
262 const fassung =
263 db
264 .prepare<[], { version: number | null }>(
265 'SELECT MAX(version) AS version FROM schema_version',
266 )
267 .get()?.version ?? 0;
268 if (fassung < 1) {
269 return {
270 art: 'abgelehnt',
271 grund:
272 'Diese Datei nennt keine Schemafassung und ist damit kein vollständiger ' +
273 `Lernstand.${schluss}`,
274 };
275 }
276 if (fassung > SCHEMA_VERSION) {
277 return {
278 art: 'abgelehnt',
279 grund:
280 `Diese Sicherung stammt aus einer neueren Fassung des Programms (Schema ${String(fassung)}, ` +
281 `dieses Programm kennt ${String(SCHEMA_VERSION)}). Bitte zuerst das Programm ` +
282 `aktualisieren.${schluss}`,
283 };
284 }
285
286 // Stufe 7: hängt es zusammen?
287 /* `integrity_check` prüft die Baumstruktur, nicht die Beziehungen. Die
288 laufende Datenbank arbeitet mit `foreign_keys = ON`; eine Datei mit
289 verwaisten Zeilen fiele später an beliebiger Stelle auf. */
290 const verwaist = db.pragma('foreign_key_check') as unknown[];
291 if (verwaist.length > 0) {
292 return {
293 art: 'abgelehnt',
294 grund:
295 'Diese Datei ist beschädigt: Sie enthält Einträge, die auf ein Profil verweisen, ' +
296 `das es darin nicht gibt.${schluss}`,
297 };
298 }
299
300 // Stufe 8: Zahlen für die Rückfrage – kein Ablehnungsgrund mehr.
301 return {
302 art: 'brauchbar',
303 kennzahlen: kennzahlenLesen(db, fassung, tabellen),
304 profilIds: db
305 .prepare<[], { id: number }>('SELECT id FROM profil ORDER BY id')
306 .all()
307 .map((z) => z.id),
308 };
309 } finally {
310 // Stufe 9: sonst bleibt die Datei unter Windows gesperrt und die
311 // Arbeitskopie liesse sich weder löschen noch umbenennen.
312 db.close();
313 }
314 }
315
316 /**
317 * Die Zahlen, die in der Rückfrage stehen.
318 *
319 * Profilnamen sind Fremdeingabe und laufen deshalb durch `entschaerft()`:
320 * Zeichen zur Schreibrichtung könnten in einer Rückfrage sonst das Gegenteil
321 * dessen anzeigen, was dort steht.
322 */
323 /** Spaltennamen einer Tabelle – ältere Sicherungen haben nicht alle. */
324 function spalten(db: BetterSqlite3.Database, tabelle: string): ReadonlySet<string> {
325 const zeilen = db.prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`).all();
326 return new Set(zeilen.map((z) => z.name));
327 }
328
329 export function kennzahlenLesen(
330 db: BetterSqlite3.Database,
331 schemafassung: number,
332 tabellen: ReadonlySet<string>,
333 ): Kennzahlen {
334 const namen = db
335 .prepare<[], { name: string }>('SELECT name FROM profil ORDER BY id')
336 .all()
337 .map((zeile) => entschaerft(zeile.name));
338
339 /*
340 Zeilen im Antwortprotokoll – und ausdrücklich nur die, die jemand wirklich
341 beantwortet hat.
342
343 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
344 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
345 aufgeschlagen wurden. Sie gehören in die Historie – sie standen im Bogen –,
346 aber nicht in eine Zahl, die „beantwortete Fragen“ heisst und über die
347 jemand eine nicht rücknehmbare Entscheidung trifft. Derselbe Befund wie
348 docs/stand.md 7.3, nur an einer zweiten Stelle.
349
350 Ältere Sicherungen haben die Spalte nicht; dort zählt alles, und das ist
351 richtig so – rückwirkend liesse sich nicht ermitteln, welche Zeile nie
352 gestellt wurde.
353 */
354 const hatNurHistorie = spalten(db, 'antwort_log').has('nur_historie');
355 const antworten =
356 db
357 .prepare<[], { anzahl: number }>(
358 hatNurHistorie
359 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0'
360 : 'SELECT COUNT(*) AS anzahl FROM antwort_log',
361 )
362 .get()?.anzahl ?? 0;
363 const letzte =
364 db
365 .prepare<[], { zeitpunkt: string | null }>(
366 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log',
367 )
368 .get()?.zeitpunkt ?? null;
369 const gemerkt =
370 db
371 .prepare<[], { anzahl: number }>(
372 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE gemerkt = 1',
373 )
374 .get()?.anzahl ?? 0;
375
376 /* Nur ab Schemafassung 2 beziehungsweise 5 – ältere Sicherungen haben die
377 Tabellen nicht, und ihr Fehlen ist kein Fehler. */
378 const pruefungslaeufe = tabellen.has('pruefung_lauf')
379 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_lauf').get()
380 ?.anzahl ?? 0)
381 : 0;
382 const offenerBogen = tabellen.has('pruefung_offen')
383 ? (db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM pruefung_offen').get()
384 ?.anzahl ?? 0) > 0
385 : false;
386
387 /* Je Profil, damit sich vergleichen lässt statt nur zu summieren. Die
388 Namen sind der einzige Anker: Die Nummern werden auf jedem Gerät
389 unabhängig vergeben und sagen über die Zugehörigkeit nichts. */
390 const jeProfil = db
391 .prepare<[], { id: number; name: string }>('SELECT id, name FROM profil ORDER BY id')
392 .all()
393 .map((profil) => {
394 const zaehle = (sql: string): number =>
395 db.prepare<[number], { anzahl: number }>(sql).get(profil.id)?.anzahl ?? 0;
396 return {
397 name: entschaerft(profil.name),
398 antworten: zaehle(
399 hatNurHistorie
400 ? 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ? AND nur_historie = 0'
401 : 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE profil_id = ?',
402 ),
403 gemerkt: zaehle(
404 'SELECT COUNT(*) AS anzahl FROM frage_stand WHERE profil_id = ? AND gemerkt = 1',
405 ),
406 pruefungslaeufe: tabellen.has('pruefung_lauf')
407 ? zaehle('SELECT COUNT(*) AS anzahl FROM pruefung_lauf WHERE profil_id = ?')
408 : 0,
409 letzteAntwort:
410 db
411 .prepare<[number], { zeitpunkt: string | null }>(
412 'SELECT MAX(zeitpunkt) AS zeitpunkt FROM antwort_log WHERE profil_id = ?',
413 )
414 .get(profil.id)?.zeitpunkt ?? null,
415 };
416 });
417
418 return {
419 profilnamen: namen,
420 jeProfil,
421 antworten,
422 letzteAntwort: letzte,
423 gemerkt,
424 pruefungslaeufe,
425 offenerBogen,
426 schemafassung,
427 };
428 }
429
430 /** Dateiname einer Sicherung, mit Datum und Uhrzeit auf die Sekunde genau. */
431 export function sicherungsDateiname(jetzt: Date, vorsatz = 'Waffensachkunde-Lernstand'): string {
432 const z = (wert: number, stellen = 2): string => String(wert).padStart(stellen, '0');
433 const stempel =
434 `${z(jetzt.getFullYear(), 4)}-${z(jetzt.getMonth() + 1)}-${z(jetzt.getDate())}` +
435 `-${z(jetzt.getHours())}${z(jetzt.getMinutes())}${z(jetzt.getSeconds())}`;
436 return `${vorsatz}-${stempel}.wsklernstand`;
437 }
438
439 /**
440 * Löscht eine Datenbankdatei samt ihrer Nebendateien.
441 *
442 * `-wal` und `-shm` kamen bis Fassung 0.19.0 nicht mit. Nachgemessen ist das
443 * im heutigen Ablauf **harmlos**: Die Arbeitskopie wird nur lesend geöffnet,
444 * das zurückbleibende `-wal` ist 0 Byte gross, und der nächste Durchgang
445 * überliest es folgenlos – gemessen liest er die richtige Datei mit den
446 * richtigen Zahlen.
447 *
448 * Harmlos, aber nicht ungefährlich. Läge dort je ein **gefülltes** `-wal`,
449 * bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, und
450 * `integrity_check` meldete dazu „ok“ – nachgestellt und bestätigt: erwartet
451 * wurden 900 Zeilen, gelesen wurden 5000 aus der anderen Datei. Im Durchgang
452 * danach war die Datei unbrauchbar („database disk image is malformed“).
453 *
454 * Ein gefülltes `-wal` entsteht, sobald die Arbeitskopie **schreibend**
455 * geöffnet wird – genau das braucht das Übernehmen eines Profils aus einer
456 * älteren Sicherung. Diese Zeilen stehen deshalb hier, bevor der erste
457 * schreibende Zugriff dazukommt, und nicht danach.
458 */
459 export function aufraeumen(pfad: string): void {
460 loeschen(pfad);
461 nebendateienAufraeumen(pfad);
462 }
463
464 /**
465 * Räumt **nur** `-wal` und `-shm` weg, nicht die Datei selbst.
466 *
467 * Für den einen Fall, in dem das Ziel stehen bleiben muss, bis sein Ersatz
468 * vollständig geschrieben ist: beim Anlegen einer Sicherung über eine
469 * vorhandene. Dort erledigt `renameSync` das Ersetzen unteilbar, und ein
470 * vorheriges Löschen hätte im Fehlerfall beide Fassungen gekostet – siehe
471 * `main/sicherung-dialoge.ts`.
472 */
473 export function nebendateienAufraeumen(pfad: string): void {
474 loeschen(`${pfad}-wal`);
475 loeschen(`${pfad}-shm`);
476 }
477
478 function loeschen(datei: string): void {
479 try {
480 unlinkSync(datei);
481 } catch {
482 /* Nicht da, oder gesperrt. Beides ist hier kein Grund abzubrechen –
483 der Aufrufer prüft anschliessend ohnehin, was er braucht. */
484 }
485 }
486
487 function hatSqliteKopf(pfad: string): boolean {
488 let griff: number;
489 try {
490 griff = openSync(pfad, 'r');
491 } catch {
492 return false;
493 }
494 try {
495 const puffer = Buffer.alloc(SQLITE_KOPF.length);
496 const gelesen = readSync(griff, puffer, 0, puffer.length, 0);
497 return gelesen === puffer.length && puffer.toString('latin1') === SQLITE_KOPF;
498 } finally {
499 closeSync(griff);
500 }
501 }
502
503 /** „1,5 MB“ – dieselbe Schreibweise in jeder Ablehnung. */
504 export function megabyte(bytes: number): string {
505 return `${(bytes / (1024 * 1024)).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`;
506 }