Files
Termind/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift
T

575 lines
22 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated enum VaultSyncPersistenceError: LocalizedError {
case invalidEnvelope(String)
case unsupportedFormat(Int)
public var errorDescription: String? {
switch self {
case .invalidEnvelope(let recordName):
String(localized: "The iCloud Vault record \(recordName) is invalid.")
case .unsupportedFormat(let version):
String(localized: "This Vault record requires sync format \(version).")
}
}
}
public nonisolated struct VaultRemoteMerge: Sendable {
public var accepted: [VaultEnvelope] = []
public var needsUpload: Set<VaultRecordKey> = []
}
public nonisolated struct VaultOutgoingEntry: Sendable {
public var envelope: VaultEnvelope
public var systemFieldsData: Data?
public init(envelope: VaultEnvelope, systemFieldsData: Data? = nil) {
self.envelope = envelope
self.systemFieldsData = systemFieldsData
}
}
extension VaultSnapshot {
public nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] {
var result: [VaultRecordKey: VaultRecordPayload] = [:]
for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) }
for var value in groups {
value.isExpanded = true
result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value)
}
for value in identities { result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value) }
for value in keys {
result[VaultRecordKey(kind: .sshKey, id: value.id)] = .sshKey(value)
}
for value in knownHosts {
result[VaultRecordKey(kind: .knownHost, id: value.id)] = .knownHost(value)
}
for value in snippets {
result[VaultRecordKey(kind: .snippet, id: value.id)] = .snippet(value)
}
for var value in snippetPackages {
value.isExpanded = true
result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value)
}
for value in portForwardings {
result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value)
}
for value in workspaceTemplates {
result[VaultRecordKey(kind: .workspaceTemplate, id: value.id)] = .workspaceTemplate(value)
}
return result
}
public nonisolated mutating func apply(_ envelope: VaultEnvelope) throws {
guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else {
throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
if envelope.isDeleted {
remove(envelope.key)
return
}
guard let payload = envelope.payload else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
switch payload {
case .host(let value): upsert(value, in: &hosts)
case .hostGroup(let value): upsert(value, in: &groups)
case .identity(let value): upsert(value, in: &identities)
case .sshKey(let value): upsert(value, in: &keys)
case .knownHost(let value): upsert(value, in: &knownHosts)
case .snippet(let value): upsert(value, in: &snippets)
case .snippetPackage(let value): upsert(value, in: &snippetPackages)
case .portForwarding(let value): upsert(value, in: &portForwardings)
case .workspaceTemplate(let value): upsert(value, in: &workspaceTemplates)
case .connectionHistory:
// Connection history is persisted independently by HostPersistence
// so HostLibrary snapshots cannot accidentally replace it.
break
case .device: break
}
}
private nonisolated mutating func remove(_ key: VaultRecordKey) {
switch key.kind {
case .host: hosts.removeAll { $0.id == key.id }
case .hostGroup: groups.removeAll { $0.id == key.id }
case .identity: identities.removeAll { $0.id == key.id }
case .sshKey: keys.removeAll { $0.id == key.id }
case .knownHost: knownHosts.removeAll { $0.id == key.id }
case .snippet: snippets.removeAll { $0.id == key.id }
case .snippetPackage: snippetPackages.removeAll { $0.id == key.id }
case .portForwarding: portForwardings.removeAll { $0.id == key.id }
case .workspaceTemplate: workspaceTemplates.removeAll { $0.id == key.id }
case .connectionHistory: break
case .device: break
}
}
private nonisolated func upsert<Value: Identifiable>(
_ value: Value, in values: inout [Value]
) where Value.ID == UUID {
if let index = values.firstIndex(where: { $0.id == value.id }) {
values[index] = value
} else {
values.append(value)
}
}
}
extension VaultRecordPayload {
fileprivate var withoutDeviceLocalMetadata: Self {
switch self {
case .hostGroup(var value):
value.isExpanded = true
return .hostGroup(value)
case .snippetPackage(var value):
value.isExpanded = true
return .snippetPackage(value)
default:
return self
}
}
}
extension VaultEnvelope {
fileprivate var withoutDeviceLocalMetadata: Self {
var copy = self
copy.payload = payload?.withoutDeviceLocalMetadata
return copy
}
}
@MainActor
public final class VaultSyncJournal {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func state() throws -> PersistentVaultSyncState {
var descriptor = FetchDescriptor<PersistentVaultSyncState>()
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first { return existing }
let state = PersistentVaultSyncState()
context.insert(state)
return state
}
@discardableResult
public func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet {
let state = try state()
guard !state.bootstrapCompleted else { return .empty }
let existing = try entriesByName()
var changed = Set<VaultRecordKey>()
for payload in snapshot.recordPayloads.values {
let key = payload.key
guard existing[key.recordName] == nil else { continue }
let envelope = VaultEnvelope(
key: key, version: .baseline(writerID: state.installationID), payload: payload
)
context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true))
changed.insert(key)
}
state.bootstrapCompleted = true
return VaultMutationSet(keys: changed)
}
public func recordLocalChanges(
from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot,
touching touchedKeys: Set<VaultRecordKey> = [],
now: Date = .now
) throws -> VaultMutationSet {
let state = try state()
let old = oldSnapshot.recordPayloads
let new = newSnapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in new where old[key] != payload || touchedKeys.contains(key) {
let row = entries[key.recordName]
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for key in old.keys where new[key] == nil {
let row = entries[key.recordName]
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
isDeleted: true
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
return VaultMutationSet(keys: changed)
}
public func pendingEnvelopes() throws -> [VaultEnvelope] {
try context.fetch(
FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.needsUpload }
)
).compactMap(\.envelope).map(\.withoutDeviceLocalMetadata)
}
public func uploadablePendingRecordNames(in scope: CloudSyncScope) throws -> [String] {
let blocked = try CloudSyncSafetyStore(context: context, scope: scope).blockedRecordNames()
return try pendingEnvelopes().map(\.key.recordName).filter { !blocked.contains($0) }
}
public func importLegacyQuarantine(into scope: CloudSyncScope) throws {
let safety = CloudSyncSafetyStore(context: context, scope: scope)
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncQuarantine>()) {
if try !safety.isUploadBlocked(recordName: row.recordName) {
try safety.quarantine(recordName: row.recordName, payloadData: row.payloadData, reason: row.reason)
}
context.delete(row)
}
}
public func outgoingEntry(for key: VaultRecordKey, scope: CloudSyncScope? = nil) throws -> VaultOutgoingEntry? {
if let scope,
try CloudSyncSafetyStore(context: context, scope: scope).isUploadBlocked(recordName: key.recordName)
{
return nil
}
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else {
return nil
}
return VaultOutgoingEntry(
envelope: envelope.withoutDeviceLocalMetadata,
systemFieldsData: row.systemFieldsData
)
}
public func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? {
try entry(recordName: key.recordName)?.envelope?.withoutDeviceLocalMetadata
}
public func modificationDates() throws -> [VaultRecordKey: Date] {
var result: [VaultRecordKey: Date] = [:]
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
guard let envelope = row.envelope, !envelope.isDeleted else { continue }
result[envelope.key] = envelope.version.modifiedAt
}
return result
}
public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge {
var result = VaultRemoteMerge()
let entries = try entriesByName()
for rawEnvelope in remote {
let envelope = rawEnvelope.withoutDeviceLocalMetadata
guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else {
throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
guard
let local = entries[envelope.key.recordName]?.envelope?.withoutDeviceLocalMetadata
else {
result.accepted.append(envelope)
continue
}
if local.version < envelope.version {
result.accepted.append(envelope)
} else if envelope.version < local.version {
result.needsUpload.insert(local.key)
} else if local != envelope {
let localData = VaultSyncCodec.encode(local)
let remoteData = VaultSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
result.accepted.append(envelope)
} else {
result.needsUpload.insert(local.key)
}
}
}
return result
}
public func acceptRemote(_ envelopes: [VaultEnvelope]) throws {
var entries = try entriesByName()
for envelope in envelopes {
try store(envelope.withoutDeviceLocalMetadata, needsUpload: false, in: &entries)
}
}
public func markNeedsUpload(_ keys: Set<VaultRecordKey>) throws {
let entries = try entriesByName()
for key in keys { entries[key.recordName]?.needsUpload = true }
}
public func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws {
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope,
envelope.version == version
else {
return
}
row.apply(envelope.withoutDeviceLocalMetadata, needsUpload: false)
}
public func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws {
try entry(recordName: key.recordName)?.systemFieldsData = data
}
public func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentVaultSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first {
row.payloadData = payloadData
row.reason = reason
row.receivedAt = .now
} else {
context.insert(
PersistentVaultSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason
))
}
}
public func clearQuarantine(recordName: String) throws {
try context.delete(
model: PersistentVaultSyncQuarantine.self,
where: #Predicate { $0.recordName == recordName }
)
}
public func firstQuarantineReason() throws -> String? {
var descriptor = FetchDescriptor<PersistentVaultSyncQuarantine>(
sortBy: [SortDescriptor(\.receivedAt, order: .reverse)]
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.reason
}
public func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws
-> VaultMutationSet
{
let state = try state()
let current = snapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in current {
let row = entries[key.recordName]
guard
row?.envelope?.withoutDeviceLocalMetadata.payload != payload
|| row?.envelope?.isDeleted != false
else {
continue
}
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for row in entries.values {
guard let envelope = row.envelope, !envelope.isDeleted,
current[envelope.key] == nil
else { continue }
let tombstone = VaultEnvelope(
key: envelope.key,
version: .next(after: envelope.version, writerID: state.installationID, now: now),
isDeleted: true
)
row.apply(tombstone, needsUpload: true)
changed.insert(envelope.key)
}
return VaultMutationSet(keys: changed)
}
/// Reconciles the independently owned connection timeline with the Vault
/// journal. Calling this on every launch also bootstraps histories created by
/// clients whose original Vault bootstrap predates this record kind.
public func recordCurrentConnectionHistory(
_ values: [ConnectionHistoryEntry],
now: Date = .now
) throws -> VaultMutationSet {
let state = try state()
let current = Dictionary(
uniqueKeysWithValues: values.map {
(VaultRecordKey(kind: .connectionHistory, id: $0.id), VaultRecordPayload.connectionHistory($0))
})
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for (key, payload) in current {
let row = entries[key.recordName]
guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { continue }
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for row in entries.values {
guard let envelope = row.envelope, envelope.key.kind == .connectionHistory,
!envelope.isDeleted, current[envelope.key] == nil
else { continue }
let tombstone = VaultEnvelope(
key: envelope.key,
version: .next(after: envelope.version, writerID: state.installationID, now: now),
isDeleted: true
)
row.apply(tombstone, needsUpload: true)
changed.insert(envelope.key)
}
return VaultMutationSet(keys: changed)
}
/// Removes connection events that exceed local retention before launch-time
/// history bootstrap. Records that have never reached CloudKit can disappear
/// from the journal; records with CloudKit identity require ordinary logical
/// deletion so other devices converge on the same retained timeline.
public func discardOverflowConnectionHistory(
_ values: [ConnectionHistoryEntry],
now: Date = .now
) throws -> VaultMutationSet {
guard !values.isEmpty else { return .empty }
let state = try state()
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
var discardedPendingChanges = false
for value in values {
let key = VaultRecordKey(kind: .connectionHistory, id: value.id)
guard let row = entries[key.recordName], let envelope = row.envelope else { continue }
if row.systemFieldsData == nil {
context.delete(row)
entries[key.recordName] = nil
discardedPendingChanges = true
continue
}
guard !envelope.isDeleted else { continue }
let tombstone = VaultEnvelope(
key: key,
version: .next(after: envelope.version, writerID: state.installationID, now: now),
isDeleted: true
)
row.apply(tombstone, needsUpload: true)
changed.insert(key)
}
// A serialized engine may still contain names removed above. A fresh
// engine restores only the journal's surviving pending changes.
if discardedPendingChanges { state.engineStateData = nil }
return VaultMutationSet(keys: changed)
}
/// Reconciles the shared installation directory without deleting records
/// for devices that are merely absent from one client's local store.
public func recordCurrentDevices(
_ values: [DeviceRecord], now: Date = .now
) throws -> VaultMutationSet {
let state = try state()
var entries = try entriesByName()
var changed = Set<VaultRecordKey>()
for device in values {
let key = VaultRecordKey(kind: .device, id: device.id)
let payload = VaultRecordPayload.device(device)
let row = entries[key.recordName]
guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { continue }
let envelope = VaultEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
return VaultMutationSet(keys: changed)
}
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
guard let envelope = row.envelope else { continue }
if envelope.isDeleted {
context.delete(row)
} else {
row.needsUpload = true
row.systemFieldsData = nil
}
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
public func markAllEntriesNeedsUploadAfterZoneReset() throws {
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
row.needsUpload = true
row.systemFieldsData = nil
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
/// CloudKit record system fields include environment-specific change tags.
/// Keep every local envelope, but discard those tags and the serialized
/// engine state before moving between Development and Production.
public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws {
try markAllEntriesNeedsUploadAfterZoneReset()
}
private func entry(recordName: String) throws -> PersistentVaultSyncEntry? {
var descriptor = FetchDescriptor<PersistentVaultSyncEntry>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func store(
_ envelope: VaultEnvelope, needsUpload: Bool,
in entries: inout [String: PersistentVaultSyncEntry]
) throws {
if let row = entries[envelope.key.recordName] {
row.apply(envelope, needsUpload: needsUpload)
} else {
let row = try PersistentVaultSyncEntry(
envelope: envelope, needsUpload: needsUpload
)
context.insert(row)
entries[envelope.key.recordName] = row
}
}
private func entriesByName() throws -> [String: PersistentVaultSyncEntry] {
Dictionary(
try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()).map {
($0.recordName, $0)
},
uniquingKeysWith: { first, duplicate in
if let firstVersion = first.envelope?.version,
let duplicateVersion = duplicate.envelope?.version,
firstVersion < duplicateVersion
{
return duplicate
}
return first
}
)
}
}