# ADD: Offline-First Cache Layer (Room)

> Architecture Decision Document — Kaizen Android App
> Status: Draft v2, 2026-06-21
> Wichtig: Vor der Implementierung muss der aktuelle Code-Stand erneut geprüft werden. Dieses Dokument beschreibt die Architektur-Entscheidung und den Plan, nicht den exakten Code. Dateipfade, Signaturen und Patterns koennen sich seit dem Schreiben geaendert haben.

---

## 1. Problem

Die App hat keine lokale Datenbank. Jeder App-Start, jeder Sidebar-Swipe und jeder Chat-Wechsel blockiert auf einen Server-Fetch. Ohne Netzwerk ist die App komplett leer.

**Auswirkung auf die UX:**
- Sidebar oeffnet sich mit Ladezeit statt sofort
- Chat-Wechsel hat spuerbare Latenz (Netzwerk-RTT)
- Flugmodus / schlechtes Netz = leerer Bildschirm
- Kein Sofortstart-Gefuehl wie bei ChatGPT, Gemini, etc.

**Die Konkurrenz:** ChatGPT, Gemini und Claude nutzen lokale Offline-First-Datenbanken. Chats oeffnen sich instant (0ms), waehrend im Hintergrund mit dem Server synchronisiert wird.

---

## 2. Entscheidung

**Room** (Jetpack, SQLite-Wrapper) als lokaler Read-Cache.

### Warum Room:
- Compose-native: DAOs geben `Flow<List<T>>` zurueck -> `collectAsState()` in der UI
- Bereits im Jetpack-Stack (Compose, EncryptedSharedPreferences, Lifecycle)
- Compile-time Query-Checks
- Eingebauter Migrations-Support (bzw. `fallbackToDestructiveMigration()` fuer einen Cache)
- Groesstes Oekosystem, beste Doku
- Skaliert fuer die Zukunft: FTS-Suche, Media-Cache-Tracking, Attachment-Queries

### Verworfene Alternativen:
| Option | Grund gegen |
|--------|-------------|
| SQLDelight | Mehr Setup (eigenes Gradle-Plugin, raw SQL), weniger Compose-Integration |
| ObjectBox | Kleine Community, Vendor-Lock-in-Risiko |
| DataStore | Nur Key-Value, keine Queries |
| Raw SQLite | Kein Grund, das Rad neu zu erfinden |
| SharedPreferences JSON | Skaliert nicht fuer Messages (laedt alles in RAM), keine Queries |

### Plattform-Strategie:
Android = Room (Jetpack-nativ). Die iOS-App wird separat in Swift/SwiftUI mit SwiftData/CoreData gebaut — jede Plattform nutzt ihren nativen Stack. Kein KMP.

---

## 3. Architektur

### Prinzip: Read-Cache, nicht Sync-Engine

```
Server (PostgreSQL)  =  Source of Truth
Room (SQLite)        =  Lokaler Cache
Writes               =  Immer direkt an den Server
```

Kein Offline-Senden, kein Conflict-Resolution, kein Sync-Queue. Das waere ein riesiger Komplexitaetssprung, der aktuell keinen Mehrwert bringt.

### Streaming-Hybrid-Modell

Waehrend eines aktiven Streams werden Messages 60x/s in-memory mutiert. Room in dieser Frequenz zu beschreiben waere absurder I/O. Daher:

```
Historische Messages   ->  aus Room (Flow<List<MessageEntity>>)
Aktive Streaming-Msg   ->  in-memory (mutableStateOf<Message>)
Nach Stream-Ende       ->  in Room flushen (upsert)
```

Room ist Source of Truth fuer **ruhende** Daten. Waehrend des Streams ist der in-memory State fuehrend. Nach Abschluss werden die neuen Messages in Room geschrieben und der Flow aktualisiert die UI automatisch.

### Datenfluss (vorher vs. nachher)

