lsa-planer
LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.
| 1 | import type { ProjectStore } from '@/app/store'; |
| 2 | import type { Project } from '@/domain/model/project'; |
| 3 | import { addRecoveryPoint, saveSession, type AppSettings } from './storage'; |
| 4 | |
| 5 | /** |
| 6 | * Zwischenspeichern des Arbeitsstands. |
| 7 | * |
| 8 | * Zwei getrennte Takte: |
| 9 | * - Nach jeder Aenderung, verzoegert um 1,5 s, wird der Sitzungsstand |
| 10 | * gesichert. So ist auch bei einem Absturz hoechstens die letzte Eingabe weg. |
| 11 | * - In groesserem Abstand entsteht ein Wiederherstellungspunkt; bis zu fuenf |
| 12 | * Staende liegen damit als zweite Sicherung in der Datenbank. Einspielen |
| 13 | * laesst sich bisher keiner davon: Die Leseseite der Speicherschicht hat |
| 14 | * ausserhalb der Pruefungen keinen Aufrufer, und die Oberflaeche bietet |
| 15 | * keinen Bedienweg dorthin an. Hier stand zuvor das Gegenteil. |
| 16 | * |
| 17 | * Der Altbestand startete bei jedem Projektwechsel ein neues setInterval, ohne |
| 18 | * das alte zu beenden. Nach fuenf Projektwechseln liefen fuenf Sicherungen |
| 19 | * gleichzeitig, die sich gegenseitig ueberschrieben. Diese Klasse haelt genau |
| 20 | * einen Zeitgeber je Aufgabe und raeumt beide in `stop()` ab. |
| 21 | * |
| 22 | * SEIT DER UMSTELLUNG AUF IndexedDB schreibt der Sitzungsspeicher asynchron. |
| 23 | * Daraus folgen drei Dinge, die hier geregelt werden: |
| 24 | * - Es laeuft nie mehr als ein Schreibvorgang gleichzeitig; waehrenddessen |
| 25 | * eingehende Aenderungen werden zu EINEM Nachzuegler zusammengefasst. Sonst |
| 26 | * stapelten sich bei zuegiger Eingabe mehrere 20-MB-Sicherungen, von denen |
| 27 | * die aelteste womoeglich als letzte ankaeme. |
| 28 | * - Unveraenderte Staende werden nicht erneut geschrieben. |
| 29 | * - Beim Verlassen des Fensters wird nicht erst in `beforeunload` gesichert - |
| 30 | * dort bleibt fuer eine IndexedDB-Transaktion keine Zeit mehr. |
| 31 | */ |
| 32 | export class AutoSave { |
| 33 | private debounceTimer: ReturnType<typeof setTimeout> | null = null; |
| 34 | private intervalTimer: ReturnType<typeof setInterval> | null = null; |
| 35 | private unsubscribe: (() => void) | null = null; |
| 36 | private beimVerlassen: (() => void) | null = null; |
| 37 | private beiSichtwechsel: (() => void) | null = null; |
| 38 | private lastSavedAt = 0; |
| 39 | |
| 40 | /** Laeuft gerade ein Schreibvorgang? */ |
| 41 | private schreibtGerade = false; |
| 42 | /** Kam waehrend des Schreibens eine neue Aenderung? */ |
| 43 | private nachholen = false; |
| 44 | /** |
| 45 | * Zuletzt erfolgreich gesicherter Projektstand. |
| 46 | * |
| 47 | * Der Vergleich laeuft ueber die Objektgleichheit: Der Zustandsspeicher |
| 48 | * ersetzt das Projekt bei jeder Aenderung durch ein neues Objekt, gleiche |
| 49 | * Kennung heisst also unveraendert. Ohne diese Pruefung schriebe der |
| 50 | * Zeitgeber beim Anlegen der Wiederherstellungspunkte auch dann, wenn nichts |
| 51 | * geschehen ist - bei einem Luftbild von 20 MB jedes Mal umsonst. |
| 52 | */ |
| 53 | private zuletztGesichert: Project | null = null; |
| 54 | /** |
| 55 | * Zuletzt erfolgreich abgelegter Wiederherstellungspunkt. |
| 56 | * |
| 57 | * Aus demselben Grund wie `zuletztGesichert` und ueber dieselbe |
| 58 | * Objektgleichheit, aber getrennt gefuehrt: `lastSavedAt` allein taugt als |
| 59 | * Sperre nicht, weil der Programmstart den Stand herstellt, ohne ihn zu |
| 60 | * sichern - `lastSavedAt` bleibt dann 0, und im Leerlauf fuellte sich der |
| 61 | * Fuenferring alle zwei Minuten mit Kopien desselben Projekts, bis die |
| 62 | * Punkte frueherer Sitzungen verdraengt waren. |
| 63 | * |
| 64 | * Erst NACH dem geglueckten Schreiben gesetzt: Ein gescheiterter Punkt liegt |
| 65 | * nirgends und darf den naechsten Versuch nicht sperren. |
| 66 | */ |
| 67 | private letzterPunkt: Project | null = null; |
| 68 | /** Zuletzt ausgegebener Meldungstext, gegen Dauerwiederholung. */ |
| 69 | private letzteMeldung = ''; |
| 70 | /** |
| 71 | * Darf der Sitzungsstand ueberhaupt geschrieben werden? |
| 72 | * |
| 73 | * Gesperrt wird nach dem Befund 'nicht-lesbar' des Sitzungsspeichers: Dort |
| 74 | * liegt ein womoeglich HEILER Stand, der sich nur nicht lesen liess, und der |
| 75 | * Programmstart sagt dem Anwender ausdruecklich zu, dass er "nicht verloren" |
| 76 | * ist und jetzt nichts gespeichert wird. Ohne diese Sperre schriebe der erste |
| 77 | * Sichtwechsel - Fenster minimieren oder schliessen, also genau der |
| 78 | * empfohlene Neustart - das leere Startprojekt darueber, und die |
| 79 | * anschliessende Grossdatenaufraeumung naehme das Luftbild gleich mit. |
| 80 | */ |
| 81 | private sitzungGesperrt = false; |
| 82 | |
| 83 | constructor( |
| 84 | private readonly store: ProjectStore, |
| 85 | private settings: AppSettings, |
| 86 | /* |
| 87 | * 'warnung' ist neu und der eigentliche Punkt: Die einzige Auskunft |
| 88 | * darueber, dass der Arbeitsstand nur noch im 5-MB-Ersatzspeicher liegt - |
| 89 | * ein Luftbild passt dort nicht hinein -, ging als hoefliche Kurzmeldung |
| 90 | * hinaus und verschwand nach fuenf Sekunden. Die Wiederholungssperre unten |
| 91 | * sorgt dafuer, dass sie genau einmal je Sitzung erscheint; wer in dem |
| 92 | * Moment nicht hinsah, erfuhr es nie. Warnungen bleiben stehen. |
| 93 | */ |
| 94 | private readonly onMessage: (message: string, kind: 'info' | 'warnung' | 'fehler') => void, |
| 95 | ) {} |
| 96 | |
| 97 | start(): void { |
| 98 | this.stop(); |
| 99 | if (!this.settings.autoSaveEnabled) return; |
| 100 | |
| 101 | this.unsubscribe = this.store.subscribe((state) => { |
| 102 | if (!state.dirty) return; |
| 103 | this.scheduleSessionSave(); |
| 104 | }); |
| 105 | |
| 106 | this.intervalTimer = setInterval( |
| 107 | () => { |
| 108 | this.createRecoveryPoint(); |
| 109 | }, |
| 110 | Math.max(15, this.settings.autoSaveIntervalSeconds) * 1000, |
| 111 | ); |
| 112 | |
| 113 | // Gesichert wird, sobald das Fenster in den Hintergrund geht. Das ist der |
| 114 | // letzte Zeitpunkt, zu dem eine IndexedDB-Transaktion zuverlaessig |
| 115 | // durchlaeuft: In `beforeunload` wird die Verbindung mit dem Fenster |
| 116 | // abgeraeumt, bevor die Transaktion festschreiben kann - der Schreibvorgang |
| 117 | // liefe ins Leere, ohne dass es jemand bemerkt. |
| 118 | this.beiSichtwechsel = () => { |
| 119 | if (document.visibilityState === 'hidden') void this.flush(); |
| 120 | }; |
| 121 | document.addEventListener('visibilitychange', this.beiSichtwechsel); |
| 122 | |
| 123 | // Zusaetzlich, nicht stattdessen: Wird das Fenster geschlossen, ohne vorher |
| 124 | // verborgen zu werden, ist das der letzte Versuch. |
| 125 | this.beimVerlassen = () => { |
| 126 | void this.flush(); |
| 127 | }; |
| 128 | window.addEventListener('beforeunload', this.beimVerlassen); |
| 129 | } |
| 130 | |
| 131 | stop(): void { |
| 132 | if (this.debounceTimer !== null) { |
| 133 | clearTimeout(this.debounceTimer); |
| 134 | this.debounceTimer = null; |
| 135 | } |
| 136 | if (this.intervalTimer !== null) { |
| 137 | clearInterval(this.intervalTimer); |
| 138 | this.intervalTimer = null; |
| 139 | } |
| 140 | this.unsubscribe?.(); |
| 141 | this.unsubscribe = null; |
| 142 | if (this.beimVerlassen !== null) { |
| 143 | window.removeEventListener('beforeunload', this.beimVerlassen); |
| 144 | this.beimVerlassen = null; |
| 145 | } |
| 146 | if (this.beiSichtwechsel !== null) { |
| 147 | document.removeEventListener('visibilitychange', this.beiSichtwechsel); |
| 148 | this.beiSichtwechsel = null; |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | updateSettings(settings: AppSettings): void { |
| 153 | this.settings = settings; |
| 154 | this.start(); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Haelt jedes Schreiben des Sitzungsstands an - siehe `sitzungGesperrt`. |
| 159 | * |
| 160 | * Wiederherstellungspunkte laufen weiter: Sie liegen unter einem eigenen |
| 161 | * Schluessel und ersetzen den Sitzungssatz nicht. |
| 162 | */ |
| 163 | sperreSitzungsspeicherung(): void { |
| 164 | this.sitzungGesperrt = true; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Gibt das Schreiben wieder frei. |
| 169 | * |
| 170 | * Nur nach einer ausdruecklichen Entscheidung des Anwenders aufzurufen - er |
| 171 | * hat ueber "Neu" oder "Öffnen" ein anderes Projekt gesetzt und damit selbst |
| 172 | * bestimmt, was von jetzt an im Sitzungsspeicher stehen soll. |
| 173 | */ |
| 174 | gibSitzungsspeicherungFrei(): void { |
| 175 | this.sitzungGesperrt = false; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Sichert sofort, ohne auf den Zeitgeber zu warten. |
| 180 | * |
| 181 | * Das Versprechen ist erst erfuellt, wenn der Stand tatsaechlich geschrieben |
| 182 | * ist; aufrufen laesst sich die Methode weiterhin ohne `await`. |
| 183 | */ |
| 184 | flush(): Promise<void> { |
| 185 | if (this.debounceTimer !== null) { |
| 186 | clearTimeout(this.debounceTimer); |
| 187 | this.debounceTimer = null; |
| 188 | } |
| 189 | return this.writeSession(); |
| 190 | } |
| 191 | |
| 192 | private scheduleSessionSave(): void { |
| 193 | if (this.debounceTimer !== null) clearTimeout(this.debounceTimer); |
| 194 | this.debounceTimer = setTimeout(() => { |
| 195 | this.debounceTimer = null; |
| 196 | void this.writeSession(); |
| 197 | }, 1500); |
| 198 | } |
| 199 | |
| 200 | private async writeSession(): Promise<void> { |
| 201 | // Vor allem anderen: Ist gesperrt, wird gar nicht geschrieben - auch nicht |
| 202 | // ueber `flush()` aus dem Sichtwechsel oder der Fehlerbehandlung heraus. |
| 203 | if (this.sitzungGesperrt) return; |
| 204 | if (this.schreibtGerade) { |
| 205 | // Nicht ueberholen lassen: Der laufende Vorgang holt den neuesten Stand |
| 206 | // selbst nach - gelingt er, gleich in der Schleife unten; scheitert er, |
| 207 | // ueber eine erneute entprellte Einplanung. |
| 208 | this.nachholen = true; |
| 209 | return; |
| 210 | } |
| 211 | this.schreibtGerade = true; |
| 212 | try { |
| 213 | for (;;) { |
| 214 | /* |
| 215 | * Der Linter haelt die beiden Abfragen auf `nachholen` weiter unten |
| 216 | * fuer entschieden ("always falsy" bzw. "always truthy"): Die |
| 217 | * Flussanalyse sieht die Zuweisung hier und behaelt sie ueber das |
| 218 | * `await` hinweg bei. Zwischen beiden liegt aber der Wartepunkt, und |
| 219 | * genau dort setzt ein zweiter Aufruf von writeSession das Feld auf |
| 220 | * true und kehrt zurueck (oben, Zweig `schreibtGerade`). Beide |
| 221 | * Abfragen sind der Grund, warum dieser Nachzuegler ankommt, und jede |
| 222 | * hat ihre eigene Wache. Gemessen durch Zuruecknehmen: Ohne die |
| 223 | * Abfrage im Fehlerzweig sind zwei Faelle in |
| 224 | * tests/services/nachzueglerNachFehlschlag.test.ts und einer |
| 225 | * in tests/services/autosave.test.ts rot; ohne die am Schleifenende |
| 226 | * zwei Faelle in tests/services/autosave.test.ts. |
| 227 | */ |
| 228 | this.nachholen = false; |
| 229 | const projekt = this.store.getProject(); |
| 230 | if (projekt === this.zuletztGesichert) return; |
| 231 | |
| 232 | const result = await saveSession(projekt); |
| 233 | if (!result.ok) { |
| 234 | this.melde(result.message, 'fehler'); |
| 235 | /* |
| 236 | * Der Nachzuegler faellt auch hier nicht weg. |
| 237 | * |
| 238 | * Der Entprellzeitgeber ist an dieser Stelle laengst abgeraeumt - |
| 239 | * ohne diese Einplanung stuende gar kein Sitzungsschreibversuch mehr |
| 240 | * an, und der zuletzt eingegebene Wert laege nur noch im |
| 241 | * Arbeitsspeicher, bis der Anwender das naechste Mal etwas aendert. |
| 242 | * |
| 243 | * ENTPRELLT eingeplant und nicht in der Schleife sofort wiederholt: |
| 244 | * Bei dauerhaft defektem Speicher drehte sie sonst durch. So bleibt |
| 245 | * es bei einem weiteren Versuch je vorgemerktem Stand. |
| 246 | */ |
| 247 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt; siehe darueber |
| 248 | if (this.nachholen) this.scheduleSessionSave(); |
| 249 | return; |
| 250 | } |
| 251 | this.zuletztGesichert = projekt; |
| 252 | this.lastSavedAt = Date.now(); |
| 253 | // Ein geglueckter Speichervorgang MIT Meldung heisst: Er ist geglueckt, |
| 254 | // aber nicht so, wie er sollte - der Ersatzspeicher hat uebernommen. |
| 255 | // Das ist eine Warnung, keine Mitteilung. |
| 256 | this.melde(result.message, 'warnung'); |
| 257 | |
| 258 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- das Feld wird ueber den Wartepunkt hinweg gesetzt |
| 259 | if (!this.nachholen) return; |
| 260 | } |
| 261 | } finally { |
| 262 | this.schreibtGerade = false; |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Gibt eine Meldung nur bei Aenderung aus. |
| 268 | * |
| 269 | * Gesichert wird 1,5 s nach jeder Eingabe. Ohne diese Sperre wuerde ein |
| 270 | * dauerhafter Zustand - etwa ein fehlender Speicher - im Sekundentakt |
| 271 | * gemeldet und der Meldungsbereich unbrauchbar. |
| 272 | */ |
| 273 | private melde(text: string, art: 'info' | 'warnung' | 'fehler'): void { |
| 274 | if (text === this.letzteMeldung) return; |
| 275 | this.letzteMeldung = text; |
| 276 | if (text !== '') this.onMessage(text, art); |
| 277 | } |
| 278 | |
| 279 | private createRecoveryPoint(): void { |
| 280 | const state = this.store.getState(); |
| 281 | if (!state.dirty && this.lastSavedAt > 0) return; |
| 282 | // Derselbe Stand ein zweites Mal ergibt keinen zweiten Punkt - siehe |
| 283 | // `letzterPunkt`. |
| 284 | if (state.project === this.letzterPunkt) return; |
| 285 | const projekt = state.project; |
| 286 | void addRecoveryPoint(projekt) |
| 287 | .then(() => { |
| 288 | this.letzterPunkt = projekt; |
| 289 | }) |
| 290 | .catch(() => { |
| 291 | // Ein Wiederherstellungspunkt ist Beiwerk; sein Scheitern darf weder den |
| 292 | // Zeitgeber anhalten noch als unbehandelte Ablehnung enden. |
| 293 | }); |
| 294 | } |
| 295 | } |