Mesh and geohash timelines are now store conversations. All public mutation sites flow through store intents; PublicMessagePipeline keeps its 80ms UI batching but commits batches via store appends with each buffered entry carrying its destination conversation (a mid-batch channel switch now flushes instead of dropping the buffer). ChatViewModel.messages becomes a cached get-only view of the active conversation, invalidated through the change subject. The mesh late-insert threshold is consciously removed: it only ever ordered the non-rendered messages copy, so strict timestamp insertion makes the working set agree with rendered order. PublicTimelineStore and the per-message full-array legacy sync are deleted; the coalescing bridge mirrors public conversations for the remaining legacy readers. pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady; store.append 237k/s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.3 KiB
Conversation Store: Single Source of Truth
Status: Steps 1–3 implemented (additive store, private cutover, public
cutover; PublicTimelineStore deleted). Baselines recorded in
bitchatTests/Performance/PerformanceBaselineTests.swift (pipeline.privateIngest,
pipeline.publicIngest, store.append).
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
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
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.