// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import SwiftData import TermindApplication import TermindDomain import struct TermindDomain.Host @MainActor public final class SwiftDataHostRepository: HostRepository { private let context: ModelContext private var continuations: [UUID: AsyncStream>.Continuation] = [:] public var didRecordHistoryChanges: ((HistoryMutationSet) -> Void)? public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? public init(context: ModelContext) { self.context = context } public func snapshot() throws -> RepositoryChange { let hidden = Set( try context.fetch( FetchDescriptor( predicate: #Predicate { $0.entityType == "host" } ) ).map(\.entityID) ) let hosts = try context.fetch(FetchDescriptor()) .map(\.value) .filter { !hidden.contains($0.id) } let groups = try HostPersistence.groups(in: context) return RepositoryChange( revision: try currentRevision(), value: HostLibrarySnapshot(hosts: hosts, groups: groups), reason: .initial ) } public func changes() -> AsyncStream> { let id = UUID() return AsyncStream { continuation in continuations[id] = continuation if let initial = try? snapshot() { continuation.yield(initial) } continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.continuations[id] = nil } } } } public func refresh() throws { try publishMutation(reason: .refresh) } public func upsertHost(_ host: Host) throws { guard !host.isTailscaleManaged else { throw TailscaleConfigurationError.managedHost } let hostID = host.id var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == hostID }) descriptor.fetchLimit = 1 if let existing = try context.fetch(descriptor).first { existing.apply(host) } else { context.insert(PersistentHost(host)) } try commitMutation() } /// The deletion marker, all durable side effects, and the revision become /// visible in one database commit. A crash can therefore see either the /// live host or a complete deletion saga, never half of each. public func requestHostDeletion(_ hostID: Host.ID) throws { guard try persistentHost(hostID) != nil else { return } if try pendingDeletion(hostID) != nil { return } let ruleIDs = try forwardingRules(for: hostID).map(\.id) let payload = try JSONEncoder().encode( HostDeletionPayload(hostID: hostID, forwardingRuleIDs: ruleIDs) ) let eventID = UUID() let now = Date() context.insert( PersistentPendingDeletion( entityType: "host", entityID: hostID, createdAt: now ) ) for handlerID in HostDeletionOutbox.handlerIDs { context.insert( PersistentOutboxJob( OutboxJob( eventID: eventID, handlerID: handlerID, eventType: HostDeletionOutbox.eventType, payload: payload, createdAt: now, nextAttemptAt: now ) ) ) } try commitMutation() } public func clearReferencesForDeletedHost(_ hostID: Host.ID) throws { for host in try context.fetch(FetchDescriptor()) where host.jumpHostID == hostID { host.jumpHostID = nil } for snippet in try context.fetch(FetchDescriptor()) where snippet.targetHostIDs.contains(hostID) { snippet.targetHostIDs.removeAll { $0 == hostID } } for rule in try forwardingRules(for: hostID) { context.delete(rule) } try HistorySyncJournal(context: context).bootstrapIfNeeded() try context.delete( model: PersistentConnectionHistoryEntry.self, where: #Predicate { $0.hostID == hostID } ) try commitMutation(recordHistory: true) } public func finalizeHostDeletion(_ hostID: Host.ID) throws { for identity in try context.fetch(FetchDescriptor()) where identity.ownerHostID == hostID { context.delete(identity) } if let host = try persistentHost(hostID) { context.delete(host) } if let marker = try pendingDeletion(hostID) { context.delete(marker) } try commitMutation() } private func publishMutation(reason: RepositoryChangeReason) throws { let revision = try advanceRevision() let current = try snapshot() let change = RepositoryChange( revision: revision, value: current.value, reason: reason ) for continuation in continuations.values { continuation.yield(change) } } private func commitMutation(recordHistory: Bool = false) throws { do { let revision = try advanceRevisionWithoutSaving() let vaultMutations = try HostPersistence(context: context).recordExternalMutation() // Only Host deletion owns a history mutation. Configuration edits must // not bootstrap, reconcile or enqueue unrelated event/device records. let historyMutations = recordHistory ? try HistorySyncJournal(context: context).recordCurrentSnapshot() : .empty try context.save() if !historyMutations.isEmpty { didRecordHistoryChanges?(historyMutations) } if !vaultMutations.isEmpty { didRecordVaultChanges?(vaultMutations) } try publishCommittedMutation(revision: revision) } catch { context.rollback() throw error } } private func publishCommittedMutation(revision: Int64) throws { let current = try snapshot() let change = RepositoryChange( revision: revision, value: current.value, reason: .mutation ) for continuation in continuations.values { continuation.yield(change) } } private func persistentHost(_ id: Host.ID) throws -> PersistentHost? { var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first } private func pendingDeletion(_ hostID: Host.ID) throws -> PersistentPendingDeletion? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.entityType == "host" && $0.entityID == hostID } ) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first } private func forwardingRules( for hostID: Host.ID ) throws -> [PersistentPortForwardingRule] { try context.fetch( FetchDescriptor( predicate: #Predicate { $0.hostID == hostID } ) ) } private func currentRevision() throws -> Int64 { var descriptor = FetchDescriptor( predicate: #Predicate { $0.key == "repository" } ) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first?.revision ?? 0 } private func advanceRevision() throws -> Int64 { let revision = try advanceRevisionWithoutSaving() try context.save() return revision } private func advanceRevisionWithoutSaving() throws -> Int64 { var descriptor = FetchDescriptor( predicate: #Predicate { $0.key == "repository" } ) descriptor.fetchLimit = 1 let metadata: PersistentDatabaseMetadata if let existing = try context.fetch(descriptor).first { metadata = existing } else { metadata = PersistentDatabaseMetadata() context.insert(metadata) } metadata.revision &+= 1 return metadata.revision } }