import type { ProjectStore } from '@/app/store'; import type { Project } from '@/domain/model/project'; import { addRecoveryPoint, saveSession, type AppSettings } from './storage'; /** * Zwischenspeichern des Arbeitsstands. * * Zwei getrennte Takte: * - Nach jeder Aenderung, verzoegert um 1,5 s, wird der Sitzungsstand * gesichert. So ist auch bei einem Absturz hoechstens die letzte Eingabe weg. * - In groesserem Abstand entsteht ein Wiederherstellungspunkt; bis zu fuenf * Staende liegen damit als zweite Sicherung in der Datenbank. Einspielen * laesst sich bisher keiner davon: Die Leseseite der Speicherschicht hat * ausserhalb der Pruefungen keinen Aufrufer, und die Oberflaeche bietet * keinen Bedienweg dorthin an. Hier stand zuvor das Gegenteil. * * Der Altbestand startete bei jedem Projektwechsel ein neues setInterval, ohne * das alte zu beenden. Nach fuenf Projektwechseln liefen fuenf Sicherungen * gleichzeitig, die sich gegenseitig ueberschrieben. Diese Klasse haelt genau * einen Zeitgeber je Aufgabe und raeumt beide in `stop()` ab. * * SEIT DER UMSTELLUNG AUF IndexedDB schreibt der Sitzungsspeicher asynchron. * Daraus folgen drei Dinge, die hier geregelt werden: * - Es laeuft nie mehr als ein Schreibvorgang gleichzeitig; waehrenddessen * eingehende Aenderungen werden zu EINEM Nachzuegler zusammengefasst. Sonst * stapelten sich bei zuegiger Eingabe mehrere 20-MB-Sicherungen, von denen * die aelteste womoeglich als letzte ankaeme. * - Unveraenderte Staende werden nicht erneut geschrieben. * - Beim Verlassen des Fensters wird nicht erst in `beforeunload` gesichert - * dort bleibt fuer eine IndexedDB-Transaktion keine Zeit mehr. */ export class AutoSave { private debounceTimer: ReturnType | null = null; private intervalTimer: ReturnType | null = null; private unsubscribe: (() => void) | null = null; private beimVerlassen: (() => void) | null = null; private beiSichtwechsel: (() => void) | null = null; private lastSavedAt = 0; /** Laeuft gerade ein Schreibvorgang? */ private schreibtGerade = false; /** Kam waehrend des Schreibens eine neue Aenderung? */ private nachholen = false; /** * Zuletzt erfolgreich gesicherter Projektstand. * * Der Vergleich laeuft ueber die Objektgleichheit: Der Zustandsspeicher * ersetzt das Projekt bei jeder Aenderung durch ein neues Objekt, gleiche * Kennung heisst also unveraendert. Ohne diese Pruefung schriebe der * Zeitgeber beim Anlegen der Wiederherstellungspunkte auch dann, wenn nichts * geschehen ist - bei einem Luftbild von 20 MB jedes Mal umsonst. */ private zuletztGesichert: Project | null = null; /** * Zuletzt erfolgreich abgelegter Wiederherstellungspunkt. * * Aus demselben Grund wie `zuletztGesichert` und ueber dieselbe * Objektgleichheit, aber getrennt gefuehrt: `lastSavedAt` allein taugt als * Sperre nicht, weil der Programmstart den Stand herstellt, ohne ihn zu * sichern - `lastSavedAt` bleibt dann 0, und im Leerlauf fuellte sich der * Fuenferring alle zwei Minuten mit Kopien desselben Projekts, bis die * Punkte frueherer Sitzungen verdraengt waren. * * Erst NACH dem geglueckten Schreiben gesetzt: Ein gescheiterter Punkt liegt * nirgends und darf den naechsten Versuch nicht sperren. */ private letzterPunkt: Project | null = null; /** Zuletzt ausgegebener Meldungstext, gegen Dauerwiederholung. */ private letzteMeldung = ''; /** * Darf der Sitzungsstand ueberhaupt geschrieben werden? * * Gesperrt wird nach dem Befund 'nicht-lesbar' des Sitzungsspeichers: Dort * liegt ein womoeglich HEILER Stand, der sich nur nicht lesen liess, und der * Programmstart sagt dem Anwender ausdruecklich zu, dass er "nicht verloren" * ist und jetzt nichts gespeichert wird. Ohne diese Sperre schriebe der erste * Sichtwechsel - Fenster minimieren oder schliessen, also genau der * empfohlene Neustart - das leere Startprojekt darueber, und die * anschliessende Grossdatenaufraeumung naehme das Luftbild gleich mit. */ private sitzungGesperrt = false; constructor( private readonly store: ProjectStore, private settings: AppSettings, /* * 'warnung' ist neu und der eigentliche Punkt: Die einzige Auskunft * darueber, dass der Arbeitsstand nur noch im 5-MB-Ersatzspeicher liegt - * ein Luftbild passt dort nicht hinein -, ging als hoefliche Kurzmeldung * hinaus und verschwand nach fuenf Sekunden. Die Wiederholungssperre unten * sorgt dafuer, dass sie genau einmal je Sitzung erscheint; wer in dem * Moment nicht hinsah, erfuhr es nie. Warnungen bleiben stehen. */ private readonly onMessage: (message: string, kind: 'info' | 'warnung' | 'fehler') => void, ) {} start(): void { this.stop(); if (!this.settings.autoSaveEnabled) return; this.unsubscribe = this.store.subscribe((state) => { if (!state.dirty) return; this.scheduleSessionSave(); }); this.intervalTimer = setInterval( () => { this.createRecoveryPoint(); }, Math.max(15, this.settings.autoSaveIntervalSeconds) * 1000, ); // Gesichert wird, sobald das Fenster in den Hintergrund geht. Das ist der // letzte Zeitpunkt, zu dem eine IndexedDB-Transaktion zuverlaessig // durchlaeuft: In `beforeunload` wird die Verbindung mit dem Fenster // abgeraeumt, bevor die Transaktion festschreiben kann - der Schreibvorgang // liefe ins Leere, ohne dass es jemand bemerkt. this.beiSichtwechsel = () => { if (document.visibilityState === 'hidden') void this.flush(); }; document.addEventListener('visibilitychange', this.beiSichtwechsel); // Zusaetzlich, nicht stattdessen: Wird das Fenster geschlossen, ohne vorher // verborgen zu werden, ist das der letzte Versuch. this.beimVerlassen = () => { void this.flush(); }; window.addEventListener('beforeunload', this.beimVerlassen); } stop(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.intervalTimer !== null) { clearInterval(this.intervalTimer); this.intervalTimer = null; } this.unsubscribe?.(); this.unsubscribe = null; if (this.beimVerlassen !== null) { window.removeEventListener('beforeunload', this.beimVerlassen); this.beimVerlassen = null; } if (this.beiSichtwechsel !== null) { document.removeEventListener('visibilitychange', this.beiSichtwechsel); this.beiSichtwechsel = null; } } updateSettings(settings: AppSettings): void { this.settings = settings; this.start(); } /** * Haelt jedes Schreiben des Sitzungsstands an - siehe `sitzungGesperrt`. * * Wiederherstellungspunkte laufen weiter: Sie liegen unter einem eigenen * Schluessel und ersetzen den Sitzungssatz nicht. */ sperreSitzungsspeicherung(): void { this.sitzungGesperrt = true; } /** * Gibt das Schreiben wieder frei. * * Nur nach einer ausdruecklichen Entscheidung des Anwenders aufzurufen - er * hat ueber "Neu" oder "Öffnen" ein anderes Projekt gesetzt und damit selbst * bestimmt, was von jetzt an im Sitzungsspeicher stehen soll. */ gibSitzungsspeicherungFrei(): void { this.sitzungGesperrt = false; } /** * Sichert sofort, ohne auf den Zeitgeber zu warten. * * Das Versprechen ist erst erfuellt, wenn der Stand tatsaechlich geschrieben * ist; aufrufen laesst sich die Methode weiterhin ohne `await`. */ flush(): Promise { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } return this.writeSession(); } private scheduleSessionSave(): void { if (this.debounceTimer !== null) clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this.debounceTimer = null; void this.writeSession(); }, 1500); } private async writeSession(): Promise { // Vor allem anderen: Ist gesperrt, wird gar nicht geschrieben - auch nicht // ueber `flush()` aus dem Sichtwechsel oder der Fehlerbehandlung heraus. if (this.sitzungGesperrt) return; if (this.schreibtGerade) { // Nicht ueberholen lassen: Der laufende Vorgang holt den neuesten Stand // selbst nach - gelingt er, gleich in der Schleife unten; scheitert er, // ueber eine erneute entprellte Einplanung. this.nachholen = true; return; } this.schreibtGerade = true; try { for (;;) { /* * Der Linter haelt die beiden Abfragen auf `nachholen` weiter unten * fuer entschieden ("always falsy" bzw. "always truthy"): Die * Flussanalyse sieht die Zuweisung hier und behaelt sie ueber das * `await` hinweg bei. Zwischen beiden liegt aber der Wartepunkt, und * genau dort setzt ein zweiter Aufruf von writeSession das Feld auf * true und kehrt zurueck (oben, Zweig `schreibtGerade`). Beide * Abfragen sind der Grund, warum dieser Nachzuegler ankommt, und jede * hat ihre eigene Wache. Gemessen durch Zuruecknehmen: Ohne die * Abfrage im Fehlerzweig sind zwei Faelle in * tests/services/nachzueglerNachFehlschlag.test.ts und einer * in tests/services/autosave.test.ts rot; ohne die am Schleifenende * zwei Faelle in tests/services/autosave.test.ts. */ this.nachholen = false; const projekt = this.store.getProject(); if (projekt === this.zuletztGesichert) return; const result = await saveSession(projekt); if (!result.ok) { this.melde(result.message, 'fehler'); /* * Der Nachzuegler faellt auch hier nicht weg. * * Der Entprellzeitgeber ist an dieser Stelle laengst abgeraeumt - * ohne diese Einplanung stuende gar kein Sitzungsschreibversuch mehr * an, und der zuletzt eingegebene Wert laege nur noch im * Arbeitsspeicher, bis der Anwender das naechste Mal etwas aendert. * * ENTPRELLT eingeplant und nicht in der Schleife sofort wiederholt: * Bei dauerhaft defektem Speicher drehte sie sonst durch. So bleibt * es bei einem weiteren Versuch je vorgemerktem Stand. */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt; siehe darueber if (this.nachholen) this.scheduleSessionSave(); return; } this.zuletztGesichert = projekt; this.lastSavedAt = Date.now(); // Ein geglueckter Speichervorgang MIT Meldung heisst: Er ist geglueckt, // aber nicht so, wie er sollte - der Ersatzspeicher hat uebernommen. // Das ist eine Warnung, keine Mitteilung. this.melde(result.message, 'warnung'); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt if (!this.nachholen) return; } } finally { this.schreibtGerade = false; } } /** * Gibt eine Meldung nur bei Aenderung aus. * * Gesichert wird 1,5 s nach jeder Eingabe. Ohne diese Sperre wuerde ein * dauerhafter Zustand - etwa ein fehlender Speicher - im Sekundentakt * gemeldet und der Meldungsbereich unbrauchbar. */ private melde(text: string, art: 'info' | 'warnung' | 'fehler'): void { if (text === this.letzteMeldung) return; this.letzteMeldung = text; if (text !== '') this.onMessage(text, art); } private createRecoveryPoint(): void { const state = this.store.getState(); if (!state.dirty && this.lastSavedAt > 0) return; // Derselbe Stand ein zweites Mal ergibt keinen zweiten Punkt - siehe // `letzterPunkt`. if (state.project === this.letzterPunkt) return; const projekt = state.project; void addRecoveryPoint(projekt) .then(() => { this.letzterPunkt = projekt; }) .catch(() => { // Ein Wiederherstellungspunkt ist Beiwerk; sein Scheitern darf weder den // Zeitgeber anhalten noch als unbehandelte Ablehnung enden. }); } }