```
VORHER:
  App oeffnen -> fetch /api/v1/conversations (500ms+) -> anzeigen
  Chat oeffnen -> fetch /api/v1/conversations/[id] (300ms+) -> anzeigen

NACHHER:
  App oeffnen -> Room lesen (0ms) -> sofort anzeigen
                 -> Background: Server fetchen -> Room updaten -> UI reaktiv aktualisiert
  Chat oeffnen -> Room lesen (0ms) -> sofort anzeigen
                  -> Background: Server fetchen (falls noetig) -> Room updaten
```

### Schichten

```
┌──────────────────────────────────┐
│          UI (Compose)             │  ChatScreen, Sidebar, etc.
│     collectAsState(Flow<T>)       │  Beobachtet Room-Flows
├──────────────────────────────────┤
│         ChatViewModel             │  Haelt Room-Flows + Streaming-State
│   (wie SessionViewModel, manuell) │  Kein Hilt/Koin noetig
├──────────────────────────────────┤
│        Repository Layer           │  Entscheidet: Room oder Server?
│    ConversationRepository         │  Steuert Background-Refresh
│       MessageRepository           │
├───────────────┬──────────────────┤
│  Room (DAO)   │    KaizenApi     │  Lokaler Cache │ Server-Calls
│   Flow<T>     │    suspend fun   │  (unveraendert)
└───────────────┴──────────────────┘
```

---

## 4. Room-Schema

### TypeConverter

```kotlin
class Converters {
    private val json = Json { ignoreUnknownKeys = true }

    @TypeConverter
    fun attachmentsToJson(attachments: List<Attachment>): String =
        json.encodeToString(attachments)

    @TypeConverter
    fun jsonToAttachments(value: String): List<Attachment> =
        try { json.decodeFromString(value) } catch (_: Exception) { emptyList() }
}
```

### Entities

```kotlin
@Entity(tableName = "conversations")
data class ConversationEntity(
    @PrimaryKey val id: String,
    val title: String,
    val locked: Boolean,
    val pinned: Boolean,
    val updatedAt: String?,
    val cachedAt: Long,       // System.currentTimeMillis(), fuer Stale-Check
)

@Entity(
    tableName = "messages",
    foreignKeys = [ForeignKey(
        entity = ConversationEntity::class,
        parentColumns = ["id"],
        childColumns = ["conversationId"],
        onDelete = ForeignKey.CASCADE,
    )],
    indices = [Index("conversationId")],
)
data class MessageEntity(
    @PrimaryKey val id: String,
    val conversationId: String,
    val role: String,          // "user" | "assistant"
    val content: String,
    val attachments: List<Attachment>,
    val sortOrder: Int,
)
```

### Mapper-Funktionen

```kotlin
fun ConversationSummary.toEntity() = ConversationEntity(
    id = id,
    title = title,
    locked = locked,
    pinned = pinned,
    updatedAt = updatedAt,
    cachedAt = System.currentTimeMillis(),
)

fun ConversationEntity.toSummary() = ConversationSummary(
    id = id,
    title = title,
    locked = locked,
    pinned = pinned,
    updatedAt = updatedAt,
)

fun StoredMessage.toEntity(conversationId: String, sortOrder: Int) = MessageEntity(
    id = id,
    conversationId = conversationId,
    role = role,
    content = content,
    attachments = attachments,
    sortOrder = sortOrder,
)

fun MessageEntity.toStoredMessage() = StoredMessage(
    id = id,
    role = role,
    content = content,
    attachments = attachments,
)
```

### DAOs

