Local message cache: instant start + offline reading (SQLDelight)
- Cache.sq / ChatDb: lanes, channels and last_lane persisted as JSON snapshots; sanitize on restore (Pending->Failed, streaming->false); ephemeral items (tool, system) skipped - Platform drivers via expect/actual: AndroidSqliteDriver (app db dir) / JdbcSqliteDriver (~/.iris/iris_cache.db) - IrisController: restore before connect, debounced (750ms) snapshot persistence, synchronous flush on dispose, clearAll on forget - History robustness: historyLoaded marked only when the response is processed (lost request/response retried on reconnect); events collector wrapped in try/catch; onHelloAck fast path so the history request fires on the WS thread instead of the starved state collector; frame-decode and history-load logging - Protocol: HistoryMessage.media nullable (defensive vs older gateways that sent "media": null) - Tests: ChatDbTest (jvmTest, JDBC in-memory), ChatStoreCacheTest, HistoryPayloadTest, HistoryWireTest (real captured 92KB response) - docs/10: §10.7 implemented schema, new §10.9 cache behavior
This commit is contained in:
1 parent
9a519e3c5a
commit
81f42ab761
18 files changed
+1040
-268
No files matched your search
+53
-8
@@ -12,7 +12,7 @@ the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
||||
| Async | Kotlinx Coroutines + Flow |
|
||||
| WS client | OkHttp (`WebSocketListener`) |
|
||||
| JSON | kotlinx-serialization |
|
||||
| Local DB | **SQLDelight** (KMP; channels, messages cache, media index, sync cursor, settings) |
|
||||
| Local DB | **SQLDelight** (KMP; local cache: messages per lane, channel directory, meta — see 10.7/10.9. Sync cursor + settings live in `SecureStore`, media files in `MediaCache`) |
|
||||
| Media playback | Media3 **ExoPlayer** (audio + video) |
|
||||
| Push | Firebase Messaging (FCM) [primary] / ntfy listener [fallback] |
|
||||
| DI | Hilt |
|
||||
@@ -250,13 +250,58 @@ color. Accent = user's chosen brand color (default indigo, like the reference).
|
||||
|
||||
## 10.7 SQLDelight schema (cache)
|
||||
|
||||
- `channels(chat_id PK, name, kind, parent_chat_id, is_default, last_preview,
|
||||
last_ts, unread)`.
|
||||
- `messages(id PK, chat_id, thread_id, role, text, reasoning, model, tokens,
|
||||
ts, status[pending|sent|read], media_json)`.
|
||||
- `media(media_id PK, local_path, kind, mime, size, ts)`.
|
||||
- `meta(key PK, value)` — sync cursor, settings, device_id, server url, pinned
|
||||
cert fingerprint.
|
||||
Implemented in `app/shared/src/commonMain/sqldelight/iris/db/Cache.sq`
|
||||
(database `IrisDatabase`, package `iris.db`). Rows are stored as **JSON
|
||||
payloads** so the schema does not drift with the Kotlin model fields
|
||||
(`MessageItem` / `ChannelInfo` are `@Serializable`):
|
||||
|
||||
- `message(lane PK, id PK, ts, payload)` — one row per persisted
|
||||
`MessageItem`; `lane` is the lane key (`chatId` or `chatId::threadId`),
|
||||
`ts` for ordering. Tool cards and local system notices are **not**
|
||||
persisted (ephemeral; they are not part of `history` either).
|
||||
- `channel(chat_id PK, payload)` — the whole channel directory (channels +
|
||||
threads), so the drawer works offline.
|
||||
- `meta(key PK, value)` — small UI state (currently: `last_lane`, the
|
||||
last-viewed lane, restored on startup).
|
||||
|
||||
Not in the DB (already persisted elsewhere): sync cursor + settings live in
|
||||
`SecureStore`; media files in the `MediaCache` (files, not DB rows).
|
||||
|
||||
## 10.9 Local cache (offline reading, instant start)
|
||||
|
||||
The server is authoritative; the SQLite cache (10.7) makes the app feel
|
||||
instant and readable offline (industry-standard chat-app behavior):
|
||||
|
||||
- **Startup:** `IrisController` restores `ChatStore` + `ChannelStore` from
|
||||
the cache *before* the gateway connection is up — the UI is populated
|
||||
immediately, no waiting for `history`/`sync`. Restored state is sanitized:
|
||||
a streaming bubble is finalized (the `sync` replay finalizes it for real)
|
||||
and a pending send becomes *failed* (tap to retry).
|
||||
- **Writes:** the controller snapshots the in-memory stores into the cache,
|
||||
debounced (750 ms) — one atomic full rewrite per change, so every mutation
|
||||
path (frames, media pulls, read receipts, deletes, retries) is covered
|
||||
without per-mutation hooks. A final synchronous flush runs on `dispose`.
|
||||
The outbox replay on reconnect is the safety net for anything lost in the
|
||||
debounce window (the cursor only advances on `sync.done`).
|
||||
- **Connect:** the last-viewed lane (from `meta`) is kept if it still exists
|
||||
on the server, otherwise the app falls back to the home channel. The full
|
||||
`history` of the active lane is reloaded either way (the cache may be
|
||||
stale; `sync` only covers the outbox delta since the saved cursor). This
|
||||
runs on a **fast path** (`GatewayClient.onHelloAck`, fired on the WS thread
|
||||
the moment `hello.ack` lands) rather than the state collector, which can be
|
||||
starved for seconds during startup on slow devices — getting the request
|
||||
out early so the response lands inside a flaky network's window. A lane is
|
||||
marked "history loaded" only when the response is actually processed, so a
|
||||
request/response lost in a WS drop is retried on the next (re)connect.
|
||||
- **Offline:** with no connection the cached lanes/channels are fully
|
||||
readable (the composer is disabled, a connection banner is shown). New
|
||||
frames reconcile the cache on reconnect via `sync` + `history`.
|
||||
- **Forget pairing:** `chatDb.clearAll()` wipes the cache (a different
|
||||
gateway means a different chat universe).
|
||||
|
||||
Storage: `AndroidSqliteDriver` (app database dir) on Android,
|
||||
`JdbcSqliteDriver` (`~/.iris/iris_cache.db`) on desktop — both via the
|
||||
`createCacheDriver()` platform actual (`iris/platform/PlatformStorage.kt`).
|
||||
|
||||
## 10.8 Onboarding / Connect screen
|
||||
|
||||
|
||||
Reference in new issue
Block a user