Cut public message path over to ConversationStore; delete PublicTimelineStore

Mesh and geohash timelines are now store conversations. All public
mutation sites flow through store intents; PublicMessagePipeline keeps
its 80ms UI batching but commits batches via store appends with each
buffered entry carrying its destination conversation (a mid-batch
channel switch now flushes instead of dropping the buffer).
ChatViewModel.messages becomes a cached get-only view of the active
conversation, invalidated through the change subject. The mesh
late-insert threshold is consciously removed: it only ever ordered the
non-rendered messages copy, so strict timestamp insertion makes the
working set agree with rendered order. PublicTimelineStore and the
per-message full-array legacy sync are deleted; the coalescing bridge
mirrors public conversations for the remaining legacy readers.

pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady;
store.append 237k/s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 13:03:07 +02:00
co-authored by Claude Fable 5
parent 879d8cba12
commit 99d1d1dccd
26 changed files with 713 additions and 875 deletions
@@ -25,15 +25,13 @@ import BitFoundation
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
// Channel & visible timeline state
var messages: [BitchatMessage] = []
// Channel state
var activeChannel: ChannelID = .mesh
var currentGeohash: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
private(set) var isBatchingPublic = false
private(set) var notifyUIChangedCount = 0
private(set) var trimMessagesCount = 0
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
@@ -43,92 +41,59 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
notifyUIChangedCount += 1
}
func trimMessagesIfNeeded() {
trimMessagesCount += 1
}
// Public timeline store
var meshTimeline: [BitchatMessage] = []
var geoTimelines: [String: [BitchatMessage]] = [:]
// Public conversation store (single-writer intents)
var conversations: [ConversationID: [BitchatMessage]] = [:]
private(set) var queuedGeohashSystemMessages: [String] = []
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh: return meshTimeline
case .location(let channel): return geoTimelines[channel.geohash] ?? []
}
func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] {
conversations[conversationID] ?? []
}
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh: meshTimeline.append(message)
case .location(let channel): geoTimelines[channel.geohash, default: []].append(message)
}
}
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
if geoTimelines[geohash]?.contains(where: { $0.id == message.id }) == true {
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else {
return false
}
geoTimelines[geohash, default: []].append(message)
conversations[conversationID, default: []].append(message)
return true
}
func removeTimelineMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for (geohash, timeline) in geoTimelines {
guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue }
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
appendPublicMessage(message, to: .geohash(geohash.lowercased()))
}
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
conversations[conversationID]?.contains(where: { $0.id == messageID }) == true
}
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for (conversationID, timeline) in conversations {
guard let index = timeline.firstIndex(where: { $0.id == messageID }) else { continue }
var updated = timeline
let removed = updated.remove(at: index)
geoTimelines[geohash] = updated
conversations[conversationID] = updated
return removed
}
return nil
}
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
geoTimelines[geohash]?.removeAll(where: predicate)
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate)
}
func clearTimeline(for channel: ChannelID) {
switch channel {
case .mesh: meshTimeline.removeAll()
case .location(let channel): geoTimelines[channel.geohash] = []
}
}
private(set) var clearedConversations: [ConversationID] = []
func timelineGeohashKeys() -> [String] {
Array(geoTimelines.keys)
func clearPublicConversation(_ conversationID: ConversationID) {
clearedConversations.append(conversationID)
conversations[conversationID] = []
}
func queueGeohashSystemMessage(_ content: String) {
queuedGeohashSystemMessages.append(content)
}
// Conversation stores
private(set) var conversationActiveChannels: [ChannelID] = []
private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = []
private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = []
private(set) var selectionStoreSyncCount = 0
func setConversationActiveChannel(_ channel: ChannelID) {
conversationActiveChannels.append(channel)
}
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replacedChannelMessages.append((channelID, messages.map(\.id)))
}
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
replacedConversationMessages.append((conversationID, messages.map(\.id)))
}
func synchronizeConversationSelectionStore() {
selectionStoreSyncCount += 1
}
// Private chats
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
@@ -222,7 +187,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
var blockedMessageIDs: Set<String> = []
var rateLimitAllowed = true
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
private(set) var enqueuedMessageIDs: [String] = []
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
var stablePeerIDs: [PeerID: PeerID] = [:]
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
@@ -238,8 +204,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
return rateLimitAllowed
}
func enqueuePublicMessage(_ message: BitchatMessage) {
enqueuedMessageIDs.append(message.id)
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
enqueuedMessages.append((message.id, conversationID))
}
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
@@ -309,24 +275,24 @@ private func makePublicMessage(
struct ChatPublicConversationCoordinatorContextTests {
@Test @MainActor
func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async {
func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
coordinator.handlePublicMessage(message)
#expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"])
#expect(context.replacedChannelMessages.count == 1)
#expect(context.replacedChannelMessages.first?.channel == .mesh)
#expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"])
// Visible-channel arrival: buffered for the batched pipeline flush
// (which commits to the store), not appended directly.
#expect(context.rateLimitChecks.count == 1)
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
#expect(context.rateLimitChecks.first?.contentKey == "hello mesh")
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
#expect(context.enqueuedMessages.map(\.messageID) == ["mesh-msg-1"])
#expect(context.enqueuedMessages.first?.conversationID == .mesh)
#expect(context.publicMessages(in: .mesh).isEmpty)
// Already visible in the timeline: stored again, but not re-enqueued.
context.messages = [message]
// Already committed to the store: not re-enqueued.
context.appendPublicMessage(message, to: .mesh)
coordinator.handlePublicMessage(message)
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
}
@@ -340,14 +306,14 @@ struct ChatPublicConversationCoordinatorContextTests {
context.blockedMessageIDs = ["blocked-msg"]
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
#expect(context.rateLimitChecks.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
// Rate limited: consulted, then dropped before storage.
context.rateLimitAllowed = false
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
#expect(context.rateLimitChecks.count == 1)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
}
@@ -364,16 +330,15 @@ struct ChatPublicConversationCoordinatorContextTests {
senderPeerID: PeerID(nostr: senderHex)
)
// On mesh channel: stored in the geohash timeline but not enqueued.
// On mesh channel: a background-channel arrival lands in the geohash
// conversation immediately, with no pipeline batching.
context.activeChannel = .mesh
coordinator.handlePublicMessage(geoMessage)
#expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"])
#expect(context.replacedConversationMessages.count == 1)
#expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash))
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"])
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.enqueuedMessageIDs.isEmpty)
// On the matching location channel: enqueued for display.
// On the matching location channel: enqueued for the batched flush.
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
let second = makePublicMessage(
id: "geo-msg-2",
@@ -381,7 +346,8 @@ struct ChatPublicConversationCoordinatorContextTests {
senderPeerID: PeerID(nostr: senderHex)
)
coordinator.handlePublicMessage(second)
#expect(context.enqueuedMessageIDs == ["geo-msg-2"])
#expect(context.enqueuedMessages.map(\.messageID) == ["geo-msg-2"])
#expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash))
}
@Test @MainActor
@@ -396,8 +362,7 @@ struct ChatPublicConversationCoordinatorContextTests {
context.currentGeohash = geohash
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
context.geoTimelines[geohash] = [geoMessage]
context.messages = [geoMessage]
context.conversations[.geohash(geohash)] = [geoMessage]
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
context.privateChats[convKey] = [geoMessage]
context.unreadPrivateMessages = [convKey]
@@ -406,13 +371,14 @@ struct ChatPublicConversationCoordinatorContextTests {
#expect(context.blockedNostrPubkeys.contains(hex))
#expect(context.removedGeoParticipants == [hex])
#expect(context.geoTimelines[geohash]?.isEmpty == true)
#expect(context.privateChats[convKey] == nil)
#expect(context.unreadPrivateMessages.isEmpty)
#expect(context.nostrKeyMapping.isEmpty)
// The blocked user's visible message is gone; a system notice was added.
#expect(!context.messages.contains(where: { $0.id == "geo-bad-1" }))
#expect(context.messages.last?.sender == "system")
// The blocked user's message is purged from the geohash conversation
// (the visible timeline is the same conversation now); a system
// notice was appended to the active conversation.
#expect(!context.publicMessages(in: .geohash(geohash)).contains(where: { $0.id == "geo-bad-1" }))
#expect(context.publicMessages(in: .geohash(geohash)).last?.sender == "system")
coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
#expect(!context.blockedNostrPubkeys.contains(hex))
@@ -424,36 +390,27 @@ struct ChatPublicConversationCoordinatorContextTests {
let coordinator = ChatPublicConversationCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let message = makePublicMessage(id: "doomed-msg")
context.messages = [message]
context.meshTimeline = [message]
context.conversations[.mesh] = [message]
context.privateChats[peerID] = [message]
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
#expect(context.messages.isEmpty)
#expect(context.meshTimeline.isEmpty)
#expect(context.publicMessages(in: .mesh).isEmpty)
#expect(context.privateChats[peerID] == nil)
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
#expect(context.notifyUIChangedCount == 1)
// Timeline removal triggers a full conversation-store resync.
#expect(context.replacedChannelMessages.contains(where: { $0.channel == .mesh && $0.messageIDs.isEmpty }))
}
@Test @MainActor
func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async {
func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async {
let context = MockChatPublicConversationContext()
let coordinator = ChatPublicConversationCoordinator(context: context)
coordinator.addPublicSystemMessage("Tor Ready")
#expect(context.meshTimeline.count == 1)
#expect(context.meshTimeline.first?.sender == "system")
// refreshVisibleMessages mirrors the timeline into the visible list.
#expect(context.messages.map(\.id) == context.meshTimeline.map(\.id))
#expect(context.publicMessages(in: .mesh).count == 1)
#expect(context.publicMessages(in: .mesh).first?.sender == "system")
#expect(context.recordedContentKeys.map(\.key) == ["tor ready"])
#expect(context.trimMessagesCount == 1)
#expect(context.notifyUIChangedCount == 1)
#expect(context.conversationActiveChannels == [.mesh])
// On mesh, geohash-only system messages are queued for the next geo visit.
coordinator.addGeohashOnlySystemMessage("geo notice")
@@ -477,22 +434,20 @@ struct ChatPublicConversationCoordinatorContextTests {
let coordinator = ChatPublicConversationCoordinator(context: context)
let pipeline = PublicMessagePipeline()
let message = makePublicMessage(id: "pipeline-msg")
context.messages = [message]
context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42)
#expect(coordinator.pipelineCurrentMessages(pipeline).map(\.id) == ["pipeline-msg"])
#expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello")
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
coordinator.pipeline(pipeline, setMessages: [])
#expect(context.messages.isEmpty)
// Commit lands in the store via the append intent; a duplicate ID
// reports `false` (the store's dedup contract).
#expect(coordinator.pipeline(pipeline, commit: message, to: .mesh))
#expect(context.publicMessages(in: .mesh).map(\.id) == ["pipeline-msg"])
#expect(!coordinator.pipeline(pipeline, commit: message, to: .mesh))
coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7))
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
coordinator.pipelineTrimMessages(pipeline)
#expect(context.trimMessagesCount == 1)
coordinator.pipelinePrewarmMessage(pipeline, message: message)
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])