import type BetterSqlite3 from 'better-sqlite3'; /** * Datenbankschema des Lernstands. * * Bewusst als TypeScript-Konstante und nicht als `.sql`-Datei: der * Main-Prozess wird von electron-vite gebündelt, eine Textdatei müsste * zusätzlich als Ressource mitgepackt und zur Laufzeit gefunden werden. * Eine Konstante ist Teil des Bundles und kann nicht fehlen. * * Alle Anweisungen sind idempotent (`IF NOT EXISTS`) – das Schema wird bei * jedem Öffnen der Datenbank angewandt. Echte Migrationen (Spalten ändern, * Daten umschreiben) laufen später über {@link MIGRATIONEN}. */ /** Aktueller Stand des Schemas. Wird in `schema_version` festgehalten. */ export const SCHEMA_VERSION = 10; /** * Ein Tag in Millisekunden. * * Steht hier und nicht in `lernstand.ts`, weil die Migration ihn braucht und * `schema.ts` nichts aus `lernstand.ts` importieren darf – die Abhängigkeit * läuft genau andersherum. */ export const TAG_MS = 86_400_000; /** * Schema-Version 2: gespeicherte Läufe der Prüfungssimulation. * * Steht als eigene Konstante, weil dieselbe Anweisung an zwei Stellen * gebraucht wird – im Grundschema für neue Datenbanken und als * Migrationsschritt für bestehende. Ausschließlich `CREATE … IF NOT EXISTS`: * ein vorhandener Lernstand wird dabei nicht angefasst. * * Die Einzelantworten stehen weiterhin in `antwort_log`; hier liegt nur die * Zusammenfassung eines Laufs, die der Verlauf anzeigt. */ const PRUEFUNG_LAUF_SCHEMA = ` CREATE TABLE IF NOT EXISTS pruefung_lauf ( id INTEGER PRIMARY KEY AUTOINCREMENT, profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE, -- ID des Prüfungsprofils aus src/shared/pruefung.ts, z. B. 'dsb'. -- Bewusst als Text und ohne Fremdschlüssel: die Profile stehen im Quelltext, -- nicht in der Datenbank, und ein alter Lauf soll auch dann lesbar bleiben, -- wenn ein Profil später umbenannt oder entfernt wird. pruefungsprofil TEXT NOT NULL, zeitpunkt TEXT NOT NULL, gesamt INTEGER NOT NULL CHECK (gesamt >= 0), richtig INTEGER NOT NULL CHECK (richtig >= 0), quote REAL NOT NULL CHECK (quote >= 0 AND quote <= 1), urteil TEXT NOT NULL CHECK ( urteil IN ('bestanden', 'nachpruefung', 'nicht_bestanden') ), dauer_ms INTEGER NOT NULL CHECK (dauer_ms >= 0) ); CREATE INDEX IF NOT EXISTS idx_pruefung_lauf_profil_zeit ON pruefung_lauf (profil_id, zeitpunkt); `; /** * Schema-Version 5: der laufende, noch nicht abgegebene Prüfungsbogen. * * **Warum das in der Datenbank steht.** Ein Bogen läuft bis zu zwei Stunden. * Er lag bisher ausschließlich im Arbeitsspeicher des Renderers; wer bei * Minute 90 das Fenster schloss, verlor alles ohne Rückfrage. Das trifft * gerade die Gruppe, die für denselben Bogen länger braucht, und widerspricht * der eigenen Zusage zu WCAG 2.2.6. * * **Warum der Kern die Zeile schreibt und nicht der Renderer.** `bogen`, * `auftrag` und `profil` stammen aus `Pruefung.starten` – der Renderer bekommt * sie, schickt sie aber nie zurück. Andernfalls wäre die einzige Prüfung der * eingereichten Antwortliste dahin: Wer den Bogen selbst mitbringt, kann ihn * auch erfinden. Der Renderer sichert nur, was ihm gehört: Eingaben, * Position, Phase und die verbrauchte Zeit. * * **Warum `lauf_id`.** Das Sichern ist entprellt. Ohne eine Kennung könnte * ein verspäteter Nachzügler eine Zeile wiederauferstehen lassen, die die * Auswertung gerade gelöscht hat – und dieselbe Prüfung ließe sich ein * zweites Mal abgeben. Gesichert wird deshalb ausschließlich per `UPDATE` * mit `lauf_id`; findet es keine Zeile, ist der Lauf vorbei und die Sicherung * verfällt still. * * **Eine Zeile je Profil.** Mehr als ein offener Bogen gleichzeitig wäre * keine Simulation, sondern eine Ablage. * * `profil` hält das *wirksame* Prüfungsprofil als JSON, also mit den * tatsächlich gewählten Werten für Zeit, Bestehensgrenze und Fehlergrenze. * Aus demselben Grund wie bei `pruefung_lauf.pruefungsprofil`: Die Profile * stehen im Quelltext, nicht in der Datenbank, und ein fortgesetzter Lauf * muss unter denselben Vorgaben zu Ende gehen, unter denen er begonnen hat. */ const PRUEFUNG_OFFEN_SCHEMA = ` CREATE TABLE IF NOT EXISTS pruefung_offen ( profil_id INTEGER PRIMARY KEY REFERENCES profil(id) ON DELETE CASCADE, -- Kennung dieses Laufs; schuetzt gegen verspaetete Sicherungen. lauf_id TEXT NOT NULL, -- JSON, vom Kern geschrieben: Auftrag, wirksames Profil, gezogener Bogen. auftrag TEXT NOT NULL, profil TEXT NOT NULL, bogen TEXT NOT NULL, -- JSON, vom Renderer gesichert: je Frage Auswahl, Freitext, Selbstbewertung. eingaben TEXT NOT NULL DEFAULT '{}', position INTEGER NOT NULL DEFAULT 0 CHECK (position >= 0), phase TEXT NOT NULL DEFAULT 'bearbeiten' CHECK ( phase IN ('bearbeiten', 'nachbewertung') ), -- Ob die Zeit bereits abgelaufen war. Ohne diese Spalte meldete eine -- wiederhergestellte Nachbewertung faelschlich einen Lauf ohne Zeitablauf. zeit_abgelaufen INTEGER NOT NULL DEFAULT 0 CHECK (zeit_abgelaufen IN (0, 1)), -- Verbrauchte Bearbeitungszeit. Waehrend das Programm zu ist, steht die -- Uhr still; beim Fortsetzen wird der Zeitursprung um diesen Wert -- zurueckdatiert, damit Restzeit und Bearbeitungsdauer aus derselben -- Rechnung stammen. verbraucht_ms INTEGER NOT NULL DEFAULT 0 CHECK (verbraucht_ms >= 0), begonnen_am TEXT NOT NULL, gesichert_am TEXT NOT NULL ); `; /** * Schema-Version 9: der Katalogstand, unter dem dieser Lernstand geführt wird. * * `frage_stand` und `antwort_log` verweisen mit Frage-IDs wie „I.1-01“ auf den * Katalog – bis Fassung 8 ohne Vermerk, gegen welchen BVA-Stand sie * entstanden. Veröffentlicht das Bundesverwaltungsamt eine Fassung mit * geänderter Nummerierung, zeigen Zeilen zu verschwundenen IDs still ins * Leere. Dieser Vermerk macht das erkennbar: Beim Öffnen vergleicht der * Lernstand den gespeicherten mit dem geladenen Stand und meldet eine * Abweichung, statt zu schweigen (`katalogstandAbgleichen` in `lernstand.ts`). * * Wie `schema_version` eine Fortschreibung: Jeder übernommene Stand bekommt * eine Zeile, maßgeblich ist die zuletzt eingetragene. Bewusst kein * Schlüssel auf `stand`: Wer zu einer älteren Programmfassung zurückkehrt, * kehrt zu einem schon dagewesenen Stand zurück – auch das ist ein Wechsel * und braucht eine neue Zeile. * * Die Migration legt die Tabelle nur an, und zwar **leer**. Den Ausgangswert * trägt der Lernstand beim Öffnen ein, denn die Migration kennt den geladenen * Katalog nicht – und ehrlich wäre ein anderer Wert ohnehin nicht zu haben: * Gegen welchen Stand die vorhandenen Zeilen wirklich entstanden, wurde nie * festgehalten und lässt sich nicht rekonstruieren. Der beim ersten Öffnen * geladene Stand ist die beste verfügbare Annahme; ab dann steht jeder * Wechsel schwarz auf weiß. */ const KATALOG_STAND_SCHEMA = ` CREATE TABLE IF NOT EXISTS katalog_stand ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- ISO-Datum aus meta.stand des Katalogs, z. B. '2024-12-16'. stand TEXT NOT NULL, vermerkt_am TEXT NOT NULL ); `; /** * Ergänzt eine Spalte, falls sie noch fehlt. * * SQLite kennt kein `ADD COLUMN IF NOT EXISTS`. Der Migrationslauf allein * würde genügen – er führt jeden Schritt genau einmal aus –, verlässt sich * dabei aber darauf, dass `schema_version` und die tatsächliche Tabelle * zusammenpassen. Tun sie das einmal nicht, scheitert der Schritt beim * Öffnen, und die Anwendung startet überhaupt nicht mehr. * * Der Blick in `PRAGMA table_info` kostet nichts und macht daraus einen * Schritt, der beliebig oft laufen darf. */ function spalteErgaenzen( db: BetterSqlite3.Database, tabelle: string, spalte: string, typ: string, ): void { /* Tabellen- und Spaltennamen lassen sich nicht als Parameter binden. Beide stammen hier ausschließlich aus Konstanten dieser Datei, nie aus einer Eingabe – die Prüfung hält das fest, damit es so bleibt. */ if (!/^[a-z_][a-z0-9_]*$/u.test(tabelle) || !/^[a-z_][a-z0-9_]*$/u.test(spalte)) { throw new Error(`Ungültiger Bezeichner in der Migration: ${tabelle}.${spalte}`); } const vorhanden = db .prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`) .all() .some((zeile) => zeile.name === spalte); if (!vorhanden) { db.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`); } } /** * Schema-Version 3: Gedächtnisstand nach FSRS. * * `frage_stand` bekommt zwei Spalten, die das Gedächtnismodell aus * `shared/fsrs.ts` fortschreibt: Stabilität in Tagen und Schwierigkeit * zwischen 1 und 10. Beide sind `NULL`, solange die Frage nie beantwortet * wurde – das ist die Unterscheidung zwischen „noch nie gesehen“ und * „gesehen, aber vergessen“. * * Als Migration und nicht im Grundschema: Eine frisch angelegte Datenbank * startet ebenfalls bei Version 0 und durchläuft alle Schritte. Stünden die * Spalten zusätzlich im Grundschema, liefe dieser Schritt in eine bereits * vorhandene Spalte. * * Bestehende Lernstände verlieren nichts. Ihre Fragen starten ohne * Gedächtnisstand und bekommen ihn bei der nächsten Antwort; bis dahin bleibt * `faellig_ab` unverändert gültig. */ function fsrsSpalten(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'frage_stand', 'stabilitaet', 'REAL'); spalteErgaenzen(db, 'frage_stand', 'schwierigkeit', 'REAL'); } /** * Schema-Version 4: die gewählte Zeitstufe eines Simulationslaufs. * * Ohne sie stehen ein Lauf ohne Uhr und ein Lauf unter Zeitdruck in der * Verlaufstabelle mit identischen Spalten nebeneinander – ausdrücklich zum * Vergleich eingeladen, obwohl sie nicht vergleichbar sind. * * Bestehende Läufe bekommen `NULL`: Ihre Zeitstufe ist nicht mehr zu * ermitteln, und sie zu raten wäre schlechter, als sie offen zu lassen. */ function zeitmodusSpalte(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'pruefung_lauf', 'zeitmodus', 'TEXT'); } /** * Schema-Version 6: Welche Kapitel ein Profil dauerhaft abwählt. * * Kapitel IV („Not- und Seenotsignalmittel“) prüft nicht jede Prüfungsstelle. * Wer es nie braucht, schleppte bisher 89 der 575 Fragen durch jede Zahl der * Anwendung – Fortschritt, Tagespensum, Prognose, Prüfungsreife waren für ihn * dauerhaft falsch. Abwählen ging nur je Simulationslauf und wirkte nur auf * den gezogenen Bogen. * * **Warum die Spalte hier steht und nicht im Grundschema.** Weil sie sonst * auf bestehenden Lernständen niemals entstünde: `CREATE TABLE IF NOT EXISTS` * ist dort ein reiner Leerlauf, und das erste `SELECT` auf die Spalte * scheiterte mit „no such column“. Kein Test im Projekt bemerkte das – alle * legen frische Datenbanken an. `pruefungstermin` steht seit dem ersten * Commit im `CREATE TABLE` und ist deshalb **kein** Vorbild; das Vorbild ist * `zeitmodusSpalte` eine Ebene darüber. * * **Warum `TEXT NOT NULL DEFAULT '[]'`.** Der Vorgabewert füllt bestehende * Zeilen beim `ADD COLUMN` auf und bedient zugleich `profilEinfuegen`, das * seine Spalten einzeln nennt und die neue nicht kennt. `NOT NULL` ohne * Vorgabe verbietet SQLite bei `ADD COLUMN` ohnehin. * * **Warum eine Liste und kein Wahrheitswert.** Heute ist nur Kapitel IV * abwählbar. Ein `boolean` müsste beim nächsten Kapitel wieder migriert * werden; eine Liste von Kapitel-IDs trägt den Fall ohne Schemaschritt mit. */ function kapitelAusschlussSpalte(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'profil', 'kapitel_ausschluss', "TEXT NOT NULL DEFAULT '[]'"); } /** Ein Migrationsschritt: SQL oder eine Funktion auf der Datenbank. */ export type Migration = string | ((db: BetterSqlite3.Database) => void); export const LERNSTAND_SCHEMA = ` -- Fortschreibung des Schemas. Jede angewandte Version bekommt eine Zeile, -- sodass sich später nachvollziehen lässt, wann was migriert wurde. CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL PRIMARY KEY, angewendet_am TEXT NOT NULL ); -- Lernprofile. Mehrere Personen teilen sich ein Gerät, ohne dass die -- Lernstände sich vermischen. CREATE TABLE IF NOT EXISTS profil ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, pruefungstermin TEXT, erstellt_am TEXT NOT NULL ); -- Aktueller Stand je Frage und Profil. Diese Tabelle ist eine -- Zusammenfassung; die Wahrheit steht in antwort_log. CREATE TABLE IF NOT EXISTS frage_stand ( profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE, frage_id TEXT NOT NULL, versuche INTEGER NOT NULL DEFAULT 0, richtige INTEGER NOT NULL DEFAULT 0, zuletzt_beantwortet TEXT, faellig_ab TEXT, gemerkt INTEGER NOT NULL DEFAULT 0 CHECK (gemerkt IN (0, 1)), letzte_bewertung TEXT CHECK ( letzte_bewertung IS NULL OR letzte_bewertung IN ('nochmal', 'schwer', 'gut', 'leicht') ), -- Zuletzt vergebenes Wiedervorlage-Intervall in Tagen. Ergebnis der -- FSRS-Rechnung, nur zur Anzeige; maßgeblich ist faellig_ab. intervall_tage REAL NOT NULL DEFAULT 0, PRIMARY KEY (profil_id, frage_id) ); -- Vollständige Historie. Wird niemals überschrieben und ist die Grundlage -- für Statistik und für ein späteres Nachtrainieren der FSRS-Parameter. CREATE TABLE IF NOT EXISTS antwort_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE, frage_id TEXT NOT NULL, zeitpunkt TEXT NOT NULL, richtig INTEGER NOT NULL CHECK (richtig IN (0, 1)), bewertung TEXT NOT NULL CHECK (bewertung IN ('nochmal', 'schwer', 'gut', 'leicht')), dauer_ms INTEGER NOT NULL CHECK (dauer_ms >= 0), -- JSON-Array der gewählten Antwortlabels, z. B. ["a","c"]. auswahl TEXT NOT NULL DEFAULT '[]', freitext TEXT ); CREATE INDEX IF NOT EXISTS idx_antwort_log_profil_zeit ON antwort_log (profil_id, zeitpunkt); CREATE INDEX IF NOT EXISTS idx_antwort_log_profil_frage ON antwort_log (profil_id, frage_id, id); CREATE INDEX IF NOT EXISTS idx_frage_stand_faellig ON frage_stand (profil_id, faellig_ab); CREATE INDEX IF NOT EXISTS idx_frage_stand_gemerkt ON frage_stand (profil_id, gemerkt); ${PRUEFUNG_LAUF_SCHEMA}${PRUEFUNG_OFFEN_SCHEMA}${KATALOG_STAND_SCHEMA}`; /** * Migrationsschritte oberhalb von Version 1. * * Der Schlüssel ist die Zielversion, der Wert entweder auszuführendes SQL * oder eine Funktion – Letzteres für Schritte, die vorher etwas nachsehen * müssen. Beim Öffnen werden alle Schritte oberhalb der gespeicherten Version * der Reihe nach in einer Transaktion angewandt. * * Jeder Schritt muss so geschrieben sein, dass er auch dann durchläuft, wenn * sein Ergebnis bereits vorliegt. Sonst wäre eine einmal aus dem Tritt * geratene Datenbank dauerhaft nicht mehr zu öffnen. * * Version 2 legt `pruefung_lauf` an, Version 3 ergänzt `frage_stand` um den * Gedächtnisstand, Version 4 die Zeitstufe eines Laufs, Version 5 den * unterbrochenen Bogen, Version 6 die dauerhaft abgewählten Kapitel eines * Profils, Version 7 den belegten Abruf je Frage, Version 8 die Zeilen, * die nur in die Historie gehören, Version 9 den Vermerk des Katalogstands, * Version 10 die Kennzahlen, ohne die sich zwei Läufe nicht ehrlich * vergleichen lassen. Bestehende Lernstände behalten dabei alle Daten: es * kommen nur Tabellen und Spalten hinzu, nichts wird geändert oder gelöscht. * * **Tabellen dürfen doppelt stehen, Spalten nicht.** Version 2, 5 und 9 legen * Tabellen an und erscheinen deshalb auch im Grundschema – `CREATE TABLE IF * NOT EXISTS` tut auf einer Bestandsdatenbank noch etwas. Für eine Spalte * gilt das nicht: Sie gehört ausschließlich hierher. */ /** * Schema-Version 7: der belegte Abruf je Frage. * * Siehe `shared/reife.ts` zur Regel selbst. Hier zählt nur, dass sie sich für * bestehende Lernstände **exakt nachbilden** lässt: `antwort_log` ist die * vollständige Historie und wird nie überschrieben – der Kommentar über * `frage_stand` sagt es selbst, dort stehe nur die Zusammenfassung, „die * Wahrheit steht in antwort_log“. * * Nachgespielt wird je Frage in Antwortreihenfolge, mit derselben Regel, die * künftig beim Antworten gilt: Eine richtige Antwort mit mindestens einem Tag * Abstand setzt den Beleg, eine falsche nimmt ihn weg, eine richtige am * selben Tag lässt ihn stehen. Kein Schätzen, kein Vorgabewert – wer seinen * Stand ehrlich erarbeitet hat, behält ihn auf die Frage genau. * * Die Schleife läuft über alle Zeilen aller Profile. Bei 575 Fragen und * einigen tausend Antworten ist das eine Sache von Millisekunden, und sie * geschieht genau einmal. */ function belegSpalte(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'frage_stand', 'bestaetigt', 'INTEGER NOT NULL DEFAULT 0'); const antworten = db .prepare<[], { profil_id: number; frage_id: string; zeitpunkt: string; richtig: number }>( `SELECT profil_id, frage_id, zeitpunkt, richtig FROM antwort_log ORDER BY profil_id, frage_id, id`, ) .all(); /* Verschachtelt statt zusammengesetzter Schluessel: Ein Trennzeichen muesste garantiert in keiner Frage-ID vorkommen, und die naheliegende Wahl - das Nullzeichen - hat in diesem Projekt schon einmal eine Quelldatei fuer git zur Binaerdatei gemacht. */ const beleg = new Map>(); let laufendesProfil = -1; let laufendeFrage = ''; let vorigerZeitpunkt = Number.NaN; for (const zeile of antworten) { if (zeile.profil_id !== laufendesProfil || zeile.frage_id !== laufendeFrage) { laufendesProfil = zeile.profil_id; laufendeFrage = zeile.frage_id; vorigerZeitpunkt = Number.NaN; } const jetzt = Date.parse(zeile.zeitpunkt); const abstandTage = Number.isNaN(vorigerZeitpunkt) ? 0 : (jetzt - vorigerZeitpunkt) / TAG_MS; let jeProfil = beleg.get(zeile.profil_id); if (jeProfil === undefined) { jeProfil = new Map(); beleg.set(zeile.profil_id, jeProfil); } if (zeile.richtig === 0) { jeProfil.set(zeile.frage_id, false); } else if (abstandTage >= 1) { jeProfil.set(zeile.frage_id, true); } if (!Number.isNaN(jetzt)) { vorigerZeitpunkt = jetzt; } } const setzen = db.prepare<[number, string]>( 'UPDATE frage_stand SET bestaetigt = 1 WHERE profil_id = ? AND frage_id = ?', ); for (const [profilId, jeProfil] of beleg) { for (const [frageId, belegt] of jeProfil) { if (belegt) { setzen.run(profilId, frageId); } } } } /** * Schema-Version 8: Zeilen, die nur in die Historie gehören. * * Läuft in der Prüfungssimulation die Zeit ab, kommen Fragen im Bogen vor, die * nie aufgeschlagen wurden. Sie gehören in die Historie – der Bogen enthielt * sie ja –, dürfen den Lernstand aber nicht zurückstufen. Deshalb schreibt * `protokollieren()` sie in `antwort_log`, ohne `frage_stand` anzufassen. * * Die Tagesbilanz zählte sie trotzdem mit, und zwar als falsch beantwortet: * „Heute beantwortet: 16 richtig, 40 falsch“ nach einem abgelaufenen Bogen, * von dem jemand 16 Fragen gesehen hatte. Unterscheiden liess sich das an * nichts – eine falsch beantwortete offene Frage sieht in jeder Spalte * genauso aus. * * Bestehende Zeilen bekommen 0 und zählen weiter mit. Rückwirkend liesse es * sich nicht ermitteln, und zu raten wäre schlechter, als es stehen zu lassen * und zu sagen. */ function nurHistorieSpalte(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'antwort_log', 'nur_historie', 'INTEGER NOT NULL DEFAULT 0'); } /** * Schema-Version 10: was ein Simulationslauf über sein Ergebnis hinaus sagt. * * Ein gespeicherter Lauf trug bis Fassung 9 nur Zahl, Treffer, Quote und * Urteil. Das genügt für eine Liste, aber nicht für einen **ehrlichen** * Vergleich zweier Läufe: * * - `quote` zählt unbeantwortete Fragen wie falsch beantwortete. Ein Lauf, in * dem die Zeit ablief, sieht darin wie ein Wissenseinbruch aus. Ohne * `unbeantwortet` und `zeit_abgelaufen` kann die Anwendung das nicht sagen – * und ein Vergleichssatz, der es verschweigt, ist schlimmer als keiner. * - Die Themenanalyse gab es nur je Einzellauf und nur im Arbeitsspeicher. * Dass ein Bereich in drei von vier Simulationen unter der Grenze lag, war * nirgends zu sehen. `bereiche` hält sie als JSON fest, so wie sie in der * Auswertung stand. * - `bestehens_quote` ist der Maßstab, an dem ein Bereich gemessen wird. Er * muss mitgespeichert werden, weil er sich später nicht mehr ermitteln * lässt: Beim frei eingestellten Profil sind die gewählten Werte nach dem * Lauf fort, und ein Fehlerpunkte-Profil hat gar keine Quote, sondern eine * umgerechnete (siehe `grenzquote` in `shared/pruefung.ts`). * * **Alle vier Spalten sind bewusst `NULL`-fähig.** Bestehende Läufe bekommen * keinen Ersatzwert. `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine * Behauptung über Läufe, von denen niemand weiß, wie sie endeten. `NULL` heißt * „nicht festgehalten“, und die Oberfläche sagt genau das. Aus `antwort_log` * ließe es sich auch nicht zurückrechnen: Dort steht nicht, welcher Lauf * welche Zeile geschrieben hat. */ function laufKennzahlenSpalten(db: BetterSqlite3.Database): void { spalteErgaenzen(db, 'pruefung_lauf', 'unbeantwortet', 'INTEGER'); spalteErgaenzen(db, 'pruefung_lauf', 'zeit_abgelaufen', 'INTEGER'); spalteErgaenzen(db, 'pruefung_lauf', 'bereiche', 'TEXT'); spalteErgaenzen(db, 'pruefung_lauf', 'bestehens_quote', 'REAL'); } export const MIGRATIONEN: Readonly> = Object.freeze({ 2: PRUEFUNG_LAUF_SCHEMA, 3: fsrsSpalten, 4: zeitmodusSpalte, 5: PRUEFUNG_OFFEN_SCHEMA, 6: kapitelAusschlussSpalte, 7: belegSpalte, 8: nurHistorieSpalte, 9: KATALOG_STAND_SCHEMA, 10: laufKennzahlenSpalten, });