Cut views over to ConversationStore; delete legacy store, bridge, resolver

Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.

Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest  6.8k -> 13.7k msg/s (2.0x)
delivery updates       38k  -> 117-133k/s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 13:57:12 +02:00
co-authored by Claude Fable 5
parent 7fb1f4a219
commit 38331e62f1
32 changed files with 542 additions and 1214 deletions
@@ -315,14 +315,13 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 6a. End-to-end private ingest pipeline (current architecture)
/// 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 `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
/// and no chat is selected, so notification/read-receipt side paths stay
/// cold (notifications are no-ops under test anyway).
/// `ChatViewModel`: `handlePrivateMessage` `ConversationStore.append`
/// intent (ordered insert + ID-index dedup) per-conversation publish +
/// `changes` emission `PrivateInboxModel` (direct store reads).
/// Measures wall time from first ingest until the store AND the feature
/// model both reflect every message. The peer is not mesh-active and no
/// chat is selected, so notification/read-receipt side paths stay cold
/// (notifications are no-ops under test anyway).
func testPipelinePrivateIngest() {
let messageCount = 200
// 64-hex (stable Noise key) peer ID: skips the short-ID consolidation
@@ -354,14 +353,14 @@ final class PerformanceBaselineTests: XCTestCase {
for message in messages {
fixture.viewModel.handlePrivateMessage(message)
}
// Drain the main queue until the debounced LegacyConversationStore sync
// ran and the PrivateInboxModel mirror caught up.
// Reads are synchronous against the single-writer store; the
// spin covers any coordinator main-actor hops.
let consistent = spinMainRunLoop(timeout: 10) {
fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount
&& fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount
fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount
&& fixture.privateInbox.messages(for: peerID).count == messageCount
}
samples.append(Date().timeIntervalSince(start))
XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged")
XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
}
@@ -374,8 +373,8 @@ final class PerformanceBaselineTests: XCTestCase {
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
/// main-actor Task hop per message) `handlePublicMessage` (rate limit,
/// pipeline enqueue) `PublicMessagePipeline` timer-batched flush into
/// the `ConversationStore` (derived `ChatViewModel.messages` view)
/// coalesced `LegacyConversationStoreBridge` mirror `PublicChatModel`.
/// the `ConversationStore` (derived `ChatViewModel.messages` view;
/// `PublicChatModel` observes the active `Conversation` directly).
/// Measures until `messages` and the feature model reflect every message,
/// so the pipeline's flush latency is part of the cycle. Senders are
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
@@ -419,8 +418,8 @@ final class PerformanceBaselineTests: XCTestCase {
}
// Drain the per-message main-actor hops and whichever surfacing
// path wins (the pipeline's batched timer flush or the startup
// channel apply's `refreshVisibleMessages`, which pulls the whole
// timeline into `messages`), plus the PublicChatModel mirror.
// channel apply's `refreshVisibleMessages`); `PublicChatModel`
// reads the same conversation synchronously.
let consistent = spinMainRunLoop(timeout: 10) {
fixture.viewModel.messages.count >= messageCount
&& fixture.publicChat.messages.count >= messageCount
@@ -720,14 +719,14 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style
/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same
/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full
/// current store-synchronization chain. Mirrors the construction used by
/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover
/// the full ingest-to-feature-model chain. Mirrors the construction used by
/// `ChatViewModelExtensionsTests`.
@MainActor
private final class PerfPipelineFixture {
let viewModel: ChatViewModel
let transport: MockTransport
let conversationStore: LegacyConversationStore
let conversations: ConversationStore
let privateInbox: PrivateInboxModel
let publicChat: PublicChatModel
@@ -736,19 +735,19 @@ private final class PerfPipelineFixture {
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let conversationStore = LegacyConversationStore()
let conversations = ConversationStore()
self.transport = transport
self.conversationStore = conversationStore
self.conversations = conversations
self.viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport,
conversationStore: conversationStore
conversations: conversations
)
self.privateInbox = PrivateInboxModel(conversationStore: conversationStore)
self.publicChat = PublicChatModel(conversationStore: conversationStore)
self.privateInbox = PrivateInboxModel(conversations: conversations)
self.publicChat = PublicChatModel(conversations: conversations)
}
}