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>
10 KiB
Conversation Store: Single Source of Truth
Status: migration complete (steps 1–5). 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
IdentityResolvercanonicalization layer. Direct conversations stay keyed by raw routingPeerID(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 fedisEmpty-style badge checks (identity-aware unread resolution lives inChatUnreadStateResolver, which works on raw keys).IdentityResolverwas deleted with the legacy store;PeerHandleslimmed toid+routingPeerID. - Selection state lives in the store. The legacy store also carried the
two UI selection axes (
activeChannel,selectedPrivatePeerID); they moved intoConversationStore(setActiveChannel/setSelectedPrivatePeer, derivingselectedConversationID), andmigrateConversationhands the private-peer selection off with the conversation. ChatViewModel.messages/privateChats/unreadPrivateMessagessurvive 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-directprivateMessages(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:
ChatPrivateConversationCoordinator.handlePrivateMessagewrites throughChatViewModel.privateChats— a passthrough computed property (ChatViewModel.swift:167-173) intoPrivateChatManager's@Published var privateChats(PrivateChatManager.swift:16).- Bridge 1: the bootstrapper subscribes
privateChatManager.$privateChatsand$unreadMessageswith.receive(on: DispatchQueue.main)sinks (ChatViewModelBootstrapper.swift:92-108). - Bridge 2: each sink calls
schedulePrivateConversationStoreSynchronization, aTask.yield-debounced task (ChatViewModel.swift:1084-1092) that eventually runssynchronizePrivateConversationStore(ChatViewModel.swift:1095-1101). - 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,normalizedatAppArchitecture.swift:359-372). - Bridge 3:
PrivateInboxModelsubscribesconversationStore.$messagesByConversation(again.receive(on: DispatchQueue.main)) and rebuilds its entiremessagesByPeerIDdictionary viarefreshMessages(PrivateConversationModels.swift:43-48,54-68). - 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:
isDuplicateMessagelinearly scans all private chats per inbound message (ChatPrivateConversationCoordinator.swift:622-630) andsanitizeChatre-sorts the whole chat per append (PrivateChatManager.swift:213-234). - Delivery status mutates two copies.
ChatDeliveryCoordinatorpatches bothcontext.messagesand a value-copiedcontext.privateChats(ChatDeliveryCoordinator.swift:105-139), navigating with a positionalmessageLocationIndex(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
@Publishedwrite and the debounced sync,privateChatManager.privateChatsandConversationStore.messagesByConversationdisagree; 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.
Conversationis a reference-typeObservableObject, one instance perConversationID(.mesh/.geohash/.direct), with@Published private(set)messagesand 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 andPrivateChatManager's cap disappear.- Publishing granularity is per conversation. Views observe ONE
Conversationobject. An append to chat A never invalidates observers of chat B — unlike today, where any write republishes the entiremessagesByConversationdictionary (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, theprivate(set)+ dedicated-mutator pattern):append(_:to:)— incremental, dedup via the ID indexupsertByID(_:in:)— replace-or-append (media progress, edits)setDeliveryStatus(_:for:in:)— keyed by message ID, no positional indexmarkRead(_:)/markUnread(_:)migrateConversation(from:to:)— the ephemeral↔stable peer-ID handoff that today is hand-rolled dictionary surgery in three placesclear(_:)Backing collections areprivate(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
appendreturns, every observer of thatConversationsees the message.
3. Deleted at end state (done)
PublicTimelineStore(bitchat/ViewModels/PublicTimelineStore.swift) — folded intoConversationcap/dedup policy.PrivateChatManager's message dict and trim/sanitize logic — the manager shrinks to read-receipt policy (markAsRead,syncReadReceiptsForSentMessages).ChatDeliveryCoordinator.messageLocationIndexand its growth/rebuild machinery (ChatDeliveryCoordinator.swift:40-45,221-320) — replaced bysetDeliveryStatus(for:in:)against the per-conversation ID index.- Both bootstrapper sync bridges (
ChatViewModelBootstrapper.swift:92-108). schedulePrivateConversationStoreSynchronization/synchronizePrivateConversationStoreand the public equivalents (ChatViewModel.swift:1084-1101,ChatPublicConversationCoordinator.swift:351-386).- Feature-model mirror collections:
PrivateInboxModel.messagesByPeerID,PublicChatModel.messages(they observeConversationobjects directly). ChatViewModel.messages/ChatViewModel.privateChatsas 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).
- Additive store. Introduce
Conversationobjects and the intent API insideConversationStorealongside the existing replace-based API. Nothing reads them yet. - Private cutover with compat shims. Inbound/outbound private paths write through
the intent API.
ChatViewModel.privateChatsbecomes 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. - Public cutover.
handlePublicMessageand thePublicMessagePipelineflush write to the store;PublicTimelineStorefolds in;ChatViewModel.messagesbecomes a derived view of the active conversation. - Delivery via store.
ChatDeliveryCoordinatorswitches tosetDeliveryStatus(for:in:);messageLocationIndexis deleted. - View cutover. Views and feature models observe
Conversationobjects 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.
sentReadReceiptsUserDefaults 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.