Files
bitchat/docs/CONVERSATION-STORE-DESIGN.md
jackandClaude Fable 5 38331e62f1 Cut views over to ConversationStore; delete legacy store, bridge, resolver
Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.

Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest  6.8k -> 13.7k msg/s (2.0x)
delivery updates       38k  -> 117-133k/s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:57:12 +02:00

170 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Conversation Store: Single Source of Truth
**Status: migration complete (steps 15).** `ConversationStore` is the sole
holder of message state; the feature models (`PublicChatModel`,
`PrivateInboxModel`, `PrivateConversationModel`, `PeerListModel`,
`ConversationUIModel`) observe it directly — `PublicChatModel` observes the
active `Conversation` object, so background appends never invalidate it.
`LegacyConversationStore`, `LegacyConversationStoreBridge`, and
`PublicTimelineStore` are deleted. Baselines recorded in
`bitchatTests/Performance/PerformanceBaselineTests.swift`
(`pipeline.privateIngest`, `pipeline.publicIngest`, `store.append`,
`delivery.incrementalUpdate`, `delivery.storeUpdate`).
Deviations from the plan below, chosen at cutover:
- **No `IdentityResolver` canonicalization layer.** Direct conversations stay
keyed by raw routing `PeerID` (`ConversationID.directPeer`). The
coordinators' ephemeral↔stable mirroring/consolidation guarantees the
selected peer's key always holds the full timeline, and no view enumerates
direct conversations as a list — the legacy resolver-keyed dedup only ever
fed `isEmpty`-style badge checks (identity-aware unread resolution lives in
`ChatUnreadStateResolver`, which works on raw keys). `IdentityResolver` was
deleted with the legacy store; `PeerHandle` slimmed to `id` +
`routingPeerID`.
- **Selection state lives in the store.** The legacy store also carried the
two UI selection axes (`activeChannel`, `selectedPrivatePeerID`); they
moved into `ConversationStore` (`setActiveChannel` /
`setSelectedPrivatePeer`, deriving `selectedConversationID`), and
`migrateConversation` hands the private-peer selection off with the
conversation.
- **`ChatViewModel.messages` / `privateChats` / `unreadPrivateMessages`
survive as derived read-only views** (not stored properties): coordinators,
commands, and tests read them; the dict shape is only rebuilt where a
coordinator genuinely needs the whole dictionary (migration scans, unread
resolution). Simple per-peer reads dispatch through the store-direct
`privateMessages(for:)` context witness instead.
---
## 1. Problem
Message state is replicated across four stores, kept eventually consistent by three
async bridges. One inbound private message today:
1. `ChatPrivateConversationCoordinator.handlePrivateMessage` writes through
`ChatViewModel.privateChats` — a passthrough computed property
(`ChatViewModel.swift:167-173`) into `PrivateChatManager`'s
`@Published var privateChats` (`PrivateChatManager.swift:16`).
2. **Bridge 1:** the bootstrapper subscribes `privateChatManager.$privateChats` and
`$unreadMessages` with `.receive(on: DispatchQueue.main)` sinks
(`ChatViewModelBootstrapper.swift:92-108`).
3. **Bridge 2:** each sink calls `schedulePrivateConversationStoreSynchronization`,
a `Task.yield`-debounced task (`ChatViewModel.swift:1084-1092`) that eventually runs
`synchronizePrivateConversationStore` (`ChatViewModel.swift:1095-1101`).
4. That calls `ConversationStore.synchronizePrivateChats` — a **full-dict replace**: every
conversation is re-normalized (dedup + `O(n log n)` sort) and diff-compared on every
sync (`AppArchitecture.swift:304-346`, `normalized` at `AppArchitecture.swift:359-372`).
5. **Bridge 3:** `PrivateInboxModel` subscribes `conversationStore.$messagesByConversation`
(again `.receive(on: DispatchQueue.main)`) and rebuilds its entire
`messagesByPeerID` dictionary via `refreshMessages`
(`PrivateConversationModels.swift:43-48`, `54-68`).
6. SwiftUI finally observes the feature model.
Costs and hazards:
- **O(total messages) × 3 layers per single message.** One append re-sorts, re-compares,
and re-publishes every conversation through store and feature-model layers. The ingest
path itself is also quadratic: `isDuplicateMessage` linearly scans *all* private chats
per inbound message (`ChatPrivateConversationCoordinator.swift:622-630`) and
`sanitizeChat` re-sorts the whole chat per append (`PrivateChatManager.swift:213-234`).
- **Delivery status mutates two copies.** `ChatDeliveryCoordinator` patches both
`context.messages` and a value-copied `context.privateChats`
(`ChatDeliveryCoordinator.swift:105-139`), navigating with a positional
`messageLocationIndex` (`ChatDeliveryCoordinator.swift:40`) that any non-append
mutation invalidates, forcing a full rebuild over every message location
(`ChatDeliveryCoordinator.swift:298-320`).
- **Transient disagreement.** Between the `@Published` write and the debounced sync,
`privateChatManager.privateChats` and `ConversationStore.messagesByConversation`
disagree; anything reading the store mid-flight sees stale data.
The public path has the same shape: `@Published var messages`
(`ChatViewModel.swift:122`) is the render copy, `PublicTimelineStore`
(`ChatViewModel.swift:342-345`) is the backing copy, and `handlePublicMessage` appends to
the timeline, then full-replaces the conversation store **per message**
(`ChatPublicConversationCoordinator.swift:504-545` calling
`synchronizePublicConversationStore` at `:358-364`, which funnels into
`ConversationStore.replaceMessages`'s whole-array compare at `AppArchitecture.swift:249-253`),
while a timer-batched `PublicMessagePipeline` mutates `messages` ~80 ms later
(`PublicMessagePipeline.swift`, `TransportConfig.basePublicFlushInterval`).
`PublicChatModel` then mirrors the store again (`PublicChatModel.swift`).
## 2. Design
`ConversationStore` (already `@MainActor` and owned by `AppRuntime`,
`AppRuntime.swift:46`) becomes the **sole writer and sole holder** of message state.
- **`Conversation` is a reference-type `ObservableObject`**, one instance per
`ConversationID` (`.mesh` / `.geohash` / `.direct`), with `@Published private(set)`
`messages` and unread state. Each conversation maintains its message-ID index
**incrementally** (insert on append, never rebuilt from scratch) and owns its cap
policy: `TransportConfig.meshTimelineCap` / `geoTimelineCap` / `privateChatCap`
(`TransportConfig.swift:17-19`) fold into the store; `PublicTimelineStore`'s trim logic
and `PrivateChatManager`'s cap disappear.
- **Publishing granularity is per conversation.** Views observe ONE `Conversation`
object. An append to chat A never invalidates observers of chat B — unlike today,
where any write republishes the entire `messagesByConversation` dictionary
(`AppArchitecture.swift:205`) and every bound feature model rebuilds.
- **Store-level `changes: PassthroughSubject<ConversationChange, Never>`** for non-UI
consumers (delivery tracking, notifications, gossip/sync) that need "a message was
appended / status changed in conversation X" without subscribing to message arrays.
- **Mutations go through an intent API only**, mirroring the codebase's existing
single-writer intent ops (`ChatViewModel.swift:421-424`, the `private(set)` +
dedicated-mutator pattern):
- `append(_:to:)` — incremental, dedup via the ID index
- `upsertByID(_:in:)` — replace-or-append (media progress, edits)
- `setDeliveryStatus(_:for:in:)` — keyed by message ID, no positional index
- `markRead(_:)` / `markUnread(_:)`
- `migrateConversation(from:to:)` — the ephemeral↔stable peer-ID handoff that today
is hand-rolled dictionary surgery in three places
- `clear(_:)`
Backing collections are `private(set)`; coordinators receive the intent surface, not
the dictionaries.
- **Reads are synchronous.** Because writers and readers share the main actor and there
is one copy, "await the sync" disappears: after `append` returns, every observer of
that `Conversation` sees the message.
## 3. Deleted at end state (done)
- `PublicTimelineStore` (`bitchat/ViewModels/PublicTimelineStore.swift`) — folded into
`Conversation` cap/dedup policy.
- `PrivateChatManager`'s message dict and trim/sanitize logic — the manager shrinks to
read-receipt policy (`markAsRead`, `syncReadReceiptsForSentMessages`).
- `ChatDeliveryCoordinator.messageLocationIndex` and its growth/rebuild machinery
(`ChatDeliveryCoordinator.swift:40-45`, `221-320`) — replaced by
`setDeliveryStatus(for:in:)` against the per-conversation ID index.
- Both bootstrapper sync bridges (`ChatViewModelBootstrapper.swift:92-108`).
- `schedulePrivateConversationStoreSynchronization` /
`synchronizePrivateConversationStore` and the public equivalents
(`ChatViewModel.swift:1084-1101`, `ChatPublicConversationCoordinator.swift:351-386`).
- Feature-model mirror collections: `PrivateInboxModel.messagesByPeerID`,
`PublicChatModel.messages` (they observe `Conversation` objects directly).
- `ChatViewModel.messages` / `ChatViewModel.privateChats` as stored/owning properties.
## 4. Migration plan (complete)
Each step lands green against the full suite plus the `PerformanceBaselineTests`
numbers (no pipeline throughput regression at any step).
1. **Additive store.** Introduce `Conversation` objects and the intent API inside
`ConversationStore` alongside the existing replace-based API. Nothing reads them yet.
2. **Private cutover with compat shims.** Inbound/outbound private paths write through
the intent API. `ChatViewModel.privateChats` becomes a derived **read-only** view of
the store; `PrivateChatManager`'s dict and the private sync bridges are bypassed but
the property surface stays so coordinators/tests compile unchanged.
3. **Public cutover.** `handlePublicMessage` and the `PublicMessagePipeline` flush write
to the store; `PublicTimelineStore` folds in; `ChatViewModel.messages` becomes a
derived view of the active conversation.
4. **Delivery via store.** `ChatDeliveryCoordinator` switches to
`setDeliveryStatus(for:in:)`; `messageLocationIndex` is deleted.
5. **View cutover.** Views and feature models observe `Conversation` objects directly;
delete all shims, mirrors, and the replace-based store API.
## 5. Non-goals
- **No message persistence.** bitchat is ephemeral by design; the store stays in-memory.
- **`sentReadReceipts` UserDefaults persistence stays put** (`ChatViewModel.swift:394-406`);
it is receipt-protocol state, not conversation state.
- **`MessageRouter`'s outbox remains the SSOT for unsent messages**; the store records
delivery status but never owns retry/resend queues.