```kotlin
@Dao
interface ConversationDao {
    @Query("SELECT * FROM conversations ORDER BY pinned DESC, updatedAt DESC")
    fun observeAll(): Flow<List<ConversationEntity>>

    @Upsert
    suspend fun upsertAll(conversations: List<ConversationEntity>)

    @Query("DELETE FROM conversations")
    suspend fun deleteAll()

    @Transaction
    suspend fun replaceAll(conversations: List<ConversationEntity>) {
        deleteAll()
        upsertAll(conversations)
    }

    @Query("SELECT cachedAt FROM conversations LIMIT 1")
    suspend fun lastCachedAt(): Long?
}

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
    fun observeByConversation(convId: String): Flow<List<MessageEntity>>

    @Query("SELECT * FROM messages WHERE conversationId = :convId ORDER BY sortOrder")
    suspend fun getByConversation(convId: String): List<MessageEntity>

    @Upsert
    suspend fun upsertAll(messages: List<MessageEntity>)

    @Query("DELETE FROM messages WHERE conversationId = :convId")
    suspend fun deleteByConversation(convId: String)
}
```

### Database

```kotlin
@Database(
    entities = [ConversationEntity::class, MessageEntity::class],
    version = 1,
)
@TypeConverters(Converters::class)
abstract class KaizenDatabase : RoomDatabase() {
    abstract fun conversationDao(): ConversationDao
    abstract fun messageDao(): MessageDao

    companion object {
        @Volatile private var instance: KaizenDatabase? = null

        fun get(context: Context): KaizenDatabase =
            instance ?: synchronized(this) {
                instance ?: Room.databaseBuilder(
                    context.applicationContext,
                    KaizenDatabase::class.java,
                    "kaizen-cache",
                )
                .fallbackToDestructiveMigration()
                .build()
                .also { instance = it }
            }
    }
}
```

`fallbackToDestructiveMigration()` ist bewusst: Room ist ein Cache, kein Source of Truth. Bei Schema-Aenderungen wird die DB weggeworfen und beim naechsten Server-Fetch neu gefuellt. Keine Migrations noetig.

---

## 5. Repository Layer

```kotlin
class ConversationRepository(
    private val dao: ConversationDao,
) {
    fun observeAll(): Flow<List<ConversationSummary>> =
        dao.observeAll().map { list -> list.map { it.toSummary() } }

    suspend fun refresh(baseUrl: String, token: String) {
        when (val r = KaizenApi.fetchConversations(baseUrl, token)) {
            is FetchResult.Ok -> {
                val entities = r.data.map { it.toEntity() }
                dao.replaceAll(entities)
            }
            is FetchResult.Fail -> { /* Cache bleibt stehen, kein Crash */ }
        }
    }
}

class MessageRepository(
    private val dao: MessageDao,
) {
    fun observeMessages(convId: String): Flow<List<MessageEntity>> =
        dao.observeByConversation(convId)

    suspend fun fetchAndCache(baseUrl: String, token: String, convId: String) {
        val stored = KaizenApi.fetchMessages(baseUrl, token, convId)
        val entities = stored.mapIndexed { i, m -> m.toEntity(convId, i) }
        dao.deleteByConversation(convId)
        dao.upsertAll(entities)
    }

    suspend fun cacheMessages(convId: String, messages: List<MessageEntity>) {
        dao.upsertAll(messages)
    }
}
```

---

## 6. ChatViewModel

Neuer ViewModel, analog zu `SessionViewModel` — manuell instanziiert, kein DI.

```kotlin
class ChatViewModel(context: Context) {
    private val db = KaizenDatabase.get(context)
    val conversationRepo = ConversationRepository(db.conversationDao())
    val messageRepo = MessageRepository(db.messageDao())
}
```

Instanziierung in `MainActivity`, weitergabe an `ChatScreen` wie `SessionViewModel`.

---

## 7. UI-Aenderungen (Uebersicht)

### Sidebar
- Vorher: `conversations` ist ein `var` State, gefuellt durch `fetchConversations()`
- Nachher: `chatViewModel.conversationRepo.observeAll().collectAsState()` — instant, reaktiv
- Background-Refresh beim App-Start und bei `LifecycleResumeEffect`

### ChatScreen — openConversation()
- Vorher: `fetchMessages()` blockiert, dann `messages.addAll()`
- Nachher: Sofort aus Room laden (falls cached), parallel vom Server refreshen
- Room-Flow fuellt die historischen Messages, Streaming-Message bleibt in-memory

