BitChat ConversationStore Design Philosophy: A Single Source of Truth for Decentralized Chat
BitChat's ConversationStore acts as the single source of truth (SSOT) for all conversation state, enforcing single-writer semantics, fine-grained publishing, and derived read-only views to eliminate data races and synchronization bugs.
The ConversationStore in permissionlesstech/bitchat solves a critical problem in peer-to-peer messaging: fragmented state management across multiple view models and caches. By centralizing all conversation mutations through one intent API, BitChat achieves predictable, high-performance updates without the quadratic costs of legacy multi-store designs.
Core Design Principles of the ConversationStore
BitChat's ConversationStore architecture rests on seven deliberate principles that together create a robust, observable conversation layer.
Single Writer, Single Holder
All mutations flow through a single intent API exposed by ConversationStore. The store owns the mutable collections, while external components receive only read-only or derived views. This eliminates many-to-many mutation pathways that plagued legacy stores and bridges.
In ConversationStore.swift (lines 33-41), the store guards its internal state:
// Intent API — the only way to modify conversation state
ConversationStore.shared.append(message, to: .mesh)
ConversationStore.shared.upsertByID(message, in: .directPeer(peerID))
ConversationStore.shared.setDeliveryStatus(.delivered, forMessageID: "msg-1234")
External code cannot directly modify Conversation.messages. All changes route through these explicit intent methods.
Fine-Grained Publishing with ObservableObject
Each Conversation is an ObservableObject representing one timeline: .mesh, .geohash, or .direct. Views subscribe to one conversation, ensuring that appends to chat A never invalidate observers of chat B.
From ConversationStore.swift (lines 25-30):
// SwiftUI view observes a specific conversation
struct ChatView: View {
@ObservedObject var conversation: Conversation
var body: some View {
List(conversation.messages) { msg in
Text(msg.body)
}
}
}
// Retrieve lazily — creates conversation if needed
let conv = ConversationStore.shared.conversation(for: .directPeer(peerID))
This granularity prevents cascading view updates across unrelated conversations.
Incremental, Index-Based Mutations
Each conversation maintains a message-ID → logical-index map (indexByMessageID) enabling O(1) operations. The store supports:
- Deduplication — fast checks against existing IDs
- Delivery-status updates — in-place mutation without list rebuilds
- Ordered insertions — by timestamp with automatic trimming of oldest entries
From ConversationStore.swift (lines 42-48, 95-100), insertions preserve ordering and enforce storage caps efficiently.
Unified Message-ID Membership Map
The global map conversationIDsByMessageID routes delivery receipts to every conversation holding a message. This handles edge cases where private messages appear in two direct conversations (sender and recipient views).
From ConversationStore.swift (lines 53-64), this map enables:
// Receipt contains only message ID — store finds all relevant conversations
ConversationStore.shared.setDeliveryStatus(.delivered, forMessageID: "msg-1234")
Derived, Read-Only Views for Feature Models
Legacy collections like ChatViewModel.messages or PrivateInboxModel.messagesByPeerID became thin, computed wrappers fetching directly from the store. This removes duplicated state and guarantees readers always see consistent data.
From ConversationStore.swift (lines 31-38), view models no longer own copies—they derive:
// Old: ChatViewModel owned and mutated its own messages array
// New: ChatViewModel.messages is a computed property reading from ConversationStore
var messages: [BitchatMessage] {
ConversationStore.shared.conversation(for: .mesh).messages
}
Explicit Selection State
UI selection axes—active public channel and selected private peer—live inside the store. A derived selectedConversationID always points to an existing conversation, eliminating stale selection bugs.
From ConversationStore.swift (lines 41-49), selection changes trigger conversation switching without view model coordination.
Robust Invariant Auditing
The auditInvariants() method validates ordering, cap limits, index consistency, and map integrity. This debugging tool ensures the SSOT remains healthy under concurrent load.
From ConversationStore.swift (lines 94-108), audits check:
- Timestamp ordering
- Storage cap compliance
indexByMessageIDconsistencyconversationIDsByMessageIDcorrectness
Practical Usage Patterns
Appending Messages
let message = BitchatMessage(
id: "msg-1234",
timestamp: Date(),
body: "Hello mesh network"
)
ConversationStore.shared.append(message, to: .mesh)
Upsert for Media Progress
Media uploads use upsert to replace pending messages with completed ones:
ConversationStore.shared.upsertByID(completedMessage, in: .directPeer(somePeerID))
Observing in Views
struct ChatView: View {
@ObservedObject var conversation: Conversation
var body: some View {
List(conversation.messages) { msg in
MessageRow(message: msg)
}
}
}
Key Implementation Files
| File | Purpose |
|---|---|
ConversationStore.swift |
Core SSOT implementation—intent API, selection state, global ID map, invariant auditing |
CONVERSATION-STORE-DESIGN.md |
Design document covering problem analysis, solution rationale, and migration strategy |
ConversationStoreTests.swift |
Invariant validation and intent semantics test suite |
ChatViewModel.swift |
Reference implementation showing derived view patterns |
ChatDeliveryCoordinator.swift |
Delivery-status routing through store API |
Summary
- Single source of truth —
ConversationStoreowns all mutable conversation state, eliminating scattered mutations - Intent-based API — All changes flow through explicit methods like
append(_:to:),upsertByID(_:in:),setDeliveryStatus(_:forMessageID:) - Fine-grained observability — Each
Conversationpublishes independently; views subscribe to exactly what they display - O(1) mutations —
indexByMessageIDenables fast deduplication and updates without full list rebuilds - Derived views — Legacy view models read from the store instead of duplicating state
- Invariant auditing —
auditInvariants()catches consistency violations during development
Frequently Asked Questions
What problem does the single source of truth pattern solve in BitChat?
Before ConversationStore, conversation state was duplicated across ChatViewModel, PrivateInboxModel, and multiple caches. Updates required coordination across these copies, causing race conditions and stale data. The SSOT pattern consolidates mutations to one place, guaranteeing that all readers see identical, up-to-date state.
How does ConversationStore handle delivery status updates efficiently?
The store maintains conversationIDsByMessageID, a global map from message ID to all conversations containing that message. When a delivery receipt arrives with only a message ID, the store immediately locates every relevant conversation and applies the status update without scanning all messages. This design supports O(1) routing even when the same message appears in multiple conversations.
Why does each Conversation use ObservableObject instead of the entire store?
Granular observability prevents unnecessary view updates. If the entire ConversationStore were observable, any message in any conversation would invalidate all chat views. By making each Conversation an ObservableObject, SwiftUI re-renders only views subscribed to the specific conversation that changed—critical for performance with many active chats.
What happens when media uploads need to update existing messages?
The upsertByID(_:in:) intent handles replace-or-append semantics. When a pending media message receives upload progress or completion data, calling upsertByID replaces the existing entry in-place using indexByMessageID for O(1) lookup. This preserves message ordering while updating content, avoiding the need to rebuild the conversation's message array.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →