Cut private message path over to ConversationStore

All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.

ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).

pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 12:19:11 +02:00
co-authored by Claude Fable 5
parent ac3a2f2d34
commit 879d8cba12
33 changed files with 1293 additions and 545 deletions
+26
View File
@@ -356,6 +356,32 @@ final class LegacyConversationStore: ObservableObject {
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
}
// MARK: Migration step 2 bridge entry points (DELETE IN STEP 5)
// Used only by `LegacyConversationStoreBridge` to mirror single
// conversations out of the new `ConversationStore` without the
// full-dictionary `synchronizePrivateChats` pass.
func replaceDirectMessages(
_ messages: [BitchatMessage],
for peerID: PeerID,
identityResolver: IdentityResolver
) {
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
let conversationID = ConversationID.direct(handle)
directHandlesByConversation[conversationID] = handle
replaceMessages(messages, for: conversationID)
}
func markUnread(
peerID: PeerID,
identityResolver: IdentityResolver
) {
let conversationID = directConversationID(for: peerID, identityResolver: identityResolver)
if !unreadConversations.contains(conversationID) {
unreadConversations.insert(conversationID)
}
}
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
var uniqueMessages: [String: BitchatMessage] = [:]
+8
View File
@@ -15,6 +15,11 @@ final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: LegacyConversationStore
/// Single source of truth for conversation message state
/// (docs/CONVERSATION-STORE-DESIGN.md). The legacy store above keeps
/// feeding the feature models until step 5, mirrored from this one by
/// `LegacyConversationStoreBridge`.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
@@ -44,10 +49,12 @@ final class AppRuntime: ObservableObject {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = LegacyConversationStore()
let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
@@ -55,6 +62,7 @@ final class AppRuntime: ObservableObject {
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
conversations: conversations,
identityResolver: identityResolver,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
+93
View File
@@ -128,6 +128,16 @@ final class Conversation: ObservableObject, Identifiable {
return true
}
/// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID)
reindex(from: index)
return removed
}
fileprivate func clearMessages() {
messages.removeAll()
indexByMessageID.removeAll()
@@ -191,6 +201,7 @@ enum ConversationChange {
case appended(ConversationID, BitchatMessage)
case updated(ConversationID, messageID: String)
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
case messageRemoved(ConversationID, messageID: String)
case cleared(ConversationID)
case removed(ConversationID)
case migrated(from: ConversationID, to: ConversationID)
@@ -322,6 +333,19 @@ final class ConversationStore: ObservableObject {
changes.send(.migrated(from: source, to: destination))
}
/// Removes a single message by ID from a conversation. Returns the
/// removed message, or `nil` (emitting nothing) when the conversation or
/// message is unknown.
@discardableResult
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
guard let conversation = conversationsByID[id],
let removed = conversation.remove(messageID: messageID) else {
return nil
}
changes.send(.messageRemoved(id, messageID: messageID))
return removed
}
/// Empties a conversation's timeline but keeps the conversation (and
/// its unread/selection state) alive.
func clear(_ id: ConversationID) {
@@ -371,3 +395,72 @@ final class ConversationStore: ObservableObject {
}
}
}
// MARK: - Migration step 2 compatibility (raw per-peer keying + derived views)
extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID.
///
/// Migration step 2 keeps one conversation per `PeerID` exactly the
/// buckets the legacy `privateChats` dictionary had so the
/// ephemeral/stable mirroring and consolidation coordinators keep their
/// current semantics. Step 5 canonicalizes direct conversations through
/// `IdentityResolver` and this helper goes away.
static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle(
id: "peer:\(peerID.id)",
routingPeerID: peerID,
displayName: nil,
noisePublicKeyHex: nil,
nostrPublicKey: nil
))
}
}
extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the
/// compat shape of the legacy `privateChats` dictionary. Values are the
/// conversations' backing arrays (COW), so building this is
/// O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count)
for (id, conversation) in conversationsByID {
guard case .direct(let handle) = id else { continue }
messagesByPeerID[handle.routingPeerID] = conversation.messages
}
return messagesByPeerID
}
/// Unread direct conversations as routing peer IDs the compat shape of
/// the legacy `unreadPrivateMessages` set.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>()
for id in unreadConversations {
guard case .direct(let handle) = id else { continue }
peerIDs.insert(handle.routingPeerID)
}
return peerIDs
}
/// `true` when any direct conversation contains a message with `messageID`
/// (O(1) per conversation via the incremental ID index).
func directConversationsContainMessage(withID messageID: String) -> Bool {
for (id, conversation) in conversationsByID {
guard case .direct = id else { continue }
if conversation.containsMessage(withID: messageID) { return true }
}
return false
}
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
}
@@ -0,0 +1,146 @@
//
// LegacyConversationStoreBridge.swift
// bitchat
//
// Migration step 2 adapter (DELETE IN STEP 5, see
// docs/CONVERSATION-STORE-DESIGN.md §4).
//
// The new `ConversationStore` is the single writer for private (direct)
// message state; the feature models (`PrivateInboxModel`,
// `PrivateConversationModel`, `ConversationUIModel`, `PeerListModel`) still
// read the replace-based `LegacyConversationStore` until step 5. This bridge
// keeps Legacy fed from the new store's `changes` subject: per-message
// changes mark the affected conversation dirty and a `Task.yield`-coalesced
// flush mirrors only the dirty conversations a burst of N appends costs
// ONE Legacy replace (like the old debounced sync) without the full-dict
// pass. Structural changes (migration/removal) resynchronize immediately.
// Legacy is therefore eventually consistent within one run-loop tick the
// same visibility the old `$privateChats` sink provided while the new
// store stays synchronously authoritative.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Combine
import Foundation
@MainActor
final class LegacyConversationStoreBridge {
private let store: ConversationStore
private let legacyStore: LegacyConversationStore
private let identityResolver: IdentityResolver
private var cancellable: AnyCancellable?
private var dirtyConversations: Set<ConversationID> = []
private var pendingFlushTask: Task<Void, Never>?
init(
store: ConversationStore,
legacyStore: LegacyConversationStore,
identityResolver: IdentityResolver
) {
self.store = store
self.legacyStore = legacyStore
self.identityResolver = identityResolver
cancellable = store.changes.sink { [weak self] change in
self?.apply(change)
}
}
/// Full resynchronization of every direct conversation into Legacy.
///
/// Needed when `IdentityResolver` learns new peer associations (the
/// canonical handle for an existing conversation can change, re-keying
/// it in Legacy) and after structural store changes. This is the old
/// `synchronizePrivateChats` full pass acceptable because it only runs
/// on peer-list changes and rare migrations, never per message.
func resynchronizeAll() {
// The full pass covers every conversation; pending per-conversation
// work is redundant.
dirtyConversations.removeAll()
legacyStore.synchronizePrivateChats(
store.directMessagesByRoutingPeerID(),
unreadPeerIDs: store.unreadDirectRoutingPeerIDs(),
identityResolver: identityResolver
)
}
}
private extension LegacyConversationStoreBridge {
func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
markDirty(id)
case .unreadChanged(let id, let isUnread):
guard case .direct(let handle) = id else { return }
if isUnread {
legacyStore.markUnread(peerID: handle.routingPeerID, identityResolver: identityResolver)
} else {
legacyStore.markRead(peerID: handle.routingPeerID, identityResolver: identityResolver)
}
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
resynchronizeAll()
case .removed(let id):
guard isDirect(id) else { return }
resynchronizeAll()
}
}
func markDirty(_ id: ConversationID) {
guard isDirect(id) else { return }
dirtyConversations.insert(id)
scheduleFlush()
}
/// One pending flush at a time, exactly like the old
/// `schedulePrivateConversationStoreSynchronization` debounce: a
/// synchronous burst of mutations coalesces into a single flush on the
/// next main-actor turn.
func scheduleFlush() {
guard pendingFlushTask == nil else { return }
pendingFlushTask = Task { @MainActor [weak self] in
await Task.yield()
guard let self else { return }
self.pendingFlushTask = nil
self.flushDirtyConversations()
}
}
func flushDirtyConversations() {
guard !dirtyConversations.isEmpty else { return }
let dirty = dirtyConversations
dirtyConversations.removeAll()
for id in dirty {
mirrorConversation(id)
}
}
func mirrorConversation(_ id: ConversationID) {
guard case .direct(let handle) = id,
let conversation = store.conversationsByID[id] else {
// Removed while dirty; the removal already resynchronized.
return
}
legacyStore.replaceDirectMessages(
conversation.messages,
for: handle.routingPeerID,
identityResolver: identityResolver
)
}
func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}