219 lines
8.2 KiB
Swift
219 lines
8.2 KiB
Swift
// 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<RepositoryChange<HostLibrarySnapshot>>.Continuation] = [:]
|
|
public var didRecordHistoryChanges: ((HistoryMutationSet) -> Void)?
|
|
public var didRecordVaultChanges: ((VaultMutationSet) -> Void)?
|
|
|
|
public init(context: ModelContext) {
|
|
self.context = context
|
|
}
|
|
|
|
public func snapshot() throws -> RepositoryChange<HostLibrarySnapshot> {
|
|
let hidden = Set(
|
|
try context.fetch(
|
|
FetchDescriptor<PersistentPendingDeletion>(
|
|
predicate: #Predicate { $0.entityType == "host" }
|
|
)
|
|
).map(\.entityID)
|
|
)
|
|
let hosts = try context.fetch(FetchDescriptor<PersistentHost>())
|
|
.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<RepositoryChange<HostLibrarySnapshot>> {
|
|
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<PersistentHost>(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<PersistentHost>())
|
|
where host.jumpHostID == hostID {
|
|
host.jumpHostID = nil
|
|
}
|
|
for snippet in try context.fetch(FetchDescriptor<PersistentSnippet>())
|
|
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<PersistentIdentity>())
|
|
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<PersistentHost>(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<PersistentPendingDeletion>(
|
|
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<PersistentPortForwardingRule>(
|
|
predicate: #Predicate { $0.hostID == hostID }
|
|
)
|
|
)
|
|
}
|
|
|
|
private func currentRevision() throws -> Int64 {
|
|
var descriptor = FetchDescriptor<PersistentDatabaseMetadata>(
|
|
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<PersistentDatabaseMetadata>(
|
|
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
|
|
}
|
|
}
|