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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:19:11 +02:00

147 lines
5.2 KiB
Swift

//
// LegacyConversationStoreBridge.swift
// bitchat
//
// Migration step 2 adapter (DELETE IN STEP 5, see
// docs/CONVERSATION-STORE-DESIGN.md §4).
//
// The new `ConversationStore` is the single writer for private (direct)
// message state; the feature models (`PrivateInboxModel`,
// `PrivateConversationModel`, `ConversationUIModel`, `PeerListModel`) still
// read the replace-based `LegacyConversationStore` until step 5. This bridge
// keeps Legacy fed from the new store's `changes` subject: per-message
// changes mark the affected conversation dirty and a `Task.yield`-coalesced
// flush mirrors only the dirty conversations — a burst of N appends costs
// ONE Legacy replace (like the old debounced sync) without the full-dict
// pass. Structural changes (migration/removal) resynchronize immediately.
// Legacy is therefore eventually consistent within one run-loop tick — the
// same visibility the old `$privateChats` sink provided — while the new
// store stays synchronously authoritative.
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Combine
import Foundation
@MainActor
final class LegacyConversationStoreBridge {
private let store: ConversationStore
private let legacyStore: LegacyConversationStore
private let identityResolver: IdentityResolver
private var cancellable: AnyCancellable?
private var dirtyConversations: Set<ConversationID> = []
private var pendingFlushTask: Task<Void, Never>?
init(
store: ConversationStore,
legacyStore: LegacyConversationStore,
identityResolver: IdentityResolver
) {
self.store = store
self.legacyStore = legacyStore
self.identityResolver = identityResolver
cancellable = store.changes.sink { [weak self] change in
self?.apply(change)
}
}
/// Full resynchronization of every direct conversation into Legacy.
///
/// Needed when `IdentityResolver` learns new peer associations (the
/// canonical handle for an existing conversation can change, re-keying
/// it in Legacy) and after structural store changes. This is the old
/// `synchronizePrivateChats` full pass — acceptable because it only runs
/// on peer-list changes and rare migrations, never per message.
func resynchronizeAll() {
// The full pass covers every conversation; pending per-conversation
// work is redundant.
dirtyConversations.removeAll()
legacyStore.synchronizePrivateChats(
store.directMessagesByRoutingPeerID(),
unreadPeerIDs: store.unreadDirectRoutingPeerIDs(),
identityResolver: identityResolver
)
}
}
private extension LegacyConversationStoreBridge {
func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
markDirty(id)
case .unreadChanged(let id, let isUnread):
guard case .direct(let handle) = id else { return }
if isUnread {
legacyStore.markUnread(peerID: handle.routingPeerID, identityResolver: identityResolver)
} else {
legacyStore.markRead(peerID: handle.routingPeerID, identityResolver: identityResolver)
}
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
resynchronizeAll()
case .removed(let id):
guard isDirect(id) else { return }
resynchronizeAll()
}
}
func markDirty(_ id: ConversationID) {
guard isDirect(id) else { return }
dirtyConversations.insert(id)
scheduleFlush()
}
/// One pending flush at a time, exactly like the old
/// `schedulePrivateConversationStoreSynchronization` debounce: a
/// synchronous burst of mutations coalesces into a single flush on the
/// next main-actor turn.
func scheduleFlush() {
guard pendingFlushTask == nil else { return }
pendingFlushTask = Task { @MainActor [weak self] in
await Task.yield()
guard let self else { return }
self.pendingFlushTask = nil
self.flushDirtyConversations()
}
}
func flushDirtyConversations() {
guard !dirtyConversations.isEmpty else { return }
let dirty = dirtyConversations
dirtyConversations.removeAll()
for id in dirty {
mirrorConversation(id)
}
}
func mirrorConversation(_ id: ConversationID) {
guard case .direct(let handle) = id,
let conversation = store.conversationsByID[id] else {
// Removed while dirty; the removal already resynchronized.
return
}
legacyStore.replaceDirectMessages(
conversation.messages,
for: handle.routingPeerID,
identityResolver: identityResolver
)
}
func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}