docs: add state-management strategy reference and SQLDelight session log
Added documentation outlining the recommended frontend state-management approach using Unidirectional Data Flow (UDF). Documented the 2026-01-28 session addressing the critical SQLDelight async issue, detailing the analysis, fix implementation, and results. Updated PingEventRepositoryImpl to use `awaitAsOneOrNull` for proper async handling.
This commit is contained in:
@@ -40,6 +40,7 @@ Das `frontend`-Verzeichnis ist wie folgt strukturiert, um eine klare Trennung de
|
|||||||
|
|
||||||
## Wichtige Dokumente
|
## Wichtige Dokumente
|
||||||
|
|
||||||
|
* **[State-Management Strategie (UDF)](state-management-strategy.md):** Beschreibt die empfohlene Strategie für komplexe Screens.
|
||||||
* **[ADR-0010: SQLDelight für Cross-Platform-Persistenz](../01_Architecture/adr/0010-sqldelight-for-cross-platform-persistence.md):** Beschreibt die Entscheidung für SQLDelight.
|
* **[ADR-0010: SQLDelight für Cross-Platform-Persistenz](../01_Architecture/adr/0010-sqldelight-for-cross-platform-persistence.md):** Beschreibt die Entscheidung für SQLDelight.
|
||||||
* **[ADR-0011: Koin für Dependency Injection](../01_Architecture/adr/0011-koin-for-dependency-injection.md):** Beschreibt die Entscheidung für Koin.
|
* **[ADR-0011: Koin für Dependency Injection](../01_Architecture/adr/0011-koin-for-dependency-injection.md):** Beschreibt die Entscheidung für Koin.
|
||||||
* **[Offline-First-Architektur](offline-first-architecture.md):** Detaillierte Beschreibung der Offline-First-Strategie.
|
* **[Offline-First-Architektur](offline-first-architecture.md):** Detaillierte Beschreibung der Offline-First-Strategie.
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
type: Reference
|
||||||
|
status: DRAFT
|
||||||
|
owner: Frontend Expert
|
||||||
|
last_update: 2026-01-28
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend State-Management Strategie (UDF)
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt die empfohlene Strategie für das State Management in komplexen UI-Komponenten wie Formularen und Reports, um die Skalierbarkeit und Wartbarkeit des Frontends sicherzustellen.
|
||||||
|
|
||||||
|
## Problemstellung: Grenzen des einfachen State Managements
|
||||||
|
|
||||||
|
Für einfache Screens ist die Verwendung von mehreren `StateFlow`s in einem ViewModel ein valider Ansatz.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Beispiel für einen einfachen Ansatz
|
||||||
|
class SimpleViewModel : ViewModel() {
|
||||||
|
private val _name = MutableStateFlow("")
|
||||||
|
val name: StateFlow<String> = _name
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei wachsender Komplexität (viele Felder, Validierungsregeln, asynchrone Aktionen) führt dieser Ansatz zu Problemen:
|
||||||
|
* **Inkonsistente Zustände:** Es ist leicht, einen State zu vergessen (z.B. `isLoading` auf `false` zu setzen, aber die Fehlermeldung nicht zu löschen), was zu schwer nachvollziehbaren UI-Bugs führt.
|
||||||
|
* **Schwere Testbarkeit:** Die Logik ist über viele Funktionen verteilt, die einzelne State-Variablen mutieren.
|
||||||
|
* **Race Conditions:** Mehrere gleichzeitige, asynchrone Updates können den State unvorhersehbar machen.
|
||||||
|
|
||||||
|
## Lösungsstrategie: Unidirectional Data Flow (UDF)
|
||||||
|
|
||||||
|
Um diese Probleme zu lösen, wird die Einführung eines **Unidirectional Data Flow (UDF)** Musters für alle neuen, komplexen Features empfohlen.
|
||||||
|
|
||||||
|
UDF erzwingt einen strikten, vorhersagbaren Kreislauf:
|
||||||
|
|
||||||
|
1. **State:** Ein **einziges, unveränderliches (immutable) `data class`** repräsentiert den gesamten Zustand der UI. Dies ist die *Single Source of Truth*.
|
||||||
|
2. **Event/Intent:** Die UI ändert den Zustand niemals direkt. Stattdessen sendet sie ein **Event** (z.B. `SaveButtonClicked`), um eine Benutzeraktion zu signalisieren.
|
||||||
|
3. **Logic (Reducer):** Eine zentrale Logik-Komponente (im ViewModel) empfängt das Event, führt Geschäftslogik aus und produziert einen **neuen, kompletten State**.
|
||||||
|
|
||||||
|
### Beispiel-Implementierung
|
||||||
|
|
||||||
|
**1. Ein einziges State-Objekt:**
|
||||||
|
```kotlin
|
||||||
|
data class FormState(
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val saveError: String? = null,
|
||||||
|
val isFormValid: Boolean = false
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Klare Events von der UI:**
|
||||||
|
```kotlin
|
||||||
|
sealed interface FormEvent {
|
||||||
|
data class NameChanged(val newName: String) : FormEvent
|
||||||
|
object SaveButtonClicked : FormEvent
|
||||||
|
object ErrorDismissed : FormEvent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Zentrale Logik im ViewModel:**
|
||||||
|
```kotlin
|
||||||
|
class FormViewModel : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(FormState())
|
||||||
|
val state: StateFlow<FormState> = _state
|
||||||
|
|
||||||
|
fun onEvent(event: FormEvent) {
|
||||||
|
when (event) {
|
||||||
|
is FormEvent.NameChanged -> {
|
||||||
|
_state.update { it.copy(name = event.newName, isFormValid = /*...*/) }
|
||||||
|
}
|
||||||
|
is FormEvent.SaveButtonClicked -> {
|
||||||
|
_state.update { it.copy(isSaving = true) }
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empfohlene Bibliotheken
|
||||||
|
|
||||||
|
Die manuelle Implementierung von UDF ist möglich, aber dedizierte Bibliotheken bieten eine bewährte Struktur und reduzieren Boilerplate.
|
||||||
|
|
||||||
|
1. **Voyager:**
|
||||||
|
* **Primär eine Navigationsbibliothek**, die aber ein leichtgewichtetes und pragmatisches UDF-System (`ScreenModel`) mitbringt.
|
||||||
|
* **Empfehlung:** **Der empfohlene Einstiegspunkt.** Da das Projekt ohnehin eine Navigationslösung benötigt, ist dies die effizienteste Wahl.
|
||||||
|
|
||||||
|
2. **MVIKotlin:**
|
||||||
|
* Eine sehr mächtige, explizite MVI-Bibliothek (eine Form von UDF).
|
||||||
|
* **Empfehlung:** **In der Hinterhand behalten.** Für extrem komplexe Features, bei denen erweiterte Funktionen wie Time-Travel-Debugging den Mehraufwand rechtfertigen, kann MVIKotlin gezielt eingesetzt werden.
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
Diese Strategie muss nicht sofort für bestehende Screens umgesetzt werden. Sie soll als **Blaupause für alle zukünftigen, fachlichen Features** dienen, sobald das grundlegende SQLDelight-Sync-Problem gelöst ist.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
type: Journal
|
||||||
|
status: COMPLETED
|
||||||
|
owner: Curator
|
||||||
|
date: 2026-01-28
|
||||||
|
participants:
|
||||||
|
- Lead Architect
|
||||||
|
- Frontend Expert
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session Log: 28. Jänner 2026 - Lösung des SQLDelight-Sync-Problems
|
||||||
|
|
||||||
|
## Zielsetzung
|
||||||
|
Systematische Analyse und Behebung des kritischen SQLDelight-Bugs in der Web-App (JS/Wasm), der einen echten Delta-Sync verhinderte.
|
||||||
|
|
||||||
|
## Durchgeführte Arbeiten
|
||||||
|
|
||||||
|
### 1. Analyse & Fehlerreproduktion
|
||||||
|
* **Ausgangslage:** Der `PingEventRepositoryImpl` enthielt einen Workaround, der `getLatestSince()` immer `null` zurückgeben ließ, um einen Full-Sync zu erzwingen.
|
||||||
|
* **Reproduktion:** Der Workaround wurde entfernt und durch den ursprünglichen Code (`db.appDatabaseQueries.selectLatestPingEventId().executeAsOneOrNull()`) ersetzt.
|
||||||
|
* **Ergebnis:** Der Fehler `The driver used with SQLDelight is asynchronous...` wurde wie erwartet in der Browser-Konsole reproduziert.
|
||||||
|
|
||||||
|
### 2. Systematische Ursachenforschung
|
||||||
|
* **Hypothese 1 (Konfiguration):** Die Build-Konfiguration (`frontend/core/local-db/build.gradle.kts`) wurde überprüft. `generateAsync.set(true)` war korrekt gesetzt. Die Fehlermeldung war also eine falsche Fährte.
|
||||||
|
* **Hypothese 2 (API-Nutzung):** Die Analyse ergab, dass `.executeAsOneOrNull()` eine **blockierende** API ist, die mit dem asynchronen Web-Worker-Treiber in Konflikt steht.
|
||||||
|
* **Lösung:** Die korrekte, **nicht-blockierende** API aus der SQLDelight-Coroutines-Erweiterung muss verwendet werden.
|
||||||
|
|
||||||
|
### 3. Implementierung des Fixes
|
||||||
|
* Der Aufruf in `PingEventRepositoryImpl` wurde von `executeAsOneOrNull()` auf `awaitAsOneOrNull()` geändert.
|
||||||
|
* Der korrekte Import-Pfad `app.cash.sqldelight.async.coroutines.awaitAsOneOrNull` wurde hinzugefügt.
|
||||||
|
|
||||||
|
## Ergebnis & Status
|
||||||
|
* **Erfolg:** Das SQLDelight-Sync-Problem ist **gelöst**.
|
||||||
|
* Die Web-App führt nun einen korrekten **Delta-Sync** durch, was durch den Aufruf des `/api/ping/sync?since=...` Endpunkts im Netzwerk-Log bestätigt wurde.
|
||||||
|
* Die wichtigste technische Schuld im Frontend wurde beseitigt.
|
||||||
|
|
||||||
|
## Nächste Schritte (Diskutiert)
|
||||||
|
* **Docker-Integration:** Das Frontend (Build & Deployment) soll in die bestehende Docker-Konstruktion des Projekts integriert werden.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
+4
-6
@@ -3,6 +3,7 @@ package at.mocode.ping.feature.data
|
|||||||
import at.mocode.frontend.core.localdb.AppDatabase
|
import at.mocode.frontend.core.localdb.AppDatabase
|
||||||
import at.mocode.frontend.core.sync.SyncableRepository
|
import at.mocode.frontend.core.sync.SyncableRepository
|
||||||
import at.mocode.ping.api.PingEvent
|
import at.mocode.ping.api.PingEvent
|
||||||
|
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||||
|
|
||||||
// ARCH-BLUEPRINT: This repository implements the generic SyncableRepository
|
// ARCH-BLUEPRINT: This repository implements the generic SyncableRepository
|
||||||
// for a specific entity, bridging the gap between the sync core and the local database.
|
// for a specific entity, bridging the gap between the sync core and the local database.
|
||||||
@@ -12,12 +13,9 @@ class PingEventRepositoryImpl(
|
|||||||
|
|
||||||
// The `since` parameter for our sync is the ID of the last event, not a timestamp.
|
// The `since` parameter for our sync is the ID of the last event, not a timestamp.
|
||||||
override suspend fun getLatestSince(): String? {
|
override suspend fun getLatestSince(): String? {
|
||||||
println("PingEventRepositoryImpl: getLatestSince called")
|
println("PingEventRepositoryImpl: getLatestSince called - using corrected async implementation")
|
||||||
// WORKAROUND: executeAsOneOrNull() fails with "driver is asynchronous" error.
|
// FIX: Use .awaitAsOneOrNull() for async drivers instead of the blocking .executeAsOneOrNull()
|
||||||
// This seems to be a bug or configuration issue where the sync version is called.
|
return db.appDatabaseQueries.selectLatestPingEventId().awaitAsOneOrNull()
|
||||||
// Since we are in Phase 2 (Tracer Bullet), we can live with a full sync for now.
|
|
||||||
// We return null to force a full sync, which works because upsert() works.
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun upsert(items: List<PingEvent>) {
|
override suspend fun upsert(items: List<PingEvent>) {
|
||||||
|
|||||||
Reference in New Issue
Block a user