Skip to content

Enhance offline resilience, caching, and SQLite persistence#447

Draft
kevintran-git wants to merge 11 commits into
cogwheel0:mainfrom
kevintran-git:feat-phase1-2-localfirst
Draft

Enhance offline resilience, caching, and SQLite persistence#447
kevintran-git wants to merge 11 commits into
cogwheel0:mainfrom
kevintran-git:feat-phase1-2-localfirst

Conversation

@kevintran-git
Copy link
Copy Markdown

This pull request introduces significant improvements to authentication state management and database persistence. The main highlights are a more resilient, "local-first" authentication flow that allows the UI to render cached user data before network checks complete, and the introduction of a new SQLite database layer with full-text search support and robust migration logic. These changes improve startup speed, offline usability, and data migration reliability.

Authentication state management:

  • Implements a "local-first" authentication flow: On cold start, the app immediately enters the authenticated state using cached user data, while a background probe checks server reachability. If the server is unreachable, a non-blocking reconnect banner is shown, but the UI remains usable and outbound actions are queued for retry. (lib/core/auth/auth_state_manager.dart [1] [2] [3] [4] [5] [6] [7] [8]
  • Adds a serverReachable flag to AuthState, with new logic to update this flag based on background /health probes and network connectivity changes, enabling the UI to reactively display reconnect banners. (lib/core/auth/auth_state_manager.dart [1] [2] [3] [4] [5] [6] [7]
  • Refactors the server reachability check into dedicated methods (_probeReachabilityAndValidate, probeServerReachability) for clearer logic and easier manual retry from the UI. (lib/core/auth/auth_state_manager.dart lib/core/auth/auth_state_manager.dartR738-R789)
  • Listens to connectivity changes and re-probes server reachability when the device comes back online, ensuring the reconnect banner clears promptly. (lib/core/auth/auth_state_manager.dart lib/core/auth/auth_state_manager.dartR190-R201)

Database and persistence:

  • Introduces a new AppDatabase class using SQLite (via sqflite and FFI), with schema and migration logic supporting full-text search (FTS5) on message content for offline search. This replaces previous per-conversation Hive blobs, improving scalability and searchability. (lib/core/persistence/database/app_database.dart lib/core/persistence/database/app_database.dartR1-R206)
  • Updates the PersistenceMigrator to handle migrations from SharedPreferences to Hive (v1), and from Hive to SQLite (v2), with a new target version and improved testability. (lib/core/persistence/persistence_migrator.dart lib/core/persistence/persistence_migrator.dartR3-R35)

Build configuration:

  • Adjusts Android build configuration to use the debug signing config if the release keystore is missing, improving developer experience for local builds. (android/app/build.gradle.kts android/app/build.gradle.ktsL56-R59)

kevintran-git and others added 8 commits April 24, 2026 00:22
Add network-resilience and cold-start improvements: track server reachability in AuthState, run background /health probes, retry on connectivity changes, and expose a non-blocking server reachability banner. Instrument cold-start with a StartupTimeline and record key markers.

Add granular local caches and persistence: per-conversation cache, per-file-info cache, and composer drafts in OptimizedStorageService; hydrate/cache conversations in providers and warm caches on conversation load. Use cached FileInfo for sends to avoid extra network round-trips and warm the cache on upload/response.

Optimize send flow: parallelize new-conversation creation with attachment metadata fetches, include outboundTaskId metadata for queued sends, and wire message delivery status via a new messageDeliveryStatus provider. Surface per-message delivery badges in the UI with retry support.

Composer & UI tweaks: persist/hydrate drafts in ModernChatInput (debounced saves, cross-chat switching), show startup/chat-first-paint markers, add server reconnect banner to ChatPage, and replace drawer loading spinner with skeleton placeholders. Clean up storage on reset to remove new caches and drafts.
Introduce SQLite-backed storage for conversations and messages: add AppDatabase (schema, indices, in-memory open for tests) and ConversationStore (CRUD, upsert, append/update/delete message, previews).

Migrate persistence flow: bump migrator target to v2 and add logic to move per-conversation Hive blobs into SQLite (best-effort, deletes migrated keys). Wire the new DB into app startup and Riverpod providers; update OptimizedStorageService to delegate conversation operations to ConversationStore.

Add unit tests for ConversationStore and PersistenceMigrator (sqflite_common_ffi test setup). Add sqflite and test ffi dependency and minor Android build.gradle tweak to fallback to debug signing if no keystore. Debug/logging preserved for migration and DB operations.
Release builds without keystore.properties were silently producing
unsigned APKs that wouldn't install. Use the debug signing config as
the fallback so local release builds still work end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions to the local-first persistence layer:

1. ConversationStore.upsertMessageEnsuringConversation — tolerant
   message upsert that scaffolds a header row if the conversation
   doesn't yet exist. Lets the chat send/streaming path persist a
   single message without first round-tripping the entire conversation.
   Wrapped in a transaction to avoid races between parallel sends.

2. SQLite schema v2 with an FTS5 mirror of message bodies. Triggers
   keep messages_fts in sync with messages so search results follow
   inserts/updates/deletes automatically. searchConversations() unions
   title-LIKE matches with FTS5 prefix matches, dedupes by id, and
   sorts pinned + recency. User input is sanitized so FTS operators
   can't be injected; falls back to title-only on FTS errors. Existing
   v1 installs get backfilled on first open.

OptimizedStorageService gains pass-throughs (persistMessageEnsuring-
Conversation, persistUpdatedMessage, persistMessageDeletion,
searchConversationsLocal) so callers in feature code don't reach into
the store directly. All wrapped in try/catch — persistence failure
logs but never breaks the calling flow.

15 new conversation_store tests cover upsert + FTS5 paths
(round-trip, prefix match, dedupe, archived exclusion, syntax
sanitization, trigger sync on update/delete).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… deletes

Builds on the new persistence APIs to deliver four user-visible
local-first behaviors in the chat surface.

Phase 3b — granular SQLite writes during send:
  Every meaningful state transition in the send pipeline now persists
  to SQLite as it happens. The user message lands as soon as the
  conversation has a real id; the assistant message is throttled to
  ~2s during streaming via the existing buffer-sync timer, with a
  guaranteed final write at completion. Error states persist too so a
  failed send survives a restart with its error populated. Reviewer
  mode and temporary chats are skipped via _shouldPersistGranular.
  All persists are unawaited and try/catch-guarded — disk never
  blocks the UI or breaks the stream state machine.

Phase 4a — stuck-streaming recovery:
  refreshActiveConversationFromServer drops stale transport state and
  pulls the server's authoritative copy, recovering messages whose
  generation completed server-side after the client disconnected.
  Triggered by an always-visible app bar refresh button, the existing
  pull-to-refresh, and a foreground-resume observer (chatLifecycle-
  Provider) that fires only when there's a stuck isStreaming
  placeholder. _manualRefreshInFlight guards against double-taps.

Phase 4b — local-first drawer search:
  localSearchProvider hits the SQLite FTS5 index in tens of ms; the
  drawer renders those results immediately and merges in the server
  result asynchronously by id, preferring the local payload. Spinner
  only when both sources are loading and we have nothing to display;
  "no results" only after the server has settled, so cached hits
  never get masked by a transient false negative.

Phase 4c — message deletion:
  deleteMessages performs an optimistic local removal (chat list,
  active conversation, drawer summary, SQLite rows) then best-effort
  syncs the new authoritative message list to the server. Server
  failure logs but does not roll back — the next refresh reconciles.
  Wired up to the existing multi-select delete UI in chat_page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Provides an escape hatch to drop cached conversations, file metadata,
and unsent drafts without signing out. Sits above Sign Out in the
profile page since both are destructive account actions; clearing
invalidates the conversations providers so the drawer reloads
immediately.

Covers the case where a user wants to free disk or recover from a
stale cache without losing their session — useful before LRU eviction
ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local-first chat: instant cold start, offline send queue, granular
SQLite writes during streaming, FTS5 search, app-bar refresh, and
manual cache clear all land here. Worth a minor bump from 2.6.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Android's system SQLite omits the FTS5 module, causing a crash at
startup because the db schema is created before runApp() is called.
sqflite_common_ffi + sqlite3_flutter_libs bundle a modern SQLite
(3.52.0) compiled with FTS5, replacing the system library on Android.

Also:
- Bump schema from v2→v3; upgrade path drops any partial FTS5
  artifacts from failed v2 installs (DROP TABLE IF EXISTS) and
  recreates the FTS table cleanly.
- _toFtsMatchExpression now returns '' when all tokens are stripped,
  with an early-return in searchConversations instead of passing an
  invalid sentinel to MATCH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cogwheel0
Copy link
Copy Markdown
Owner

@kevintran-git this is an interesting PR but massive. Could you please open an issue describing the problem it will solve first? I will look into implementing it myself as it requires extensive testing. Thank you for the effort!

Improve local-first resilience across auth, storage, task queue and send path:

- Auth: avoid full-screen error on transient failures if a valid token exists; toggle a serverReachable flag instead of demoting to AuthStatus.error for mid-session 401/403 and retry exhaustion. Keep state when possible so UI remains usable.
- Persistence: add ConversationStore.replaceAllConversations and make upsert preserve messages marked metadata.localPending (protect queued sends from passive syncs). Use insert-or-update for conversation headers to avoid FK CASCADE wiping protected messages.
- Hive: add cachedUserSystemPrompt key and OptimizedStorageService helpers (replaceLocalConversations, get/setCachedUserSystemPrompt) for synchronous read/warm cache of user system prompt used in hot send path.
- Chat send: make sends idempotent on retries by reusing original message IDs when outboundTaskId is present; mark queued messages with localPending and persist them early to avoid vanishing UI; prefer cached system prompt synchronously and refresh in background; classify send errors into auth/permanent/transient and map behavior (auth → trigger auth flow, permanent → surface error and stop retries, transient → keep for TaskQueue retry). Clear localPending after successful dispatch and re-persist.
- Task queue & tasks: add OutboundTask.failureError and failedPermanently helpers; on connectivity restore cancel backoffs and revive tasks that exhausted retries while offline (but leave permanent failures alone); refine load/save filters and retention so transient failures can be revived.
- Tests: add unit tests for ConversationStore localPending preservation and replaceAllConversations, and tests for OutboundTask.failedPermanently.

These changes aim to make the app more robust to intermittent network/server issues, avoid surprising UX (vanishing messages or blocking full-screen errors), and ensure queued sends survive reconnects and restarts.
Replace the previous Hive/task-queue outbox with a SQLite-backed outbox and worker. Bump DB schema to v4 and add send_status/send_attempt/send_next_at/send_error columns + index and migration. Add MessageSendStatus enum, PendingMessage model and numerous ConversationStore APIs (upsert/insertMessageAsSending, markSending, markSent, scheduleRetry, markPermanentFailed, cancelPending, getSendStatus, pendingMessages, pendingMessageIdsByConversation) to manage per-row outbound state. Introduce MessageOutbox provider to drive delivery, backoff and connectivity handling; wire it into chat send flows so sends are persisted as 'sending' rows and retried from SQLite. Update chat providers and send logic to reuse pending rows on retry, merge pending outbox rows into server snapshots, and flip rows to 'sent' or schedule retries on errors. Adjust UI: delivery badge now reads from DB status and triggers markSending + outbox.kick on manual retry; chat page and user message bubble now dispatch directly (no Hive enqueue). Add OWUI chats import in profile and small chats drawer date-grouping. Update tests accordingly and remove an obsolete outbound_task_test.
Allow local-first new chats and reconcile them with server-assigned ids after createConversation completes. Added ConversationStore.renameConversation to atomically remap a conversation primary key and its child messages in a single SQLite transaction. Persist local placeholders to SQLite up-front so streaming writes have a stable parent, and run /api/v1/chats/new concurrently instead of gating the stream.

Also added a best-effort _reconcileNewChatId flow that waits for the server conversation, renames SQLite rows, swaps the active conversation provider if still active, upserts the sidebar entry, refreshes caches, and pushes the final message list to the server via syncConversationMessages. Errors are swallowed as this is a best-effort cleanup.

Introduced a cachedUserSystemPromptUpdatedAt Hive key plus storage helpers: setCachedUserSystemPrompt now writes/deletes an updated-at timestamp and getCachedUserSystemPromptUpdatedAt() returns a DateTime. Chat send logic now uses the cached timestamp (5-minute freshness) to skip or background-refresh /user/settings to avoid extra per-send HTTP work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants