mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Route delivery status through the store; delete the location index
ConversationStore maintains an exact messageID -> Set<ConversationID> map at every mutation point (append/upsert/remove/migrate/trim/clear), so delivery updates are ID-only lookups that fan out to mirrored ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119 lines: the positional location index, its growth-detection/rebuild machinery, and the duplicate no-downgrade check are deleted - the rule now lives in exactly one place. The middle-insertion regression tests are rewritten against the store since stale positional locations are structurally impossible now. delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,10 +64,25 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
/// All message IDs currently in this conversation (unordered).
|
||||
var messageIDs: Dictionary<String, Int>.Keys {
|
||||
indexByMessageID.keys
|
||||
}
|
||||
|
||||
// MARK: Store-internal mutations
|
||||
|
||||
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
|
||||
/// evicted by the cap so the store can keep its message-ID →
|
||||
/// conversation map exact.
|
||||
fileprivate struct InsertResult {
|
||||
let inserted: Bool
|
||||
let trimmedMessageIDs: [String]
|
||||
|
||||
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
|
||||
}
|
||||
|
||||
fileprivate enum UpsertOutcome {
|
||||
case appended
|
||||
case appended(trimmedMessageIDs: [String])
|
||||
case updated
|
||||
}
|
||||
|
||||
@@ -75,9 +90,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// 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 }
|
||||
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||
|
||||
if let last = messages.last, message.timestamp < last.timestamp {
|
||||
let index = insertionIndex(for: message.timestamp)
|
||||
@@ -88,8 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
trimIfNeeded()
|
||||
return true
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID. An existing message keeps its
|
||||
@@ -100,14 +114,14 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
_ = insert(message)
|
||||
return .appended
|
||||
let result = insert(message)
|
||||
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||
/// update flows through the store): 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 }
|
||||
@@ -197,14 +211,17 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func trimIfNeeded() {
|
||||
guard messages.count > cap else { return }
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
let overflow = messages.count - cap
|
||||
for message in messages.prefix(overflow) {
|
||||
indexByMessageID.removeValue(forKey: message.id)
|
||||
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||
for id in trimmedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +259,19 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||
|
||||
/// Store-level message-ID → conversation-membership map for ID-only
|
||||
/// lookups (delivery receipts arrive with a message ID, not a
|
||||
/// conversation). Maintained incrementally at every mutation point —
|
||||
/// all mutation is centralized in the intent API below, so the map is
|
||||
/// exact, never scanned or rebuilt.
|
||||
///
|
||||
/// The value is a `Set` because a private message can legitimately live
|
||||
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
|
||||
/// message into both the stable-key and ephemeral-peer chats
|
||||
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
|
||||
/// copies.
|
||||
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
|
||||
|
||||
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||
|
||||
// MARK: Intent API
|
||||
@@ -264,7 +294,10 @@ final class ConversationStore: ObservableObject {
|
||||
@discardableResult
|
||||
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||
let conversation = conversation(for: id)
|
||||
guard conversation.insert(message) else { return false }
|
||||
let result = conversation.insert(message)
|
||||
guard result.inserted else { return false }
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
return true
|
||||
}
|
||||
@@ -273,7 +306,9 @@ final class ConversationStore: ObservableObject {
|
||||
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
switch conversation.upsert(message) {
|
||||
case .appended:
|
||||
case .appended(let trimmedMessageIDs):
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
case .updated:
|
||||
changes.send(.updated(id, messageID: message.id))
|
||||
@@ -293,6 +328,44 @@ final class ConversationStore: ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Applies a delivery status to EVERY conversation containing
|
||||
/// `messageID` (ID-only — delivery receipts don't know conversations;
|
||||
/// mirrored private copies live in two direct chats). Returns `false`
|
||||
/// when the message is unknown or no copy changed (equal status or
|
||||
/// downgrade — read beats delivered beats sent).
|
||||
///
|
||||
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
|
||||
/// instance are updated by the first apply; subsequent conversations
|
||||
/// skip as already-equal (state stays correct everywhere, the
|
||||
/// `.statusChanged` event fires for the conversation that applied).
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
var applied = false
|
||||
for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) {
|
||||
applied = true
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
/// Current delivery status of `messageID` in whichever conversation
|
||||
/// holds it (mirrored copies share status — see `setDeliveryStatus`).
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
|
||||
for id in ids {
|
||||
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Every conversation currently containing `messageID` (empty when the
|
||||
/// message is unknown).
|
||||
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
|
||||
conversationIDsByMessageID[messageID] ?? []
|
||||
}
|
||||
|
||||
func markRead(_ id: ConversationID) {
|
||||
guard unreadConversations.contains(id) else { return }
|
||||
unreadConversations.remove(id)
|
||||
@@ -329,7 +402,13 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
let destinationConversation = conversation(for: destination)
|
||||
for message in sourceConversation.messages {
|
||||
_ = destinationConversation.insert(message)
|
||||
let result = destinationConversation.insert(message)
|
||||
guard result.inserted else { continue }
|
||||
registerMessageID(message.id, in: destination)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
|
||||
}
|
||||
for messageID in sourceConversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: source)
|
||||
}
|
||||
|
||||
let wasUnread = unreadConversations.contains(source)
|
||||
@@ -359,6 +438,7 @@ final class ConversationStore: ObservableObject {
|
||||
let removed = conversation.remove(messageID: messageID) else {
|
||||
return nil
|
||||
}
|
||||
unregisterMessageID(messageID, from: id)
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
return removed
|
||||
}
|
||||
@@ -368,7 +448,9 @@ final class ConversationStore: ObservableObject {
|
||||
/// conversation is consistent. No-op for unknown conversations.
|
||||
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.removeAll(where: predicate) {
|
||||
let removedIDs = conversation.removeAll(where: predicate)
|
||||
unregisterMessageIDs(removedIDs, from: id)
|
||||
for messageID in removedIDs {
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
}
|
||||
}
|
||||
@@ -377,6 +459,9 @@ final class ConversationStore: ObservableObject {
|
||||
/// its unread/selection state) alive.
|
||||
func clear(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversation.clearMessages()
|
||||
changes.send(.cleared(id))
|
||||
}
|
||||
@@ -384,7 +469,10 @@ final class ConversationStore: ObservableObject {
|
||||
/// 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 }
|
||||
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversationIDs.removeAll { $0 == id }
|
||||
unreadConversations.remove(id)
|
||||
if selectedConversationID == id {
|
||||
@@ -400,6 +488,7 @@ final class ConversationStore: ObservableObject {
|
||||
conversationsByID.removeAll()
|
||||
conversationIDs.removeAll()
|
||||
unreadConversations.removeAll()
|
||||
conversationIDsByMessageID.removeAll()
|
||||
if selectedConversationID != nil {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
@@ -411,6 +500,26 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private func registerMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
}
|
||||
|
||||
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
guard var ids = conversationIDsByMessageID[messageID] else { return }
|
||||
ids.remove(id)
|
||||
if ids.isEmpty {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
} else {
|
||||
conversationIDsByMessageID[messageID] = ids
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
|
||||
for messageID in messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cap(for id: ConversationID) -> Int {
|
||||
switch id {
|
||||
case .mesh:
|
||||
@@ -471,13 +580,23 @@ extension ConversationStore {
|
||||
}
|
||||
|
||||
/// `true` when any direct conversation contains a message with `messageID`
|
||||
/// (O(1) per conversation via the incremental ID index).
|
||||
/// (O(1) via the store-level message-ID → conversation map).
|
||||
func directConversationsContainMessage(withID messageID: String) -> Bool {
|
||||
conversationIDs(forMessageID: messageID).contains { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Message IDs across all direct conversations (read-receipt pruning
|
||||
/// keeps only receipts whose messages still exist).
|
||||
func directMessageIDs() -> Set<String> {
|
||||
var messageIDs = Set<String>()
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct = id else { continue }
|
||||
if conversation.containsMessage(withID: messageID) { return true }
|
||||
messageIDs.formUnion(conversation.messageIDs)
|
||||
}
|
||||
return false
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
/// Removes every direct conversation (panic clear).
|
||||
@@ -501,12 +620,10 @@ extension ConversationStore {
|
||||
/// message, if any.
|
||||
@discardableResult
|
||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||
for (id, conversation) in conversationsByID {
|
||||
for id in conversationIDs(forMessageID: messageID) {
|
||||
switch id {
|
||||
case .mesh, .geohash:
|
||||
if conversation.containsMessage(withID: messageID) {
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
}
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
case .direct:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,18 +12,19 @@ import Foundation
|
||||
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
||||
@MainActor
|
||||
protocol ChatDeliveryContext: AnyObject {
|
||||
/// Get-only derived views of the `ConversationStore`. `BitchatMessage`
|
||||
/// is a reference type, so the public-timeline status patch below writes
|
||||
/// through the shared message objects; step 4 replaces this with
|
||||
/// `setDeliveryStatus(for:in:)` store intents.
|
||||
var messages: [BitchatMessage] { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||
var isStartupPhase: Bool { get }
|
||||
/// Applies a delivery status to a private message by ID (single-writer
|
||||
/// store intent; full delivery migration is step 4). Returns `false`
|
||||
/// when the message is unknown or the update would downgrade the status.
|
||||
/// Applies a delivery status to every copy of the message across
|
||||
/// conversations (`ConversationStore` intent, ID-only: the store's
|
||||
/// message-ID → conversation map resolves which conversations hold the
|
||||
/// message, including mirrored ephemeral/stable private copies). The
|
||||
/// no-downgrade rule is enforced in the store. Returns `false` when the
|
||||
/// message is unknown or no copy changed.
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
||||
/// Current delivery status of the message in whichever conversation holds it.
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
||||
/// Message IDs across all direct conversations (read-receipt pruning).
|
||||
func privateMessageIDs() -> Set<String>
|
||||
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
||||
/// Returns the number of receipts removed. (Single mutation path for the
|
||||
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||
@@ -35,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatDeliveryContext {
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
conversations.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
func privateMessageIDs() -> Set<String> {
|
||||
conversations.directMessageIDs()
|
||||
}
|
||||
|
||||
func notifyUIChanged() {
|
||||
objectWillChange.send()
|
||||
}
|
||||
@@ -44,14 +58,12 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin mapper from delivery events (read receipts, transport delivery
|
||||
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
|
||||
/// retention cleanup. The store's message-ID → conversation map replaces the
|
||||
/// positional `messageLocationIndex` this coordinator used to maintain.
|
||||
final class ChatDeliveryCoordinator {
|
||||
private unowned let context: any ChatDeliveryContext
|
||||
private var messageLocationIndex: [String: Set<MessageLocation>] = [:]
|
||||
private var indexedPublicMessageCount = 0
|
||||
private var indexedPublicTailMessageID: String?
|
||||
private var indexedPrivateMessageCounts: [PeerID: Int] = [:]
|
||||
private var indexedPrivateTailMessageIDs: [PeerID: String] = [:]
|
||||
private var hasBuiltMessageLocationIndex = false
|
||||
|
||||
init(context: any ChatDeliveryContext) {
|
||||
self.context = context
|
||||
@@ -59,15 +71,9 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func cleanupOldReadReceipts() {
|
||||
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let validMessageIDs = Set(
|
||||
context.privateChats.values.flatMap { messages in
|
||||
messages.map(\.id)
|
||||
}
|
||||
)
|
||||
guard !context.isStartupPhase else { return }
|
||||
let validMessageIDs = context.privateMessageIDs()
|
||||
guard !validMessageIDs.isEmpty else { return }
|
||||
|
||||
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||
if removedCount > 0 {
|
||||
@@ -90,9 +96,7 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
withValidLocations(for: messageID) { locations in
|
||||
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
|
||||
}
|
||||
context.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -106,222 +110,10 @@ final class ChatDeliveryCoordinator {
|
||||
break
|
||||
}
|
||||
|
||||
var didUpdateStatus = false
|
||||
let locations = withValidLocations(for: messageID) { $0 }
|
||||
guard !locations.isEmpty else { return false }
|
||||
|
||||
for location in locations {
|
||||
guard case .publicTimeline(let index) = location,
|
||||
index < context.messages.count,
|
||||
context.messages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = context.messages[index].deliveryStatus
|
||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||
context.messages[index].deliveryStatus = status
|
||||
didUpdateStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
for location in locations {
|
||||
guard case .privateChat(let peerID, let index) = location,
|
||||
let chatMessages = context.privateChats[peerID],
|
||||
index < chatMessages.count,
|
||||
chatMessages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = chatMessages[index].deliveryStatus
|
||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||
|
||||
if context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID) {
|
||||
didUpdateStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
if didUpdateStatus {
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
return didUpdateStatus
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatDeliveryCoordinator {
|
||||
enum MessageLocation: Hashable {
|
||||
case publicTimeline(index: Int)
|
||||
case privateChat(peerID: PeerID, index: Int)
|
||||
}
|
||||
|
||||
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||
guard let currentStatus else { return false }
|
||||
if currentStatus == newStatus { return true }
|
||||
|
||||
switch (currentStatus, newStatus) {
|
||||
case (.read, .delivered), (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func withValidLocations<T>(
|
||||
for messageID: String,
|
||||
_ body: (Set<MessageLocation>) -> T
|
||||
) -> T {
|
||||
let didRebuildIndex = refreshMessageLocationIndexForGrowth()
|
||||
|
||||
if let locations = messageLocationIndex[messageID],
|
||||
locations.allSatisfy({ isLocation($0, validFor: messageID) }) {
|
||||
return body(locations)
|
||||
}
|
||||
|
||||
guard !didRebuildIndex else {
|
||||
return body(messageLocationIndex[messageID] ?? [])
|
||||
}
|
||||
|
||||
if messageLocationIndex[messageID] == nil {
|
||||
return body([])
|
||||
}
|
||||
|
||||
rebuildMessageLocationIndex()
|
||||
return body(messageLocationIndex[messageID] ?? [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? {
|
||||
switch location {
|
||||
case .publicTimeline(let index):
|
||||
guard index < context.messages.count else { return nil }
|
||||
return context.messages[index].deliveryStatus
|
||||
case .privateChat(let peerID, let index):
|
||||
guard let messages = context.privateChats[peerID],
|
||||
index < messages.count else {
|
||||
return nil
|
||||
}
|
||||
return messages[index].deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool {
|
||||
switch location {
|
||||
case .publicTimeline(let index):
|
||||
return index < context.messages.count
|
||||
&& context.messages[index].id == messageID
|
||||
case .privateChat(let peerID, let index):
|
||||
guard let messages = context.privateChats[peerID],
|
||||
index < messages.count else {
|
||||
return false
|
||||
}
|
||||
return messages[index].id == messageID
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func refreshMessageLocationIndexForGrowth() -> Bool {
|
||||
guard hasBuiltMessageLocationIndex else {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count < indexedPublicMessageCount {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count == indexedPublicMessageCount,
|
||||
context.messages.last?.id != indexedPublicTailMessageID {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count > indexedPublicMessageCount {
|
||||
// Growth is only a pure append if the previously indexed tail kept
|
||||
// its position; a middle insertion (out-of-order timestamp arrival)
|
||||
// shifts it and invalidates every indexed location after the
|
||||
// insertion point.
|
||||
if indexedPublicMessageCount > 0,
|
||||
context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
for index in indexedPublicMessageCount..<context.messages.count {
|
||||
add(.publicTimeline(index: index), for: context.messages[index].id)
|
||||
}
|
||||
indexedPublicMessageCount = context.messages.count
|
||||
indexedPublicTailMessageID = context.messages.last?.id
|
||||
}
|
||||
|
||||
let currentPeerIDs = Set(context.privateChats.keys)
|
||||
if !Set(indexedPrivateMessageCounts.keys).isSubset(of: currentPeerIDs) {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
for (peerID, messages) in context.privateChats {
|
||||
let indexedCount = indexedPrivateMessageCounts[peerID] ?? 0
|
||||
if messages.count < indexedCount {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if messages.count == indexedCount,
|
||||
messages.last?.id != indexedPrivateTailMessageIDs[peerID] {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
guard messages.count > indexedCount else { continue }
|
||||
// Same append-only check as the public timeline above.
|
||||
if indexedCount > 0,
|
||||
messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
for index in indexedCount..<messages.count {
|
||||
add(.privateChat(peerID: peerID, index: index), for: messages[index].id)
|
||||
}
|
||||
indexedPrivateMessageCounts[peerID] = messages.count
|
||||
if let tailID = messages.last?.id {
|
||||
indexedPrivateTailMessageIDs[peerID] = tailID
|
||||
} else {
|
||||
indexedPrivateTailMessageIDs.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func rebuildMessageLocationIndex() {
|
||||
messageLocationIndex.removeAll(keepingCapacity: true)
|
||||
|
||||
for (index, message) in context.messages.enumerated() {
|
||||
add(.publicTimeline(index: index), for: message.id)
|
||||
}
|
||||
indexedPublicMessageCount = context.messages.count
|
||||
indexedPublicTailMessageID = context.messages.last?.id
|
||||
|
||||
indexedPrivateMessageCounts.removeAll(keepingCapacity: true)
|
||||
indexedPrivateTailMessageIDs.removeAll(keepingCapacity: true)
|
||||
for (peerID, messages) in context.privateChats {
|
||||
for (index, message) in messages.enumerated() {
|
||||
add(.privateChat(peerID: peerID, index: index), for: message.id)
|
||||
}
|
||||
indexedPrivateMessageCounts[peerID] = messages.count
|
||||
if let tailID = messages.last?.id {
|
||||
indexedPrivateTailMessageIDs[peerID] = tailID
|
||||
}
|
||||
}
|
||||
|
||||
hasBuiltMessageLocationIndex = true
|
||||
}
|
||||
|
||||
func add(_ location: MessageLocation, for messageID: String) {
|
||||
messageLocationIndex[messageID, default: []].insert(location)
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
||||
func deliveryStatus_survivesPrivateChatReorder() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "reordered-msg-1"
|
||||
@@ -335,8 +335,8 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
||||
|
||||
// A late arrival with an older timestamp is inserted before the
|
||||
// target by the store, shifting its position and invalidating the
|
||||
// delivery coordinator's location index.
|
||||
// target by the store, shifting its position; the store's ID-keyed
|
||||
// indexes must keep the target updatable.
|
||||
viewModel.seedPrivateChat([olderMessage], for: peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["older-msg", messageID])
|
||||
|
||||
@@ -383,16 +383,31 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
// MARK: - Mock Delivery Context
|
||||
|
||||
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
||||
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
|
||||
/// `ChatDeliveryCoordinator` is testable without constructing a
|
||||
/// `ChatViewModel`: the delivery surface forwards to a real
|
||||
/// `ConversationStore` (the coordinator is a thin mapper over store
|
||||
/// intents), and assertions read store state.
|
||||
@MainActor
|
||||
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
let store = ConversationStore()
|
||||
var sentReadReceipts: Set<String> = []
|
||||
var isStartupPhase = false
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
private(set) var markedDeliveredMessageIDs: [String] = []
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
func privateMessageIDs() -> Set<String> {
|
||||
store.directMessageIDs()
|
||||
}
|
||||
|
||||
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||
let oldCount = sentReadReceipts.count
|
||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||
@@ -406,29 +421,19 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
markedDeliveredMessageIDs.append(messageID)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
||||
guard var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||
return false
|
||||
}
|
||||
if Conversation.shouldSkipStatusUpdate(current: chat[index].deliveryStatus, new: status) {
|
||||
return false
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage {
|
||||
private func makePrivateMessage(
|
||||
id: String,
|
||||
status: DeliveryStatus,
|
||||
timestamp: Date = Date()
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "me",
|
||||
content: "Test message",
|
||||
timestamp: Date(),
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
@@ -437,10 +442,28 @@ private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMe
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makePublicMessage(
|
||||
id: String,
|
||||
status: DeliveryStatus,
|
||||
timestamp: Date = Date()
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "me",
|
||||
content: "Public message",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: status
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` —
|
||||
/// the exemplar for the narrow-dependency coordinator pattern.
|
||||
/// the exemplar for the narrow-dependency coordinator pattern. State is
|
||||
/// seeded into and asserted against the mock's `ConversationStore`.
|
||||
struct ChatDeliveryCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
@@ -449,7 +472,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "mock-msg-1"
|
||||
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)]
|
||||
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(peerID))
|
||||
|
||||
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
@@ -457,7 +480,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus))
|
||||
#expect(isDelivered(context.store.conversation(for: .directPeer(peerID)).message(withID: messageID)?.deliveryStatus))
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||
}
|
||||
@@ -468,13 +491,16 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "mock-msg-2"
|
||||
context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))]
|
||||
context.store.append(
|
||||
makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date())),
|
||||
to: .directPeer(peerID)
|
||||
)
|
||||
|
||||
coordinator.didReceiveReadReceipt(
|
||||
ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer")
|
||||
)
|
||||
|
||||
#expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus))
|
||||
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||
}
|
||||
@@ -483,22 +509,12 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
context.messages = [
|
||||
BitchatMessage(
|
||||
id: "public-mock-1",
|
||||
sender: "me",
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
]
|
||||
context.store.append(makePublicMessage(id: "public-mock-1", status: .sending), to: .mesh)
|
||||
|
||||
// .sent is not a confirmed receipt — must not reach markMessageDelivered.
|
||||
let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent)
|
||||
#expect(didUpdate)
|
||||
#expect(isSent(context.messages.first?.deliveryStatus))
|
||||
#expect(isSent(context.store.conversation(for: .mesh).message(withID: "public-mock-1")?.deliveryStatus))
|
||||
#expect(context.markedDeliveredMessageIDs.isEmpty)
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
|
||||
@@ -508,57 +524,98 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
#expect(context.notifyUIChangedCount == 1)
|
||||
}
|
||||
|
||||
// The old positional `messageLocationIndex` could go stale when a late
|
||||
// arrival was inserted mid-array (count grew but indexed locations
|
||||
// shifted). The store's per-conversation ID index is reindexed inside the
|
||||
// same mutation, so staleness is structurally impossible — these tests
|
||||
// pin the equivalent behavior through the new path: after out-of-order
|
||||
// insertion, updates keyed by ID still land on the right messages.
|
||||
|
||||
@Test @MainActor
|
||||
func middleInsertedMessage_isFoundAfterIndexWasBuilt() async {
|
||||
func middleInsertedMessage_isStillUpdatableByID() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let makePublic = { (id: String) in
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "me",
|
||||
content: "Public message",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
}
|
||||
context.messages = [makePublic("public-a"), makePublic("public-b")]
|
||||
context.store.append(
|
||||
makePublicMessage(id: "public-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||
to: .mesh
|
||||
)
|
||||
context.store.append(
|
||||
makePublicMessage(id: "public-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||
to: .mesh
|
||||
)
|
||||
|
||||
// Build the incremental index.
|
||||
#expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent))
|
||||
|
||||
// Out-of-order arrival: PublicMessagePipeline inserts by timestamp,
|
||||
// so the count grows while the tail ID stays the same.
|
||||
context.messages.insert(makePublic("public-mid"), at: 1)
|
||||
// Out-of-order arrival: the store inserts by timestamp, shifting the
|
||||
// tail's position.
|
||||
context.store.append(
|
||||
makePublicMessage(id: "public-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
|
||||
to: .mesh
|
||||
)
|
||||
let mesh = context.store.conversation(for: .mesh)
|
||||
#expect(mesh.messages.map(\.id) == ["public-a", "public-mid", "public-b"])
|
||||
|
||||
// The inserted message must be locatable, and the shifted tail must
|
||||
// not be updated through a stale index entry.
|
||||
// Both the inserted message and the shifted tail stay updatable.
|
||||
#expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent))
|
||||
#expect(isSent(context.messages[1].deliveryStatus))
|
||||
#expect(isSent(mesh.message(withID: "public-mid")?.deliveryStatus))
|
||||
#expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent))
|
||||
#expect(isSent(context.messages[2].deliveryStatus))
|
||||
#expect(isSent(mesh.message(withID: "public-b")?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async {
|
||||
func middleInsertedPrivateMessage_isStillUpdatableByID() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "pm-a", status: .sending),
|
||||
makePrivateMessage(id: "pm-b", status: .sending)
|
||||
]
|
||||
let conversationID = ConversationID.directPeer(peerID)
|
||||
context.store.append(
|
||||
makePrivateMessage(id: "pm-a", status: .sending, timestamp: Date(timeIntervalSince1970: 10)),
|
||||
to: conversationID
|
||||
)
|
||||
context.store.append(
|
||||
makePrivateMessage(id: "pm-b", status: .sending, timestamp: Date(timeIntervalSince1970: 30)),
|
||||
to: conversationID
|
||||
)
|
||||
|
||||
#expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent))
|
||||
|
||||
// Timestamp re-sort (sanitizeChat) can place a late arrival mid-array.
|
||||
context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1)
|
||||
// A late arrival with an older timestamp lands mid-array.
|
||||
context.store.append(
|
||||
makePrivateMessage(id: "pm-mid", status: .sending, timestamp: Date(timeIntervalSince1970: 20)),
|
||||
to: conversationID
|
||||
)
|
||||
let chat = context.store.conversation(for: conversationID)
|
||||
#expect(chat.messages.map(\.id) == ["pm-a", "pm-mid", "pm-b"])
|
||||
|
||||
#expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent))
|
||||
#expect(isSent(context.privateChats[peerID]?[1].deliveryStatus))
|
||||
#expect(isSent(chat.message(withID: "pm-mid")?.deliveryStatus))
|
||||
#expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent))
|
||||
#expect(isSent(context.privateChats[peerID]?[2].deliveryStatus))
|
||||
#expect(isSent(chat.message(withID: "pm-b")?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func mirroredPrivateCopies_bothReceiveDeliveryUpdate() async {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let stablePeerID = PeerID(str: String(repeating: "ab", count: 32))
|
||||
let ephemeralPeerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "mirrored-mock-1"
|
||||
|
||||
// Step 2's keying mirrors a private message into both the stable-key
|
||||
// and ephemeral-peer conversations (distinct copies here to prove
|
||||
// per-conversation application, not shared-reference aliasing).
|
||||
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(stablePeerID))
|
||||
context.store.append(makePrivateMessage(id: messageID, status: .sent), to: .directPeer(ephemeralPeerID))
|
||||
|
||||
let didUpdate = coordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date())
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isDelivered(context.store.conversation(for: .directPeer(stablePeerID)).message(withID: messageID)?.deliveryStatus))
|
||||
#expect(isDelivered(context.store.conversation(for: .directPeer(ephemeralPeerID)).message(withID: messageID)?.deliveryStatus))
|
||||
#expect(context.markedDeliveredMessageIDs == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -566,7 +623,7 @@ struct ChatDeliveryCoordinatorContextTests {
|
||||
let context = MockChatDeliveryContext()
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)]
|
||||
context.store.append(makePrivateMessage(id: "keep-receipt", status: .sent), to: .directPeer(peerID))
|
||||
context.sentReadReceipts = ["keep-receipt", "drop-receipt"]
|
||||
|
||||
// Startup phase: cleanup must be a no-op.
|
||||
|
||||
@@ -562,4 +562,159 @@ struct ConversationStoreTests {
|
||||
#expect(!conversation.containsMessage(withID: "one"))
|
||||
#expect(store.append(makeMessage(id: "one", timestamp: 2000), to: id))
|
||||
}
|
||||
|
||||
// MARK: - Store-level message-ID → conversation map (ID-only delivery)
|
||||
|
||||
@Test("ID map tracks append, upsert, and removal")
|
||||
@MainActor
|
||||
func messageIDMapTracksAppendAndRemoval() {
|
||||
let store = ConversationStore()
|
||||
let direct = makeDirectConversationID("aa")
|
||||
|
||||
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||
|
||||
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||
#expect(store.conversationIDs(forMessageID: "m1") == [.mesh])
|
||||
|
||||
// Upsert-as-append registers; upsert-in-place does not duplicate.
|
||||
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true), in: direct)
|
||||
store.upsertByID(makeMessage(id: "d1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: direct)
|
||||
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
|
||||
|
||||
store.removeMessage(withID: "m1", from: .mesh)
|
||||
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||
|
||||
store.removeMessages(from: direct, where: { $0.id == "d1" })
|
||||
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
|
||||
}
|
||||
|
||||
@Test("ID map handles multi-conversation membership (mirrored private copies)")
|
||||
@MainActor
|
||||
func messageIDMapHandlesMirroredCopies() {
|
||||
let store = ConversationStore()
|
||||
let stable = makeDirectConversationID("stable")
|
||||
let ephemeral = makeDirectConversationID("ephemeral")
|
||||
|
||||
// Step 2's keying mirrors one private message into the stable-key
|
||||
// AND ephemeral-peer conversations (distinct copies here to prove
|
||||
// per-conversation bookkeeping).
|
||||
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: stable)
|
||||
store.upsertByID(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent), in: ephemeral)
|
||||
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable, ephemeral])
|
||||
|
||||
// An ID-only delivery update reaches BOTH copies.
|
||||
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||
for id in [stable, ephemeral] {
|
||||
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
|
||||
Issue.record("expected .delivered in \(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Removing one copy keeps the other resolvable.
|
||||
store.removeMessage(withID: "dm-1", from: ephemeral)
|
||||
#expect(store.conversationIDs(forMessageID: "dm-1") == [stable])
|
||||
}
|
||||
|
||||
@Test("ID-only setDeliveryStatus enforces no-downgrade and unknown IDs")
|
||||
@MainActor
|
||||
func idOnlyDeliveryStatusRules() {
|
||||
let store = ConversationStore()
|
||||
let direct = makeDirectConversationID("aa")
|
||||
store.append(
|
||||
makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .read(by: "bob", at: Date())),
|
||||
to: direct
|
||||
)
|
||||
|
||||
// Unknown message.
|
||||
#expect(!store.setDeliveryStatus(.sent, forMessageID: "missing"))
|
||||
#expect(store.deliveryStatus(forMessageID: "missing") == nil)
|
||||
|
||||
// Downgrade from .read is refused; status is readable by ID alone.
|
||||
#expect(!store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||
guard case .read = store.deliveryStatus(forMessageID: "dm-1") else {
|
||||
Issue.record("expected .read to survive the downgrade attempt")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ID map follows migration between conversations")
|
||||
@MainActor
|
||||
func messageIDMapFollowsMigration() {
|
||||
let store = ConversationStore()
|
||||
let source = makeDirectConversationID("ephemeral")
|
||||
let destination = makeDirectConversationID("stable")
|
||||
store.append(makeMessage(id: "dm-1", timestamp: 1, isPrivate: true), to: source)
|
||||
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: source)
|
||||
// Already present in the destination: migration dedups, and the map
|
||||
// must not retain a stale source membership.
|
||||
store.append(makeMessage(id: "dm-2", timestamp: 2, isPrivate: true), to: destination)
|
||||
|
||||
store.migrateConversation(from: source, to: destination)
|
||||
|
||||
#expect(store.conversationIDs(forMessageID: "dm-1") == [destination])
|
||||
#expect(store.conversationIDs(forMessageID: "dm-2") == [destination])
|
||||
// Delivery updates keep flowing after the handoff.
|
||||
#expect(store.setDeliveryStatus(.sent, forMessageID: "dm-1"))
|
||||
}
|
||||
|
||||
@Test("ID map drops trimmed messages")
|
||||
@MainActor
|
||||
func messageIDMapDropsTrimmedMessages() {
|
||||
let store = ConversationStore()
|
||||
let id = ConversationID.geohash("u4pruyd")
|
||||
let conversation = store.conversation(for: id)
|
||||
store.append(makeMessage(id: "first", timestamp: 1), to: id)
|
||||
for index in 0..<conversation.cap {
|
||||
store.append(makeMessage(id: "filler-\(index)", timestamp: 2 + TimeInterval(index)), to: id)
|
||||
}
|
||||
|
||||
// "first" fell off the cap: the map must forget it.
|
||||
#expect(store.conversationIDs(forMessageID: "first").isEmpty)
|
||||
#expect(!store.setDeliveryStatus(.sent, forMessageID: "first"))
|
||||
// Survivors stay resolvable.
|
||||
#expect(store.conversationIDs(forMessageID: "filler-0") == [id])
|
||||
}
|
||||
|
||||
@Test("ID map is emptied by clear, removeConversation, and clearAll")
|
||||
@MainActor
|
||||
func messageIDMapClearedWithConversations() {
|
||||
let store = ConversationStore()
|
||||
let direct = makeDirectConversationID("aa")
|
||||
store.append(makeMessage(id: "m1", timestamp: 1), to: .mesh)
|
||||
store.append(makeMessage(id: "d1", timestamp: 1, isPrivate: true), to: direct)
|
||||
|
||||
store.clear(.mesh)
|
||||
#expect(store.conversationIDs(forMessageID: "m1").isEmpty)
|
||||
#expect(store.conversationIDs(forMessageID: "d1") == [direct])
|
||||
|
||||
store.removeConversation(direct)
|
||||
#expect(store.conversationIDs(forMessageID: "d1").isEmpty)
|
||||
|
||||
store.append(makeMessage(id: "m2", timestamp: 2), to: .mesh)
|
||||
store.clearAll()
|
||||
#expect(store.conversationIDs(forMessageID: "m2").isEmpty)
|
||||
}
|
||||
|
||||
@Test("shared message instance across conversations stays consistent")
|
||||
@MainActor
|
||||
func sharedInstanceMirroredCopiesStayConsistent() {
|
||||
let store = ConversationStore()
|
||||
let stable = makeDirectConversationID("stable")
|
||||
let ephemeral = makeDirectConversationID("ephemeral")
|
||||
// The production mirroring path upserts the SAME BitchatMessage
|
||||
// instance (reference type) into both conversations.
|
||||
let message = makeMessage(id: "dm-1", timestamp: 1, isPrivate: true, deliveryStatus: .sent)
|
||||
store.upsertByID(message, in: stable)
|
||||
store.upsertByID(message, in: ephemeral)
|
||||
|
||||
#expect(store.setDeliveryStatus(.delivered(to: "bob", at: Date()), forMessageID: "dm-1"))
|
||||
|
||||
for id in [stable, ephemeral] {
|
||||
guard case .delivered = store.conversation(for: id).message(withID: "dm-1")?.deliveryStatus else {
|
||||
Issue.record("expected .delivered in \(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,26 +194,18 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
|
||||
}
|
||||
|
||||
// MARK: - 4a. Delivery location index (incremental path)
|
||||
// MARK: - 4a. Delivery status updates through the coordinator (store path)
|
||||
|
||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` against a warm
|
||||
/// location index: 2000 public + 50x40 private messages, 500 status
|
||||
/// updates per pass. Statuses alternate sent <-> delivered so every call
|
||||
/// performs a real update (never the skip path).
|
||||
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
|
||||
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
||||
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
||||
/// private messages, 500 status updates per pass. Statuses alternate
|
||||
/// sent <-> delivered so every call performs a real update (never the
|
||||
/// skip path).
|
||||
func testDeliveryStatusIncrementalUpdates() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index so the measure block exercises the incremental path.
|
||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
||||
|
||||
// 250 public + 250 private IDs spread across the corpus.
|
||||
var targetIDs: [String] = []
|
||||
for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) }
|
||||
let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id }
|
||||
for (offset, peer) in peerIDs.enumerated() where offset < 25 {
|
||||
guard let chat = context.privateChats[peer] else { continue }
|
||||
for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) }
|
||||
}
|
||||
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
@@ -235,46 +227,38 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
||||
}
|
||||
|
||||
// MARK: - 4b. Delivery location index (rebuild path)
|
||||
// MARK: - 4b. Delivery status updates against the store directly
|
||||
|
||||
/// The expensive path: a non-append insertion (out-of-order arrival at
|
||||
/// the head of the timeline) invalidates the index, so the next status
|
||||
/// update triggers a full rebuild over ~4000 message locations.
|
||||
func testDeliveryStatusIndexRebuild() {
|
||||
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
|
||||
/// scale as 4a, without the coordinator/context wrapping — the store-side
|
||||
/// cost of an ID-only delivery update (map lookup + per-conversation
|
||||
/// ID-index apply + change emission). Replaces the deleted
|
||||
/// `delivery.indexRebuild` benchmark: the positional location index and
|
||||
/// its rebuild path no longer exist; the store's ID indexes are
|
||||
/// maintained inside each mutation, so there is no rebuild to measure.
|
||||
func testDeliveryStatusStoreUpdates() {
|
||||
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
||||
let coordinator = ChatDeliveryCoordinator(context: context)
|
||||
// Warm the index before the first insertion invalidates it.
|
||||
XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent))
|
||||
let store = context.store
|
||||
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
||||
XCTAssertEqual(targetIDs.count, 500)
|
||||
|
||||
// Pre-built head insertions, one consumed per measure pass.
|
||||
let insertions: [BitchatMessage] = (0..<32).map { i in
|
||||
BitchatMessage(
|
||||
id: "insert-\(i)",
|
||||
sender: "alice",
|
||||
content: "out-of-order arrival \(i)",
|
||||
timestamp: Date(timeIntervalSince1970: 1_690_000_000),
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
let probeID = context.messages[1000].id
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var pass = 0
|
||||
var toggle = false
|
||||
var samples: [TimeInterval] = []
|
||||
|
||||
measure {
|
||||
precondition(pass < insertions.count, "add more pre-built insertions")
|
||||
context.messages.insert(insertions[pass], at: 0)
|
||||
pass += 1
|
||||
toggle.toggle()
|
||||
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
||||
let start = Date()
|
||||
let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status)
|
||||
var updated = 0
|
||||
for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) {
|
||||
updated += 1
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertTrue(updated)
|
||||
XCTAssertEqual(updated, targetIDs.count)
|
||||
}
|
||||
|
||||
reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds")
|
||||
reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
||||
}
|
||||
|
||||
// MARK: - 5. Message formatting
|
||||
@@ -638,50 +622,63 @@ private final class PerfNostrContext: ChatNostrContext {
|
||||
|
||||
// MARK: - Mock ChatDeliveryContext
|
||||
|
||||
/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the
|
||||
/// coordinator is a thin mapper onto store intents, so the measured cost is
|
||||
/// the store's ID-map delivery path).
|
||||
@MainActor
|
||||
private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
let store = ConversationStore()
|
||||
var sentReadReceipts: Set<String> = []
|
||||
var isStartupPhase: Bool { false }
|
||||
private var publicIDs: [String] = []
|
||||
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
|
||||
|
||||
func notifyUIChanged() {}
|
||||
func markMessageDelivered(_ messageID: String) {}
|
||||
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
store.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
store.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
func privateMessageIDs() -> Set<String> {
|
||||
store.directMessageIDs()
|
||||
}
|
||||
|
||||
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
||||
let oldCount = sentReadReceipts.count
|
||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||
return oldCount - sentReadReceipts.count
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
|
||||
guard var chat = privateChats[peerID],
|
||||
let index = chat.firstIndex(where: { $0.id == messageID }) else {
|
||||
return false
|
||||
}
|
||||
chat[index].deliveryStatus = status
|
||||
privateChats[peerID] = chat
|
||||
return true
|
||||
}
|
||||
|
||||
/// 2000 public + `peerCount` x `messagesPerPeer` private messages with
|
||||
/// deterministic IDs and timestamps.
|
||||
/// `publicCount` public messages (split evenly between mesh and one
|
||||
/// geohash conversation so the corpus stays under the per-conversation
|
||||
/// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into
|
||||
/// the store with deterministic IDs and timestamps.
|
||||
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
||||
let context = PerfDeliveryContext()
|
||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
context.messages = (0..<publicCount).map { i in
|
||||
BitchatMessage(
|
||||
let geohashID = ConversationID.geohash("u4pruyd")
|
||||
for i in 0..<publicCount {
|
||||
let message = BitchatMessage(
|
||||
id: "pub-\(i)",
|
||||
sender: "peer\(i % 20)",
|
||||
content: "public message number \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
)
|
||||
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
|
||||
context.publicIDs.append(message.id)
|
||||
}
|
||||
for p in 0..<peerCount {
|
||||
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
||||
context.privateChats[peerID] = (0..<messagesPerPeer).map { i in
|
||||
BitchatMessage(
|
||||
var messageIDs: [String] = []
|
||||
for i in 0..<messagesPerPeer {
|
||||
let message = BitchatMessage(
|
||||
id: "dm-\(p)-\(i)",
|
||||
sender: "peer\(p)",
|
||||
content: "private message \(i) for peer \(p)",
|
||||
@@ -691,10 +688,32 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
recipientNickname: "me",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.store.append(message, to: .directPeer(peerID))
|
||||
messageIDs.append(message.id)
|
||||
}
|
||||
context.privateIDsByPeer.append((peerID, messageIDs))
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/// Deterministic update targets spread across the corpus: `publicTargets`
|
||||
/// public IDs plus `privateTargets` private IDs.
|
||||
func makeTargetIDs(publicTargets: Int, privateTargets: Int) -> [String] {
|
||||
var targetIDs: [String] = []
|
||||
let publicStride = max(1, publicIDs.count / publicTargets)
|
||||
for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets {
|
||||
targetIDs.append(publicIDs[i])
|
||||
}
|
||||
var privateCount = 0
|
||||
outer: for (_, messageIDs) in privateIDsByPeer {
|
||||
for i in stride(from: 0, to: messageIDs.count, by: 4) {
|
||||
guard privateCount < privateTargets else { break outer }
|
||||
targetIDs.append(messageIDs[i])
|
||||
privateCount += 1
|
||||
}
|
||||
}
|
||||
return targetIDs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - End-to-end pipeline fixture
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Conversation Store: Single Source of Truth
|
||||
|
||||
**Status:** Steps 1–3 implemented (additive store, private cutover, public
|
||||
cutover; `PublicTimelineStore` deleted). Baselines recorded in
|
||||
**Status:** Steps 1–4 implemented (additive store, private cutover, public
|
||||
cutover, delivery via store; `PublicTimelineStore` and
|
||||
`ChatDeliveryCoordinator.messageLocationIndex` deleted). Baselines recorded in
|
||||
`bitchatTests/Performance/PerformanceBaselineTests.swift` (`pipeline.privateIngest`,
|
||||
`pipeline.publicIngest`, `store.append`).
|
||||
`pipeline.publicIngest`, `store.append`, `delivery.incrementalUpdate`,
|
||||
`delivery.storeUpdate`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user