### ChatScreen — send()
- Waehrend des Streams: `messages` bleibt `mutableStateListOf` (in-memory, wie bisher)
- Nach Stream-Ende: `messageRepository.cacheMessages()` zusaetzlich zum bestehenden `saveMessages()`
- `conversationRepo.refresh()` aktualisiert die Sidebar reaktiv ueber den Room-Flow

### Streaming-Hybrid im Detail
```
1. User oeffnet Chat     -> Room-Flow liefert gecachte Messages (instant)
                         -> Background: Server-Fetch -> Room-Update -> Flow emittiert neu
2. User sendet Message   -> In-memory State fuehrt (wie bisher)
                         -> Stream laeuft, messages[idx].copy(content=...) in-memory
3. Stream endet          -> messageRepo.cacheMessages(convId, newMessages)
                         -> conversationRepo.refresh() fuer Sidebar-Update
4. User wechselt Chat    -> Zurueck zu Schritt 1
```

### Stale-Check
- `cachedAt` auf jeder Conversation — wenn aelter als z.B. 5 Minuten, Background-Refresh
- App-Start: immer refreshen, aber UI zeigt sofort den Cache

---

## 8. Neue Dependencies

```toml
# gradle/libs.versions.toml
room = "2.7.1"    # Exakte Version beim Implementieren pruefen!
ksp = "..."       # Muss zur Kotlin-Version passen (2.2.10)

# libraries
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }

# plugins
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
```

```kotlin
// build.gradle.kts
plugins {
    alias(libs.plugins.ksp)
}
dependencies {
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)
    ksp(libs.room.compiler)
}
```

---

## 9. Implementierungs-Reihenfolge

| Schritt | Was | Abhaengigkeit |
|---------|-----|---------------|
| 1 | Room-Dependencies + KSP Plugin | — |
| 2 | TypeConverters + Entities + DAOs + KaizenDatabase (mit Singleton + fallbackToDestructiveMigration) | Schritt 1 |
| 3 | Mapper-Funktionen (toEntity/toSummary/toStoredMessage) | Schritt 2 |
| 4 | ConversationRepository + MessageRepository | Schritt 3 |
| 5 | ChatViewModel + Instanziierung in MainActivity | Schritt 4 |
| 6 | Sidebar auf Room-Flow umstellen | Schritt 5 |
| 7 | openConversation() auf Room umstellen (Hybrid: Room + Server-Refresh) | Schritt 5 |
| 8 | send() cached neue Messages in Room nach Stream-Ende | Schritt 5 |

---

## 10. Was sich NICHT aendert

- **KaizenApi** bleibt unveraendert — reine HTTP-Calls, kein Room-Wissen
- **StreamConsumer** bleibt unveraendert — Streaming-Parsing ist orthogonal zum Cache
- **Senden ist weiterhin nur online** — kein Offline-Queue, kein Conflict-Resolution
- **Server bleibt Source of Truth** — Room ist ein Cache, keine eigene Datenhaltung
- **SecureStore / ServerConfig** bleiben in EncryptedSharedPreferences
- **Models-Cache** bleibt in SecureStore (klein, flach, funktioniert)

---

## 11. Zukunft (nicht Teil dieser Iteration)

- **Offline-Queue fuer Sends** — Messages lokal queuen, bei Netzwerk senden (braucht Conflict-Resolution)
- **Inkrementeller Sync** — nur geaenderte Conversations holen statt die volle Liste (braucht Server-seitigen `since`-Parameter)
- **Media-Cache** — Bilder/Dateien lokal cachen (Coil oder eigener Disk-Cache)
- **Volltextsuche** — FTS4/FTS5 ueber gecachte Messages (Room unterstuetzt das nativ)
- **Attachment-Queries** — "Zeig mir alle Chats mit Bildern/PDFs" ueber die Room-DB
