From ac3a2f2d3482ec47579af530f882cfbd1a4efe88 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 11:02:27 +0200 Subject: [PATCH] Add single-writer ConversationStore core (additive) Per-conversation ObservableObjects with O(1) dedup via an incrementally maintained message-ID index, binary-search timestamp insertion, folded cap policies, a no-downgrade delivery rule, and a typed change subject. All mutation flows through store intents (conversation mutators are fileprivate). The previous store is renamed LegacyConversationStore pending deletion in step 5. 16 behavioral tests including per- conversation publish isolation; store.append benchmarks at ~144k messages/sec. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppArchitecture.swift | 2 +- bitchat/App/AppRuntime.swift | 4 +- bitchat/App/ConversationStore.swift | 373 ++++++++++++++ bitchat/App/ConversationUIModel.swift | 4 +- bitchat/App/PeerListModel.swift | 4 +- bitchat/App/PrivateConversationModels.swift | 8 +- bitchat/App/PublicChatModel.swift | 4 +- bitchat/ViewModels/ChatViewModel.swift | 10 +- bitchatTests/AppArchitectureTests.swift | 24 +- bitchatTests/ConversationStoreTests.swift | 471 ++++++++++++++++++ .../PerformanceBaselineTests.swift | 52 +- 11 files changed, 919 insertions(+), 37 deletions(-) create mode 100644 bitchat/App/ConversationStore.swift create mode 100644 bitchatTests/ConversationStoreTests.swift diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 829e79ec..6d2c33e0 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -197,7 +197,7 @@ final class IdentityResolver { } @MainActor -final class ConversationStore: ObservableObject { +final class LegacyConversationStore: ObservableObject { @Published private(set) var activeChannel: ChannelID = .mesh @Published private(set) var selectedPrivatePeerID: PeerID? @Published private(set) var selectedConversationID: ConversationID = .mesh diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index f4aba8c8..777e00e6 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -14,7 +14,7 @@ import AppKit final class AppRuntime: ObservableObject { let chatViewModel: ChatViewModel let events = AppEventStream() - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let publicChatModel: PublicChatModel @@ -43,7 +43,7 @@ final class AppRuntime: ObservableObject { ) { self.idBridge = idBridge let identityResolver = IdentityResolver() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let peerIdentityStore = PeerIdentityStore() let locationPresenceStore = LocationPresenceStore() let locationManager = LocationChannelManager.shared diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift new file mode 100644 index 00000000..417574b8 --- /dev/null +++ b/bitchat/App/ConversationStore.swift @@ -0,0 +1,373 @@ +// +// ConversationStore.swift +// bitchat +// +// Single source of truth for conversation message state (see +// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per +// `ConversationID`; all mutations flow through the store's intent API and +// every mutation emits a `ConversationChange` after state is consistent. +// +// During migration the previous replace-based store lives on as +// `LegacyConversationStore` (AppArchitecture.swift) and is deleted in the +// final migration step. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Combine +import Foundation + +// MARK: - Conversation + +/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`). +/// +/// Publishing granularity is per conversation: views observe ONE +/// `Conversation` object, so an append to chat A never invalidates observers +/// of chat B. +/// +/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent +/// API may mutate a conversation, keeping the store the sole writer. +@MainActor +final class Conversation: ObservableObject, Identifiable { + let id: ConversationID + /// Maximum retained messages; oldest are trimmed on overflow. + let cap: Int + + @Published private(set) var messages: [BitchatMessage] = [] + @Published private(set) var isUnread: Bool = false + + /// Incrementally-maintained message-ID → index map for O(1) dedup and + /// delivery-status lookup. Kept in sync on every mutation: + /// - tail append: single insert + /// - out-of-order insert: suffix reindex from the insertion point + /// - trim: full rebuild — `removeFirst(k)` is already O(n), so the + /// rebuild does not change the asymptotics, and trim only happens once + /// the cap (1337) is reached. Simple and correct beats the + /// offset-tracking alternative here. + private var indexByMessageID: [String: Int] = [:] + + fileprivate init(id: ConversationID, cap: Int) { + self.id = id + self.cap = max(1, cap) + } + + // MARK: Reads + + func containsMessage(withID messageID: String) -> Bool { + indexByMessageID[messageID] != nil + } + + func message(withID messageID: String) -> BitchatMessage? { + guard let index = indexByMessageID[messageID] else { return nil } + return messages[index] + } + + // MARK: Store-internal mutations + + fileprivate enum UpsertOutcome { + case appended + case updated + } + + /// Inserts a message in timestamp order, deduplicating by message ID. + /// Fast path appends when the timestamp is >= the current tail; + /// otherwise a binary search finds the upper-bound insertion point so + /// arrival order is preserved among equal timestamps. + /// Returns `false` if a message with the same ID already exists. + fileprivate func insert(_ message: BitchatMessage) -> Bool { + guard indexByMessageID[message.id] == nil else { return false } + + if let last = messages.last, message.timestamp < last.timestamp { + let index = insertionIndex(for: message.timestamp) + messages.insert(message, at: index) + reindex(from: index) + } else { + messages.append(message) + indexByMessageID[message.id] = messages.count - 1 + } + + trimIfNeeded() + return true + } + + /// Replace-or-append by message ID. An existing message keeps its + /// timeline position (in-place updates like media progress reuse the + /// original timestamp); a new message goes through ordered insertion. + fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { + if let index = indexByMessageID[message.id] { + messages[index] = message + return .updated + } + _ = insert(message) + return .appended + } + + /// Applies a delivery status keyed by message ID, honoring the + /// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`): + /// equal statuses are skipped, and `.read` is never downgraded to + /// `.delivered` or `.sent`. + /// Returns `true` when the status was applied. + fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { + guard let index = indexByMessageID[messageID] else { return false } + let message = messages[index] + guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } + + message.deliveryStatus = status + // BitchatMessage is a reference type; write back through the + // subscript so the @Published wrapper emits. + messages[index] = message + return true + } + + @discardableResult + fileprivate func setUnread(_ unread: Bool) -> Bool { + guard isUnread != unread else { return false } + isUnread = unread + return true + } + + fileprivate func clearMessages() { + messages.removeAll() + indexByMessageID.removeAll() + } + + // MARK: Internals + + static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool { + guard let current else { return false } + if current == new { return true } + + switch (current, new) { + case (.read, .delivered), (.read, .sent): + return true + default: + return false + } + } + + /// Upper-bound binary search: first index whose timestamp is strictly + /// greater than `timestamp`, so equal-timestamp messages keep arrival + /// order. + private func insertionIndex(for timestamp: Date) -> Int { + var low = 0 + var high = messages.count + while low < high { + let mid = (low + high) / 2 + if messages[mid].timestamp <= timestamp { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + private func reindex(from start: Int) { + for index in start.. cap else { return } + let overflow = messages.count - cap + for message in messages.prefix(overflow) { + indexByMessageID.removeValue(forKey: message.id) + } + messages.removeFirst(overflow) + reindex(from: 0) + } +} + +// MARK: - ConversationChange + +/// Typed mutation events for non-UI consumers (delivery tracking, +/// notifications, sync) that need "something changed in conversation X" +/// without subscribing to whole message arrays. Emitted on the store's +/// `changes` subject AFTER the corresponding state is consistent. +enum ConversationChange { + case appended(ConversationID, BitchatMessage) + case updated(ConversationID, messageID: String) + case statusChanged(ConversationID, messageID: String, DeliveryStatus) + case cleared(ConversationID) + case removed(ConversationID) + case migrated(from: ConversationID, to: ConversationID) + case unreadChanged(ConversationID, isUnread: Bool) +} + +// MARK: - ConversationStore + +/// Sole writer and sole holder of conversation message state. All mutations +/// go through the intent API below; backing collections are `private(set)`. +/// Reads are synchronous — writers and readers share the main actor, so +/// after an intent returns every observer sees the result. +@MainActor +final class ConversationStore: ObservableObject { + /// Conversation creation order; published so list-style consumers can + /// observe conversations appearing/disappearing without rebuilding from + /// the dictionary. + @Published private(set) var conversationIDs: [ConversationID] = [] + @Published private(set) var selectedConversationID: ConversationID? + @Published private(set) var unreadConversations: Set = [] + + private(set) var conversationsByID: [ConversationID: Conversation] = [:] + + let changes = PassthroughSubject() + + // MARK: Intent API + + /// Returns the conversation for `id`, creating it (with the cap policy + /// for its kind) on first access. + @discardableResult + func conversation(for id: ConversationID) -> Conversation { + if let existing = conversationsByID[id] { + return existing + } + let conversation = Conversation(id: id, cap: Self.cap(for: id)) + conversationsByID[id] = conversation + conversationIDs.append(id) + return conversation + } + + /// Appends a message in timestamp order. Returns `false` (and emits + /// nothing) if a message with the same ID is already present. + @discardableResult + func append(_ message: BitchatMessage, to id: ConversationID) -> Bool { + let conversation = conversation(for: id) + guard conversation.insert(message) else { return false } + changes.send(.appended(id, message)) + return true + } + + /// Replace-or-append by message ID (media progress, edits). + func upsertByID(_ message: BitchatMessage, in id: ConversationID) { + let conversation = conversation(for: id) + switch conversation.upsert(message) { + case .appended: + changes.send(.appended(id, message)) + case .updated: + changes.send(.updated(id, messageID: message.id)) + } + } + + /// Applies a delivery status keyed by message ID. Returns `false` when + /// the message is unknown or the update would downgrade the status + /// (read beats delivered beats sent). + @discardableResult + func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool { + guard let conversation = conversationsByID[id], + conversation.applyDeliveryStatus(status, forMessageID: messageID) else { + return false + } + changes.send(.statusChanged(id, messageID: messageID, status)) + return true + } + + func markRead(_ id: ConversationID) { + guard unreadConversations.contains(id) else { return } + unreadConversations.remove(id) + conversationsByID[id]?.setUnread(false) + changes.send(.unreadChanged(id, isUnread: false)) + } + + func markUnread(_ id: ConversationID) { + guard !unreadConversations.contains(id) else { return } + let conversation = conversation(for: id) + unreadConversations.insert(id) + conversation.setUnread(true) + changes.send(.unreadChanged(id, isUnread: true)) + } + + /// Selects a conversation (creating it if needed) or clears the + /// selection with `nil`. + func select(_ id: ConversationID?) { + if let id { + conversation(for: id) + } + guard selectedConversationID != id else { return } + selectedConversationID = id + } + + /// Moves all messages from `source` into `destination` (the + /// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves + /// timestamp order, carries unread state over, and hands off the + /// selection — mirroring `ChatPrivateConversationCoordinator`'s + /// migration semantics. The source conversation is removed. Emits a + /// single `.migrated(from:to:)` once the whole move is consistent. + func migrateConversation(from source: ConversationID, to destination: ConversationID) { + guard source != destination, let sourceConversation = conversationsByID[source] else { return } + + let destinationConversation = conversation(for: destination) + for message in sourceConversation.messages { + _ = destinationConversation.insert(message) + } + + let wasUnread = unreadConversations.contains(source) + let wasSelected = selectedConversationID == source + + conversationsByID.removeValue(forKey: source) + conversationIDs.removeAll { $0 == source } + unreadConversations.remove(source) + + if wasUnread, !unreadConversations.contains(destination) { + unreadConversations.insert(destination) + destinationConversation.setUnread(true) + } + if wasSelected { + selectedConversationID = destination + } + + changes.send(.migrated(from: source, to: destination)) + } + + /// Empties a conversation's timeline but keeps the conversation (and + /// its unread/selection state) alive. + func clear(_ id: ConversationID) { + guard let conversation = conversationsByID[id] else { return } + conversation.clearMessages() + changes.send(.cleared(id)) + } + + /// Removes a conversation entirely, including unread state; clears the + /// selection if it pointed at the removed conversation. + func removeConversation(_ id: ConversationID) { + guard conversationsByID.removeValue(forKey: id) != nil else { return } + conversationIDs.removeAll { $0 == id } + unreadConversations.remove(id) + if selectedConversationID == id { + selectedConversationID = nil + } + changes.send(.removed(id)) + } + + func clearAll() { + let removedIDs = conversationIDs + guard !removedIDs.isEmpty || selectedConversationID != nil else { return } + + conversationsByID.removeAll() + conversationIDs.removeAll() + unreadConversations.removeAll() + if selectedConversationID != nil { + selectedConversationID = nil + } + + for id in removedIDs { + changes.send(.removed(id)) + } + } + + // MARK: Internals + + private static func cap(for id: ConversationID) -> Int { + switch id { + case .mesh: + return TransportConfig.meshTimelineCap + case .geohash: + return TransportConfig.geoTimelineCap + case .direct: + return TransportConfig.privateChatCap + } + } +} diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 01888d35..120077fb 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -15,14 +15,14 @@ final class ConversationUIModel: ObservableObject { private let chatViewModel: ChatViewModel private let privateConversationModel: PrivateConversationModel - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var activeChannel: ChannelID private var cancellables = Set() init( chatViewModel: ChatViewModel, privateConversationModel: PrivateConversationModel, - conversationStore: ConversationStore + conversationStore: LegacyConversationStore ) { self.chatViewModel = chatViewModel self.privateConversationModel = privateConversationModel diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 91993d47..90a8ea2b 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject { @Published private(set) var renderID = "" private let chatViewModel: ChatViewModel - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private let locationPresenceStore: LocationPresenceStore @@ -45,7 +45,7 @@ final class PeerListModel: ObservableObject { init( chatViewModel: ChatViewModel, - conversationStore: ConversationStore, + conversationStore: LegacyConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 412c1ad2..cf50037f 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -8,10 +8,10 @@ final class PrivateInboxModel: ObservableObject { @Published private(set) var unreadPeerIDs: Set = [] @Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var cancellables = Set() - init(conversationStore: ConversationStore) { + init(conversationStore: LegacyConversationStore) { self.conversationStore = conversationStore bind() @@ -94,14 +94,14 @@ final class PrivateConversationModel: ObservableObject { @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? private let chatViewModel: ChatViewModel - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private let locationChannelsModel: LocationChannelsModel private let peerIdentityStore: PeerIdentityStore private var cancellables = Set() init( chatViewModel: ChatViewModel, - conversationStore: ConversationStore, + conversationStore: LegacyConversationStore, locationChannelsModel: LocationChannelsModel? = nil, peerIdentityStore: PeerIdentityStore? = nil ) { diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift index 0d225bdb..48159b71 100644 --- a/bitchat/App/PublicChatModel.swift +++ b/bitchat/App/PublicChatModel.swift @@ -7,10 +7,10 @@ final class PublicChatModel: ObservableObject { @Published private(set) var activeChannel: ChannelID @Published private(set) var messages: [BitchatMessage] = [] - private let conversationStore: ConversationStore + private let conversationStore: LegacyConversationStore private var cancellables = Set() - init(conversationStore: ConversationStore) { + init(conversationStore: LegacyConversationStore) { self.activeChannel = conversationStore.activeChannel self.conversationStore = conversationStore diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4318ce1e..fbd3daab 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -270,7 +270,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let identityResolver: IdentityResolver let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore @@ -532,13 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - conversationStore: ConversationStore? = nil, + conversationStore: LegacyConversationStore? = nil, identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? ConversationStore() + let conversationStore = conversationStore ?? LegacyConversationStore() let identityResolver = identityResolver ?? IdentityResolver() self.init( keychain: keychain, @@ -561,13 +561,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, transport: Transport, - conversationStore: ConversationStore? = nil, + conversationStore: LegacyConversationStore? = nil, identityResolver: IdentityResolver? = nil, peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let conversationStore = conversationStore ?? ConversationStore() + let conversationStore = conversationStore ?? LegacyConversationStore() let identityResolver = identityResolver ?? IdentityResolver() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index c58a6fd4..975d2ec3 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -146,10 +146,10 @@ struct AppArchitectureTests { #expect(Set([first, second]).count == 1) } - @Test("ConversationStore normalizes timeline ordering and duplicates") + @Test("LegacyConversationStore normalizes timeline ordering and duplicates") @MainActor func conversationStoreNormalizesMessages() { - let store = ConversationStore() + let store = LegacyConversationStore() let older = BitchatMessage( id: "m1", sender: "alice", @@ -191,10 +191,10 @@ struct AppArchitectureTests { #expect(messages.last?.content == "second-updated") } - @Test("ConversationStore tracks unread direct conversations with canonical IDs") + @Test("LegacyConversationStore tracks unread direct conversations with canonical IDs") @MainActor func conversationStoreTracksUnreadDirectConversations() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let peerID = PeerID(str: "peer-1") let message = BitchatMessage( @@ -226,10 +226,10 @@ struct AppArchitectureTests { #expect(!store.unreadConversations.contains(conversationID)) } - @Test("ConversationStore tracks the selected app conversation context") + @Test("LegacyConversationStore tracks the selected app conversation context") @MainActor func conversationStoreTracksSelectedConversationContext() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let noiseKey = Data((0..<32).map(UInt8.init)) let shortPeerID = PeerID(str: "0011223344556677") @@ -268,10 +268,10 @@ struct AppArchitectureTests { #expect(store.selectedConversationID == ConversationID.mesh) } - @Test("ConversationStore exposes direct conversations by the latest routing peer ID") + @Test("LegacyConversationStore exposes direct conversations by the latest routing peer ID") @MainActor func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let noiseKey = Data((0..<32).map(UInt8.init)) let shortPeerID = PeerID(str: "0011223344556677") @@ -334,10 +334,10 @@ struct AppArchitectureTests { #expect(store.unreadDirectPeerIDs() == Set([fullPeerID])) } - @Test("PrivateInboxModel mirrors direct message state from ConversationStore") + @Test("PrivateInboxModel mirrors direct message state from LegacyConversationStore") @MainActor func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async { - let store = ConversationStore() + let store = LegacyConversationStore() let resolver = IdentityResolver() let inboxModel = PrivateInboxModel(conversationStore: store) let messagePeerID = PeerID(str: "peer-1") @@ -383,7 +383,7 @@ struct AppArchitectureTests { @MainActor func appChromeModelMirrorsNicknameAndUnreadState() async { let viewModel = makeArchitectureViewModel() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let resolver = IdentityResolver() let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) @@ -414,7 +414,7 @@ struct AppArchitectureTests { @MainActor func appChromeModelOwnsPresentationState() { let viewModel = makeArchitectureViewModel() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) let peerID = PeerID(str: "peer-2") diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift new file mode 100644 index 00000000..4a805134 --- /dev/null +++ b/bitchatTests/ConversationStoreTests.swift @@ -0,0 +1,471 @@ +// +// ConversationStoreTests.swift +// bitchatTests +// +// Tests for the new single-source-of-truth ConversationStore +// (docs/CONVERSATION-STORE-DESIGN.md): intent API, ordered insertion, +// dedup, caps, delivery-status rules, migration, unread state, change +// emission, and per-conversation publish isolation. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Combine +import Foundation +import Testing +@testable import bitchat + +@MainActor +private func makeMessage( + id: String, + timestamp: TimeInterval, + content: String? = nil, + isPrivate: Bool = false, + deliveryStatus: DeliveryStatus? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: content ?? "message \(id)", + timestamp: Date(timeIntervalSince1970: timestamp), + isRelay: false, + isPrivate: isPrivate, + recipientNickname: isPrivate ? "bob" : nil, + senderPeerID: PeerID(str: "peer-a"), + deliveryStatus: deliveryStatus + ) +} + +private func makeDirectConversationID(_ suffix: String) -> ConversationID { + .direct(PeerHandle( + id: "noise:\(suffix)", + routingPeerID: PeerID(str: "peer-\(suffix)"), + displayName: "peer \(suffix)", + noisePublicKeyHex: suffix, + nostrPublicKey: nil + )) +} + +@Suite("ConversationStore") +struct ConversationStoreTests { + + // MARK: - Append, dedup, ordering + + @Test("append dedups by message ID and reports duplicates") + @MainActor + func appendDedupsByMessageID() { + let store = ConversationStore() + var received: [ConversationChange] = [] + let cancellable = store.changes.sink { received.append($0) } + defer { cancellable.cancel() } + + #expect(store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)) + #expect(!store.append(makeMessage(id: "m1", timestamp: 2, content: "dup"), to: .mesh)) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.count == 1) + #expect(conversation.messages.first?.content == "message m1") + #expect(conversation.containsMessage(withID: "m1")) + #expect(received.count == 1) + guard case .appended(.mesh, let message) = received.first else { + Issue.record("expected a single .appended change, got \(received)") + return + } + #expect(message.id == "m1") + } + + @Test("out-of-order appends are inserted in timestamp order") + @MainActor + func outOfOrderInsertKeepsTimestampOrder() { + let store = ConversationStore() + + store.append(makeMessage(id: "m1", timestamp: 10), to: .mesh) + store.append(makeMessage(id: "m3", timestamp: 30), to: .mesh) + store.append(makeMessage(id: "m2", timestamp: 20), to: .mesh) + store.append(makeMessage(id: "m0", timestamp: 5), to: .mesh) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.map(\.id) == ["m0", "m1", "m2", "m3"]) + + // The ID index must survive middle inserts: lookups by ID still + // resolve to the right message. + #expect(conversation.message(withID: "m2")?.timestamp == Date(timeIntervalSince1970: 20)) + #expect(conversation.message(withID: "m0")?.timestamp == Date(timeIntervalSince1970: 5)) + } + + @Test("equal timestamps preserve arrival order") + @MainActor + func equalTimestampsPreserveArrivalOrder() { + let store = ConversationStore() + + store.append(makeMessage(id: "first", timestamp: 10), to: .mesh) + store.append(makeMessage(id: "second", timestamp: 10), to: .mesh) + // A late message with an equal timestamp lands after existing peers. + store.append(makeMessage(id: "late-tail", timestamp: 20), to: .mesh) + store.append(makeMessage(id: "third", timestamp: 10), to: .mesh) + + let conversation = store.conversation(for: .mesh) + #expect(conversation.messages.map(\.id) == ["first", "second", "third", "late-tail"]) + } + + // MARK: - Caps + + @Test("cap trims oldest messages and keeps the ID index valid") + @MainActor + func capTrimsOldestAndKeepsIndexValid() { + let store = ConversationStore() + let conversation = store.conversation(for: .mesh) + let cap = conversation.cap + #expect(cap == TransportConfig.meshTimelineCap) + + let overflow = 3 + for i in 0..<(cap + overflow) { + store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh) + } + + #expect(conversation.messages.count == cap) + #expect(conversation.messages.first?.id == "m\(overflow)") + #expect(conversation.messages.last?.id == "m\(cap + overflow - 1)") + + // Trimmed messages left the index entirely… + for i in 0..() + conversationA.objectWillChange + .sink { aWillChangeCount += 1 } + .store(in: &cancellables) + conversationB.objectWillChange + .sink { bWillChangeCount += 1 } + .store(in: &cancellables) + + store.append(makeMessage(id: "m1", timestamp: 1), to: a) + store.append(makeMessage(id: "m2", timestamp: 2), to: a) + store.setDeliveryStatus(.sent, forMessageID: "m1", in: a) + store.markUnread(a) + + #expect(aWillChangeCount >= 4) + #expect(bWillChangeCount == 0) + + store.append(makeMessage(id: "m3", timestamp: 3), to: b) + #expect(bWillChangeCount > 0) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 418b75e2..839ceb2d 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -333,7 +333,7 @@ final class PerformanceBaselineTests: XCTestCase { /// Baseline for the full private-message ingest cycle through a real /// `ChatViewModel`: `handlePrivateMessage` → `privateChats` passthrough → /// `PrivateChatManager`'s `@Published` dict → bootstrapper Combine sink → - /// `Task.yield`-debounced `ConversationStore.synchronizePrivateChats` + /// `Task.yield`-debounced `LegacyConversationStore.synchronizePrivateChats` /// (full-dict replace) → `PrivateInboxModel.refreshMessages` (full /// rebuild). Measures wall time from first ingest until the store AND the /// feature model both reflect every message. The peer is not mesh-active @@ -370,14 +370,14 @@ final class PerformanceBaselineTests: XCTestCase { for message in messages { fixture.viewModel.handlePrivateMessage(message) } - // Drain the main queue until the debounced ConversationStore sync + // Drain the main queue until the debounced LegacyConversationStore sync // ran and the PrivateInboxModel mirror caught up. let consistent = spinMainRunLoop(timeout: 10) { fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount && fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount } samples.append(Date().timeIntervalSince(start)) - XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged") + XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged") XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount) } @@ -390,7 +390,7 @@ final class PerformanceBaselineTests: XCTestCase { /// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry, /// main-actor Task hop per message) → `handlePublicMessage` (rate limit, /// `PublicTimelineStore` append, per-message full-array - /// `ConversationStore` sync) → `PublicMessagePipeline` timer-batched + /// `LegacyConversationStore` sync) → `PublicMessagePipeline` timer-batched /// flush into `ChatViewModel.messages` → `PublicChatModel` mirror. /// Measures until `messages` and the feature model reflect every message, /// so the pipeline's flush latency is part of the cycle. Senders are @@ -450,6 +450,44 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages") } + // MARK: - 7. ConversationStore append (to-be architecture) + + /// Core op of the new single-source-of-truth `ConversationStore` + /// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one + /// conversation, every 10th arriving out of order so the binary-search + /// insert path (plus suffix reindex) is part of the measured work. + /// Includes per-message ID-index dedup and the per-conversation + /// `@Published` array write. + func testConversationStoreAppend() { + let messageCount = 1000 + let base = Date(timeIntervalSince1970: 1_700_000_000) + let messages: [BitchatMessage] = (0.. Bool) -> Bool { @@ -654,14 +692,14 @@ private final class PerfDeliveryContext: ChatDeliveryContext { /// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style /// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same -/// `ConversationStore`, so end-to-end ingest benchmarks cover the full +/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full /// current store-synchronization chain. Mirrors the construction used by /// `ChatViewModelExtensionsTests`. @MainActor private final class PerfPipelineFixture { let viewModel: ChatViewModel let transport: MockTransport - let conversationStore: ConversationStore + let conversationStore: LegacyConversationStore let privateInbox: PrivateInboxModel let publicChat: PublicChatModel @@ -670,7 +708,7 @@ private final class PerfPipelineFixture { let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() - let conversationStore = ConversationStore() + let conversationStore = LegacyConversationStore() self.transport = transport self.conversationStore = conversationStore