waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | } |