EventApplier schreibt eingehende Sync-Ereignisse absichtlich direkt auf die rohe LiteDB-Collection
(Ping-Pong-Vermeidung), benachrichtigt dabei aber kein ViewModel - eine per Sync empfangene neue
Stunde erschien bisher erst nach manuellem Neuladen (z.B. Tab-Wechsel).
Neues SyncEngine.DataChanged-Ereignis, gefeuert nach jedem Pull mit Ereignissen sowie bei einer
RemoteWon-Konfliktauflösung; SyncStatusViewModel reicht es durch. MainWindowViewModel abonniert es
und lädt ausschließlich die gerade sichtbare Seite über ihren eigenen, längst vorhandenen
Lade-Einstieg neu - keine Navigation weg von offenen Detailansichten, keine neue Lade-Logik, und
eine laufende Inline-Bearbeitung (Schülerdetail) wird nicht überschrieben.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId-
Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der
Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs
EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der
neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404
bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei
jedem weiteren Sync-Versuch.
Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der
nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der
"Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht
nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der
kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler
überhaupt auftreten kann.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SyncEngine.PushAsync setzte den lokalen Pull-Cursor bisher aus PushResponse.ServerSequenceNr -
dem globalen Zähler über alle Geräte nach dem eigenen Push, nicht dem tatsächlich zugestellten
Stand. War beim Server zu diesem Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines
anderen Geräts mit niedrigerer ServerSeq vorhanden, sprang der Cursor darüber hinweg und der
direkt folgende Pull bekam 0 Ereignisse, ohne es je angewendet zu haben - ein zweiter,
unabhängiger Cursor-Bug mit demselben Symptom wie der vorherige Wasserzeichen-Fix, diesmal
client- statt serverseitig.
Ergänzt außerdem einen "Vollständigen Sync erzwingen"-Button in den Sync-Einstellungen, damit
bereits durch diesen Bug zu weit vorgerückte Geräte ihren Fortschritt manuell zurücksetzen und
alle Ereignisse erneut laden können.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EventStore.Pull gab bisher den globalen ServerSeq-Höchststand als neuen Cursor zurück statt
den höchsten unter den tatsächlich gelieferten Ereignissen - hatte ein Gerät selbst kurz zuvor
etwas gepusht, sprang sein Pull-Cursor über noch nicht abgeholte Ereignisse anderer Geräte
hinweg und verpasste sie dauerhaft, ohne jeden Fehler.
Ersetzt außerdem die bisherige 30-Sekunden-Heuristik zur Konflikterkennung beim Push durch
exakte BasedOnServerSeq-Prüfung: jedes SyncEvent trägt die ServerSeq, auf der es aufbaut: der
Server lehnt ab, wenn der aktuelle Stand nicht mehr passt. Bei Ablehnung lädt der Client sofort
den neuen Server-Stand nach, löst den Konflikt nach der bestehenden Desktop-vs-Companion/
Timestamp-Politik auf und macht ihn immer in der Konflikt-Review-UI sichtbar, statt die
verworfene Änderung stillschweigend zu verlieren.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere
Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines
einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in
SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq)
gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut
und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter
Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im
selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte
weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte
sich höchstens als knapper Text in der Sync-Statusleiste.
EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über
AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine
loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client,
kein API-Redeploy nötig.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Minimale Liste im Tab "Synchronisation" - Entitaet, Zeitpunkt, welche
Seite ConflictResolver gewaehlt hat, mit "Gesehen"-Aktion. Kein
Feld-Diff fuer v1: die Payloads sind clientseitig verschluesselt, ein
Diff wuerde ohnehin nur rohes JSON zeigen.
Neu EventQueue.MarkReviewed(id) - bisher gab es AddConflict/
GetUnreviewed/ConflictCount, aber keinen Weg, einen Konflikt als
gesehen zu markieren.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.
- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
Documentation-Ereignisses nach - ueber die rohe Collection statt
IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
hier die Original-StorageId erhalten bleiben muss
Round-Trip-Tests belegen byteidentische Uebertragung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Die zentrale, bisher komplett fehlende Luecke: selbst mit Baustein
1-4 haette SyncEngine.PullAsync empfangene Ereignisse nur zur
Konflikterkennung genutzt, nie in die lokale LiteDB geschrieben -
ankommende Aenderungen von anderen Geraeten waeren nirgends sichtbar
geworden.
Neu EventApplier: entschluesselt, dispatcht ueber eine explizite
EntityType-Tabelle, schreibt IMMER direkt auf die rohe LiteDB-
Collection, nie ueber eine Repository-Save/Delete-Methode - sonst
wuerde der OnChange-Hook (Baustein 2) die gerade angewendete Aenderung
als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). Ein
gemeinsames Suppress-Flag wurde geprueft und verworfen: SyncEngine
laeuft per Timer nebenlaeufig zum UI-Thread, ein Flag koennte einen
echten Nutzer-Save waehrenddessen verschlucken. Der direkte Collection-
Zugriff ist zustandslos und dadurch korrekt. Kaskaden-Faelle nutzen
dieselben internen LiteDbContext-Hilfsmethoden wie die Repositories
(Baustein 4).
Neu SyncEventPublisher, der den OnChange-Hook in ein verschluesseltes
EventQueue.Enqueue uebersetzt (an LiteDbContext.OnChange gehaengt).
Mit dediziertem Loop-Prevention-Test abgesichert: belegt mit echtem
LiteDbContext + OnChange-Zaehler, dass Apply keinen neuen Hook-Aufruf
ausloest.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vier neue xUnit-Testprojekte (LehrerApp.Tests, .Data.Tests, .Desktop.Tests,
.Sync.Tests) mit zusammen 104 Tests: GradingService, SchoolYearService,
Repositories gegen In-Memory-LiteDB, Mitarbeits-Aggregation (3.2),
Zeugnisnotenberechnung (2.4) und ConflictResolver.
Dabei zwei echte Fehler in der Sync-Schicht gefunden und behoben:
- SyncEvent.EventId fehlte [BsonId], wodurch EventQueue.Acknowledge()
nie etwas aus der Queue löschte.
- ConflictResolver verglich Zeitstempel unterschiedlicher DateTimeKind
direkt (LiteDB liefert Local statt Utc zurück), was die Gleichstand-
Regel außerhalb von UTC+0 verfälschte.
SchoolYearService.CurrentSchoolYear()/RecentSchoolYears() um ein optionales
today-Argument erweitert, um den Schuljahreswechsel deterministisch zu
testen; LiteDbContext um einen Stream-Konstruktor für In-Memory-Tests.