BitChat ConversationStore and AppRuntime Architecture: A Deep Dive into Message State Management
BitChat centralizes all message state in a single ConversationStore singleton owned by the @MainActor AppRuntime, providing O(1) incremental updates via a thin intent API while publishing changes through ObservableObject conversations.
The permissionlesstech/bitchat repository implements a centralized message state architecture that eliminates legacy synchronization complexity. Understanding how the ConversationStore and AppRuntime components interact is essential for developers building on or contributing to this peer-to-peer messaging platform.
ConversationStore: The Single Source of Truth
The ConversationStore holds every message, unread count, and delivery status for every conversation type, including public, mesh, geohash-based, and direct chats. It acts as the sole writer to message state, ensuring consistent reads and incremental updates without full-dictionary rebuilds.
Singleton Design and Main Actor Concurrency
The store is an @MainActor singleton instantiated by AppRuntime during application launch (see bitchat/App/AppRuntime.swift). This design guarantees that all mutations occur on the main thread, eliminating race conditions in the UI layer. Because the store lives on the main actor, a call to any mutation returns only after the new state is observable, ensuring consistent reads.
The Conversation Model and ObservableObjects
Each conversation is modeled by a reference-type ObservableObject called Conversation. A Conversation owns an incremental message-ID index and maintains a capped message array constrained by TransportConfig caps to prevent unbounded memory growth. These objects publish their own messages and unread state via @Published properties, allowing UI components to subscribe to granular updates rather than observing the entire messagesByConversation dictionary.
Intent API for Single-Writer Semantics
The store exposes a thin intent API that enforces single-writer semantics:
append(_:to:)– Adds a new message to a specific conversationupsertByID(_:in:)– Updates or inserts a message by IDsetDeliveryStatus(_:for:in:)– Updates delivery trackingmarkRead(_:)andmarkUnread(_:)– Manages read receiptsmigrateConversation(from:to:)– Handles conversation transitionsclear(_:)– Removes conversation data
All internal collections are marked private(set), ensuring external code can read but never mutate directly. This guarantees that every state change flows through the intent surface, making side effects predictable and debuggable.
Publishing Strategy for UI and Background Consumers
The store employs a dual-publishing strategy to serve both UI and non-UI consumers. Each Conversation publishes changes via its own @Published properties, while the store exposes a global changes: PassthroughSubject<ConversationChange, Never> stream. Background services such as delivery tracking and background sync subscribe to this subject to receive fine-grained events like "message appended in conversation X" or "status changed," without triggering unnecessary UI refreshes.
AppRuntime: The Lifecycle Orchestrator
The AppRuntime is the top-level component that owns the ConversationStore and lives on the main actor. It serves as the entry point for the app’s lifecycle, bootstrapping the UI, initializing services (transport, verification, peer management), and wiring the store to the rest of the system.
Store Ownership and Initialization
On launch, AppRuntime creates the ConversationStore instance. Coordinators and view-models (such as ChatViewModel and PrivateInboxModel) obtain a reference to this store and either issue intents for writes or subscribe to its Conversation objects for reads. This ownership model eliminates the need for dependency injection containers or global variable abuse, as the runtime provides the authoritative store reference.
Decoupling from Legacy Synchronization
Because the ConversationStore is the sole writer, AppRuntime no longer requires the bridge-based synchronization loops that previously performed full-dictionary replacements. The architecture has removed legacy stores including PublicTimelineStore, PrivateChatManager, and LegacyConversationStore, with feature models now deriving their view data directly from the unified store. This consolidation reduces state duplication and eliminates an entire class of synchronization bugs.
High-Level Message Flow
The interaction between components follows a strict unidirectional flow:
- Incoming message triggers
ChatViewModel(or an appropriate coordinator) to callstore.append(message, to: conversationID). - ConversationStore inserts the message incrementally, updates the per-conversation ID index, and publishes the change through both the specific
Conversationobject and the globalchangesstream. - UI components observing that
Conversationreceive the update instantly, while background services listening tostore.changesreact for delivery tracking or notification dispatch.
Implementation Examples
Appending a message to a direct conversation:
let store = ConversationStore.shared // owned by AppRuntime
let newMessage = ChatMessage(...)
store.append(newMessage, to: .directPeer(peerID))
Observing a specific conversation in a SwiftUI view:
@ObservedObject var convo: Conversation // injected from the store
Text(convo.messages.last?.text ?? "")
Marking a message as read:
store.markRead(messageID)
Key Implementation Files
| Component | File | Description |
|---|---|---|
| Conversation Store | bitchat/App/ConversationStore.swift |
Core implementation with intent API and singleton |
| Conversation Model | bitchat/App/Conversation.swift |
ObservableObject representing individual chats |
| Store Tests | bitchatTests/ConversationStoreTests.swift |
Verification of intent API and state invariants |
| Design Document | docs/CONVERSATION-STORE-DESIGN.md |
Architectural rationale and decisions |
| Runtime Entry | bitchat/App/AppRuntime.swift |
Main actor orchestrator that creates the store |
| Architecture Overview | docs/ARCHITECTURE_V2.md |
High-level system architecture documentation |
Summary
- ConversationStore is an
@MainActorsingleton that acts as the single source of truth for all message state, owned by the AppRuntime. - The store uses single-writer semantics via a thin intent API (
append,upsertByID,setDeliveryStatus, etc.) to guarantee consistent, incremental updates. - Each Conversation is an ObservableObject with
@Publishedproperties, enabling O(1) per-conversation updates without full-dictionary rebuilds. - AppRuntime initializes the store and eliminates legacy synchronization by removing
PublicTimelineStore,PrivateChatManager, and other deprecated components. - The architecture supports both UI observation (via
Conversationobjects) and background processing (viaPassthroughSubject<ConversationChange, Never>).
Frequently Asked Questions
What is the role of AppRuntime in BitChat?
AppRuntime is the top-level @MainActor component that initializes and owns the ConversationStore. It bootstraps the application lifecycle, initializes transport and verification services, and provides the central store reference to coordinators and view-models, eliminating the need for complex dependency injection or global state managers.
How does ConversationStore handle concurrent updates?
The store uses Swift's @MainActor annotation to ensure all mutations occur on the main thread. Because it operates as a single-writer with private(set) internal collections, external code cannot mutate state directly. All changes flow through the intent API, ensuring that reads and writes are atomic and that external observers receive updates only after the mutation completes.
Why does BitChat use an intent API pattern?
The intent API (methods like append(_:to:) and markRead(_:)) encapsulates state transitions, allowing the store to enforce invariants such as ID index maintenance, message capping based on TransportConfig, and unread-count calculations. This pattern prevents scattered mutability, makes state changes auditable, and enables O(1) incremental updates instead of expensive full-collection rebuilds.
Where are conversation messages actually stored?
Messages reside in the Conversation objects held by ConversationStore. Each Conversation maintains an in-memory array of messages (capped based on TransportConfig settings) and exposes them via @Published properties for SwiftUI observation. The store itself manages these objects in a dictionary keyed by conversation ID, providing the single point of access for the entire application.
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 →