feat: delete old history and devices

This commit is contained in:
Kaede Akino committed 2026-09-24 20:14:16 +08:00
1 parent 3250aa55fa
commit 60d8822df9
24 files changed
+2231 -210

No files matched your search

+39 -2
View File
@@ -68,7 +68,7 @@ struct CloudSyncLifecycleTests {
.sentRecordZoneChanges(.init(savedRecords: [], failedRecordSaves: [])), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
fixture.driver.handle(
.fetchedDatabaseChanges(deletedZoneIDs: [fixture.driver.zoneID]), engine: activeEngine,
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, .deleted)]), engine: activeEngine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(receivedRecordNames == ["remote"])
@@ -85,6 +85,38 @@ struct CloudSyncLifecycleTests {
#expect(receivedRecordNames == ["remote"])
}
@Test(arguments: [CloudSyncZoneDeletionReason.purged, .encryptedDataReset])
func destructiveZoneDeletionPurgesAndRetiresWithoutRecreating(
reason: CloudSyncZoneDeletionReason
) async throws {
let fixture = LifecycleFixture()
fixture.driver.start()
await fixture.driver.operation?.value
let engine = fixture.network.nextEngine
let receive: @MainActor @Sendable ([CKRecord], [CKRecord.ID]) throws -> Void = { _, _ in }
let sent: @MainActor @Sendable (CloudSyncSentChanges) throws -> Void = { _ in }
fixture.driver.handle(
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, reason)]), engine: engine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(fixture.journal.purgeCount == 1)
#expect(fixture.driver.engine == nil)
#expect(!fixture.journal.uploadsAuthorized)
#expect(fixture.network.nextEngine.recreatedZones.isEmpty)
#expect(fixture.journal.resets == ["environment"])
fixture.driver.handle(
.fetchedDatabaseChanges(deletions: [(fixture.driver.zoneID, reason)]), engine: engine,
receiveRemoteRecords: receive, handleSentChanges: sent)
#expect(fixture.journal.purgeCount == 1)
fixture.journal.pending = ["new-explicit-write"]
fixture.driver.noteLocalChanges(fixture.journal.pending)
await fixture.driver.operation?.value
#expect(fixture.network.engineCount == 2)
}
@Test
func sharedEventDispatcherFailsClosedAndFetchFailureKeepsUploadsFenced() async {
let fixture = LifecycleFixture()
@@ -570,7 +602,11 @@ private struct LifecycleFixture {
requiresNewerClient: { _ in false }),
resetForNewAccount: { journal.reset("account") },
resetForZone: { journal.reset("zone") },
resetForEnvironment: { journal.reset("environment") }),
resetForEnvironment: { journal.reset("environment") },
purge: { _ in
journal.purgeCount += 1
journal.pending.removeAll()
}),
transport: network.transport, zoneID: .init(zoneName: "LifecycleTests"),
containerIdentifier: "LifecycleTests",
defaults: UserDefaults(suiteName: "CloudSyncLifecycleTests.\(UUID())")!,
@@ -594,6 +630,7 @@ private final class LifecycleJournal {
var resets: [String] = []
var uploadsAuthorized = false
var migrationCount = 0
var purgeCount = 0
func reset(_ reason: String) {
resets.append(reason)
value.engineStateData = nil
File diff suppressed because it is too large. Load diff
+191 -31
View File
@@ -28,13 +28,19 @@ private struct SyncSafetyAdapter {
var sameVersionLosingRecord: (CKRecord.ID) throws -> CKRecord
var pendingNames: () throws -> Set<String>
var markTombstone: () throws -> Void
var recreateFirst: () throws -> Bool
var editFirstAgain: () throws -> Bool
var projectionIsPopulated: () throws -> Bool
var projectionIsCleared: () throws -> Bool
var failingMerge: (CloudSyncSafetyStore) throws -> Void
var remoteApplyCount: () -> Int
var remoteApplyObservedCleared: () -> Bool
}
@MainActor
private final class RemoteApplyCounter {
var value = 0
var observedCleared = false
}
/// Real journals, coordinators, record coding and batch providers; only CloudKit transport is fake.
@@ -60,10 +66,19 @@ final class CloudSyncSafetyFixture {
var sameVersionLosingRecord: (CKRecord.ID) throws -> CKRecord { adapter.sameVersionLosingRecord }
var pendingNames: () throws -> Set<String> { adapter.pendingNames }
var markTombstone: () throws -> Void { adapter.markTombstone }
var recreateFirst: () throws -> Bool { adapter.recreateFirst }
var editFirstAgain: () throws -> Bool { adapter.editFirstAgain }
var projectionIsPopulated: () throws -> Bool { adapter.projectionIsPopulated }
var projectionIsCleared: () throws -> Bool { adapter.projectionIsCleared }
var failingMerge: (CloudSyncSafetyStore) throws -> Void { adapter.failingMerge }
var remoteApplyCount: Int { adapter.remoteApplyCount() }
var remoteApplyObservedCleared: Bool { adapter.remoteApplyObservedCleared() }
init(category: SyncSafetyCategory, url: URL? = nil, environment: String = "Test") throws {
init(
category: SyncSafetyCategory, url: URL? = nil, environment: String = "Test", seed: Bool = true,
assetCacheRoot: URL? = nil,
removeAssetDirectory: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }
) throws {
container = try TermindPersistence.makeContainer(inMemory: url == nil, url: url)
defaults = UserDefaults(suiteName: defaultsName)!
for key in [
@@ -75,13 +90,19 @@ final class CloudSyncSafetyFixture {
case .history, .device:
adapter = try Self.history(
context, defaults: defaults, network: network,
environment: environment, devices: category == .device)
environment: environment, devices: category == .device, seed: seed)
case .vault:
adapter = try Self.vault(context, defaults: defaults, network: network, environment: environment)
adapter = try Self.vault(
context, defaults: defaults, network: network, environment: environment, seed: seed,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory)
case .settings:
adapter = try Self.settings(context, defaults: defaults, network: network, environment: environment)
adapter = try Self.settings(
context, defaults: defaults, network: network, environment: environment, seed: seed,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory)
case .metrics:
adapter = try Self.metrics(context, defaults: defaults, network: network, environment: environment)
adapter = try Self.metrics(
context, defaults: defaults, network: network, environment: environment, seed: seed,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory)
}
try context.save()
}
@@ -93,17 +114,29 @@ final class CloudSyncSafetyFixture {
}
private static func vault(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String, seed: Bool,
assetCacheRoot: URL?, removeAssetDirectory: @escaping (URL) throws -> Void
) throws -> SyncSafetyAdapter {
let journal = VaultSyncJournal(context: context)
let payloads = ["First", "Second"].map { VaultRecordPayload.hostGroup(HostGroup(name: $0)) }
let payloads = ["First", "Second"].enumerated().map { index, name in
VaultRecordPayload.hostGroup(
HostGroup(
id: UUID(uuidString: String(format: "10000000-0000-0000-0000-%012d", index + 1))!,
name: name))
}
let envelopes = payloads.map { VaultEnvelope(key: $0.key, version: version, payload: $0) }
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
if seed {
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
}
let coordinator = VaultSyncCoordinator(
context: context, defaults: defaults, environmentIdentifier: environment,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory,
makeTransport: { _, _ in network.transport })
let counter = RemoteApplyCounter()
coordinator.didApplyRemote = { _, _ in counter.value += 1 }
coordinator.didApplyRemote = { snapshot, _ in
counter.value += 1
counter.observedCleared = snapshot.groups.isEmpty
}
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
@@ -146,6 +179,28 @@ final class CloudSyncSafetyFixture {
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
recreateFirst: {
guard case .hostGroup(var group) = payloads[0] else { return false }
group.name = "Recreated group"
_ = try HostPersistence(context: context).save(VaultSnapshot(groups: [group]))
return true
},
editFirstAgain: {
guard case .hostGroup(var group) = payloads[0] else { return false }
group.name = "Edited group"
_ = try HostPersistence(context: context).save(VaultSnapshot(groups: [group]))
return true
},
projectionIsPopulated: {
let ids = Set(try HostPersistence(context: context).load().groups.map(\.id))
return ids
== Set(
payloads.compactMap { payload in
guard case .hostGroup(let group) = payload else { return nil }
return group.id
})
},
projectionIsCleared: { try HostPersistence(context: context).load().groups.isEmpty },
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
@@ -156,31 +211,48 @@ final class CloudSyncSafetyFixture {
throw CocoaError(.fileWriteUnknown)
})
},
remoteApplyCount: { counter.value })
remoteApplyCount: { counter.value },
remoteApplyObservedCleared: { counter.observedCleared })
}
private static func history(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork,
environment: String, devices: Bool
environment: String, devices: Bool, seed: Bool
) throws -> SyncSafetyAdapter {
let persistence = HistorySyncPersistence(context: context)
try persistence.prepare()
let envelopes = (0..<2).map { index in
let id = UUID(uuidString: String(format: "20000000-0000-0000-0000-%012d", index + 1))!
let payload: HistoryRecordPayload =
devices
? .device(DeviceRecord(id: UUID(), displayName: "Device \(index)"))
: .connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .ssh))
? .device(DeviceRecord(id: id, displayName: "Device \(index)"))
: .connectionHistory(
ConnectionHistoryEntry(
id: id,
hostID: UUID(uuidString: "21000000-0000-0000-0000-000000000001")!,
kind: .ssh))
return HistoryEnvelope(key: payload.key, version: version, payload: payload)
}
_ = try persistence.applyRemote(envelopes)
try persistence.journal.markNeedsUpload(Set(envelopes.map(\.key)))
if seed {
_ = try persistence.applyRemote(envelopes)
try persistence.journal.markNeedsUpload(Set(envelopes.map(\.key)))
}
let journal = persistence.journal
let coordinator = HistoryRecordSyncCoordinator(
persistence: persistence, kind: devices ? .device : .connectionHistory,
defaults: defaults, environmentIdentifier: environment,
makeTransport: { _, _ in network.transport })
let counter = RemoteApplyCounter()
coordinator.didApplyRemote = { _ in counter.value += 1 }
coordinator.didApplyRemote = { _ in
counter.value += 1
let count: Int
if devices {
count = (try? context.fetchCount(FetchDescriptor<PersistentDeviceRecord>())) ?? -1
} else {
count = (try? context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>())) ?? -1
}
counter.observedCleared = count == 0
}
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
@@ -233,6 +305,30 @@ final class CloudSyncSafetyFixture {
try journal.markNeedsUpload([envelope.key])
try context.save()
},
recreateFirst: {
guard devices, case .device(var device) = envelopes[0].payload else { return false }
device.displayName = "Recreated device"
try SwiftDataDeviceDirectoryRepository(context: context).upsert(device)
return true
},
editFirstAgain: {
guard devices, case .device(var device) = envelopes[0].payload else { return false }
device.displayName = "Edited device"
try SwiftDataDeviceDirectoryRepository(context: context).upsert(device)
return true
},
projectionIsPopulated: {
if devices {
return try context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == envelopes.count
}
return try context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == envelopes.count
},
projectionIsCleared: {
if devices {
return try context.fetchCount(FetchDescriptor<PersistentDeviceRecord>()) == 0
}
return try context.fetchCount(FetchDescriptor<PersistentConnectionHistoryEntry>()) == 0
},
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
@@ -243,23 +339,35 @@ final class CloudSyncSafetyFixture {
throw CocoaError(.fileWriteUnknown)
})
},
remoteApplyCount: { counter.value })
remoteApplyCount: { counter.value },
remoteApplyObservedCleared: { counter.observedCleared })
}
private static func settings(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String, seed: Bool,
assetCacheRoot: URL?, removeAssetDirectory: @escaping (URL) throws -> Void
) throws -> SyncSafetyAdapter {
let persistence = SettingsSyncPersistence(context: context, defaults: defaults)
let journal = persistence.journal
let payloads: [SettingsRecordPayload] = [.sftp(SFTPPreferences()), .ssh(SSHPreferences())]
let remoteWebSearch = AIWebSearchPreferences(isEnabled: false, prefersBuiltIn: true)
let remoteTerminal = TerminalPreferences(darkTheme: "Remote Dark", copyOnSelect: true)
let payloads: [SettingsRecordPayload] = [.webSearch(remoteWebSearch), .terminal(remoteTerminal)]
let envelopes = payloads.map { SettingsEnvelope(key: $0.key, version: version, payload: $0) }
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
if seed {
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
}
let secrets = AISecretStore(context: context, keychain: InMemorySecretStore(), defaults: defaults)
let coordinator = SettingsSyncCoordinator(
persistence: persistence, secrets: secrets, defaults: defaults, environmentIdentifier: environment,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory,
makeTransport: { _, _ in network.transport })
let counter = RemoteApplyCounter()
coordinator.didApplyRemote = { _, _ in counter.value += 1 }
coordinator.didApplyRemote = { snapshot, _ in
counter.value += 1
counter.observedCleared =
snapshot.webSearch == AIWebSearchPreferences()
&& snapshot.terminal == TerminalPreferences()
}
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
@@ -286,7 +394,7 @@ final class CloudSyncSafetyFixture {
throw CocoaError(.fileReadNoSuchFile)
}
var remote = local
remote.payload = .sftp(SFTPPreferences(globalLimit: 1, perHostLimit: 1))
remote.payload = .webSearch(AIWebSearchPreferences(isEnabled: false, prefersBuiltIn: false))
guard try SettingsSyncCodec.encode(remote).lexicographicallyPrecedes(SettingsSyncCodec.encode(local))
else { throw CocoaError(.coderInvalidValue) }
return try SettingsSyncCoordinator.makeRecord(.init(envelope: remote), recordID: id)
@@ -300,6 +408,25 @@ final class CloudSyncSafetyFixture {
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
recreateFirst: {
try persistence.saveWebSearch(
AIWebSearchPreferences(isEnabled: false, prefersBuiltIn: false))
return true
},
editFirstAgain: {
try persistence.saveWebSearch(
AIWebSearchPreferences(isEnabled: true, prefersBuiltIn: false))
return true
},
projectionIsPopulated: {
let snapshot = try persistence.load()
return snapshot.webSearch == remoteWebSearch && snapshot.terminal == remoteTerminal
},
projectionIsCleared: {
let snapshot = try persistence.load()
return snapshot.webSearch == AIWebSearchPreferences()
&& snapshot.terminal == TerminalPreferences()
},
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
@@ -310,25 +437,36 @@ final class CloudSyncSafetyFixture {
throw CocoaError(.fileWriteUnknown)
})
},
remoteApplyCount: { counter.value })
remoteApplyCount: { counter.value },
remoteApplyObservedCleared: { counter.observedCleared })
}
private static func metrics(
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String
_ context: ModelContext, defaults: UserDefaults, network: LifecycleNetwork, environment: String, seed: Bool,
assetCacheRoot: URL?, removeAssetDirectory: @escaping (URL) throws -> Void
) throws -> SyncSafetyAdapter {
let persistence = MetricsSyncPersistence(context: context, defaults: defaults)
let journal = persistence.journal
let envelopes = (0..<2).map { index in
let payload = MetricsRecordPayload.selection(
SyncedMetricsSelection(selection: MetricsSelection(id: UUID()), sortIndex: index))
return MetricsEnvelope(key: payload.key, version: version, payload: payload)
let payloads = (0..<2).map { index in
MetricsRecordPayload.selection(
SyncedMetricsSelection(
selection: MetricsSelection(
id: UUID(uuidString: String(format: "30000000-0000-0000-0000-%012d", index + 1))!),
sortIndex: index))
}
let envelopes = payloads.map { MetricsEnvelope(key: $0.key, version: version, payload: $0) }
if seed {
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
}
for envelope in envelopes { try journal.upsert(envelope, needsUpload: true) }
let coordinator = MetricsSyncCoordinator(
persistence: persistence, defaults: defaults, environmentIdentifier: environment,
assetCacheRoot: assetCacheRoot, removeAssetDirectory: removeAssetDirectory,
makeTransport: { _, _ in network.transport })
let counter = RemoteApplyCounter()
coordinator.didApplyRemote = { _, _ in counter.value += 1 }
coordinator.didApplyRemote = { snapshot, _ in
counter.value += 1
counter.observedCleared = snapshot.selections.isEmpty
}
return SyncSafetyAdapter(
lifecycle: coordinator.lifecycle, recordNames: envelopes.map(\.key.recordName),
receive: { try coordinator.receiveRemoteRecords($0, deletions: $1) },
@@ -370,6 +508,27 @@ final class CloudSyncSafetyFixture {
try journal.upsert(envelope, needsUpload: true)
try context.save()
},
recreateFirst: {
guard case .selection(let selection) = payloads[0] else { return false }
try persistence.save([selection.selection])
return true
},
editFirstAgain: {
guard case .selection(var selection) = payloads[0] else { return false }
selection.selection.enabled = false
try persistence.save([selection.selection])
return true
},
projectionIsPopulated: {
let ids = Set(try persistence.load().map(\.id))
return ids
== Set(
payloads.compactMap { payload in
guard case .selection(let selection) = payload else { return nil }
return selection.selection.id
})
},
projectionIsCleared: { try persistence.load().isEmpty },
failingMerge: { safety in
var envelope = envelopes[0]
envelope.version.counter += 1
@@ -380,7 +539,8 @@ final class CloudSyncSafetyFixture {
throw CocoaError(.fileWriteUnknown)
})
},
remoteApplyCount: { counter.value })
remoteApplyCount: { counter.value },
remoteApplyObservedCleared: { counter.observedCleared })
}
func start() async throws {
@@ -155,25 +155,29 @@ struct CloudSyncSafetyPersistenceTests {
case .history, .device:
let journal = HistorySyncJournal(context: context)
pending = try journal.pendingEnvelopes().map(\.key.recordName)
uploadable = try journal.uploadablePendingRecordNames(in: saved.scope)
uploadable = try CloudSyncRecordStateStore(context: context, scope: saved.scope)
.pendingChanges(journal.cloudRecords()).map(\.recordName)
let key = try #require(HistoryRecordKey(recordName: saved.recordName))
#expect(try journal.outgoingEntry(for: key, scope: saved.scope) == nil)
case .vault:
let journal = VaultSyncJournal(context: context)
pending = try journal.pendingEnvelopes().map(\.key.recordName)
uploadable = try journal.uploadablePendingRecordNames(in: saved.scope)
uploadable = try CloudSyncRecordStateStore(context: context, scope: saved.scope)
.pendingChanges(journal.cloudRecords()).map(\.recordName)
let key = try #require(VaultRecordKey(recordName: saved.recordName))
#expect(try journal.outgoingEntry(for: key, scope: saved.scope) == nil)
case .settings:
let journal = SettingsSyncJournal(context: context)
pending = try journal.pendingEnvelopes().map(\.key.recordName)
uploadable = try journal.uploadablePendingRecordNames(in: saved.scope)
uploadable = try CloudSyncRecordStateStore(context: context, scope: saved.scope)
.pendingChanges(journal.cloudRecords()).map(\.recordName)
let key = try #require(SettingsRecordKey(recordName: saved.recordName))
#expect(try journal.outgoingEntry(for: key, scope: saved.scope) == nil)
case .metrics:
let journal = MetricsSyncJournal(context: context)
pending = try journal.pendingEnvelopes().map(\.key.recordName)
uploadable = try journal.uploadablePendingRecordNames(in: saved.scope)
uploadable = try CloudSyncRecordStateStore(context: context, scope: saved.scope)
.pendingChanges(journal.cloudRecords()).map(\.recordName)
let key = try #require(MetricsRecordKey(recordName: saved.recordName))
#expect(try journal.outgoingEntry(for: key, scope: saved.scope) == nil)
}
@@ -182,6 +186,67 @@ struct CloudSyncSafetyPersistenceTests {
#expect(uploadable.count == 1)
}
@Test
func deletionHandoffSidecarSurvivesDiskReopen() throws {
let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("DeleteHandoff.store")
let recordName = "handoff/sidecar"
let old = VaultVersion(
modifiedAt: Date(timeIntervalSince1970: 100), counter: 1,
writerID: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!)
let rebuilt = VaultVersion(
modifiedAt: Date(timeIntervalSince1970: 200), counter: 2,
writerID: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!)
let delete = CloudSyncMutation(recordName: recordName, operation: .delete, version: old)
do {
let container = try TermindPersistence.makeContainer(url: url)
let state = CloudSyncRecordStateStore(context: container.mainContext, scope: scope)
try state.observed(recordName)
try state.recordDeletionHandoff(delete)
try container.mainContext.save()
}
let reopened = try TermindPersistence.makeContainer(url: url)
let context = reopened.mainContext
let live = CloudSyncJournalRecord(
recordName: recordName, version: rebuilt, isDeleted: false,
needsUpload: true, hasSystemFields: true)
var cleared: [String] = []
let replica = CloudSyncReplicaStore.persistent(
context: context, records: { [live] }, clearSystemFields: { cleared.append($0) })
let missing = try replica.missing(scope, [])
try context.save()
#expect(missing.deleted.isEmpty)
#expect(missing.recreated == [recordName])
#expect(cleared == [recordName])
#expect(
try CloudSyncRecordStateStore(context: context, scope: scope).pendingChanges([live])
== [CloudSyncMutation(recordName: recordName, operation: .save, version: rebuilt)])
}
@Test
func emptyInventoryStillDeletesPreviouslyPresentOrdinaryLocalEditAndTerminalHistoryDelete() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let state = CloudSyncRecordStateStore(context: container.mainContext, scope: scope)
let writer = UUID()
let live = CloudSyncJournalRecord(
recordName: "ordinary-edit", version: .baseline(writerID: writer), isDeleted: false,
needsUpload: true, hasSystemFields: true)
try state.observed(live.mutation.recordName)
#expect(try state.missingRecordNames(seen: [], records: [live]).deleted == [live.mutation.recordName])
let historyDelete = CloudSyncJournalRecord(
recordName: "v1/connectionHistory/00000000-0000-0000-0000-000000000001",
version: .baseline(writerID: writer), isDeleted: true, needsUpload: true, hasSystemFields: false)
try state.observed(historyDelete.mutation.recordName)
try state.recordDeletionHandoff(historyDelete.mutation)
let result = try state.missingRecordNames(seen: [], records: [historyDelete])
#expect(result.deleted.contains(historyDelete.mutation.recordName))
#expect(result.recreated.isEmpty)
}
@Test
func version30StoreMigratesAdditivelyAndLegacyQuarantineIsArchivedUnderItsOriginalScope() throws {
let directory = try temporaryDirectory()
@@ -208,7 +273,7 @@ struct CloudSyncSafetyPersistenceTests {
#expect(try context.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.name) == ["Existing data"])
#expect(!TermindSchemaV30.models.contains { $0 == PersistentCloudSyncQuarantine.self })
#expect(TermindSchemaV31.models.contains { $0 == PersistentCloudSyncQuarantine.self })
#expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV37.versionIdentifier)
#expect(TermindMigrationPlan.schemas.last?.versionIdentifier == TermindSchemaV38.versionIdentifier)
}
private func writeInterruptedFetch(
@@ -247,4 +312,5 @@ struct CloudSyncSafetyPersistenceTests {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
}
@@ -32,11 +32,17 @@ struct CloudSyncSentChanges {
/// A constructible projection of the CKSyncEngine events used by writable sync categories.
/// Keeping this value separate from CloudKit's event constructors gives the shared dispatcher a deterministic test seam.
enum CloudSyncZoneDeletionReason: Sendable {
case deleted
case purged
case encryptedDataReset
}
enum CloudSyncEventInput {
case stateUpdate(CKSyncEngine.State.Serialization)
case accountChange(CKSyncEngine.Event.AccountChange.ChangeType)
case fetchedRecordZoneChanges(records: [CKRecord], deletions: [CKRecord.ID])
case fetchedDatabaseChanges(deletedZoneIDs: [CKRecordZone.ID])
case fetchedDatabaseChanges(deletions: [(zoneID: CKRecordZone.ID, reason: CloudSyncZoneDeletionReason)])
case sentRecordZoneChanges(CloudSyncSentChanges)
case willFetchChanges
case willSendChanges
@@ -55,7 +61,17 @@ enum CloudSyncEventInput {
self = .fetchedRecordZoneChanges(
records: changes.modifications.map(\.record), deletions: changes.deletions.map(\.recordID))
case .fetchedDatabaseChanges(let changes):
self = .fetchedDatabaseChanges(deletedZoneIDs: changes.deletions.map(\.zoneID))
self = .fetchedDatabaseChanges(
deletions: changes.deletions.map {
let reason: CloudSyncZoneDeletionReason
switch $0.reason {
case .deleted: reason = .deleted
case .purged: reason = .purged
case .encryptedDataReset: reason = .encryptedDataReset
@unknown default: reason = .purged
}
return ($0.zoneID, reason)
})
case .sentRecordZoneChanges(let changes):
self = .sentRecordZoneChanges(CloudSyncSentChanges(changes))
case .willFetchChanges:
@@ -89,8 +105,10 @@ extension CloudSyncLifecycle {
try handleAccountChange(change)
case .fetchedRecordZoneChanges(let records, let deletions):
try receiveRemoteRecords(records, deletions)
case .fetchedDatabaseChanges(let deletedZoneIDs):
if deletedZoneIDs.contains(zoneID) { try handleZoneDeletion() }
case .fetchedDatabaseChanges(let deletions):
if let deletion = deletions.first(where: { $0.zoneID == zoneID }) {
try handleZoneDeletion(reason: deletion.reason)
}
case .sentRecordZoneChanges(let changes):
try handleSentChanges(changes)
case .willFetchChanges:
@@ -139,23 +157,27 @@ extension CloudSyncLifecycle {
_ changes: CloudSyncSentChanges, acknowledge: (CloudSyncMutation) throws -> Void
) throws {
guard let scope else { throw CancellationError() }
// A save result proves that the ID exists after any previously handed-off delete.
noteRecordsObserved(changes.savedRecords)
for id in changes.deletedRecordIDs where id.zoneID == zoneID {
guard try !store.safety.isUploadBlocked(scope, id.recordName),
let mutation = takeDeletionAcknowledgement(id)
else { continue }
guard let mutation = takeDeletionAcknowledgement(id) else { continue }
guard try !store.safety.isUploadBlocked(scope, id.recordName) else { continue }
try acknowledge(mutation)
}
for (id, error) in changes.failedRecordDeletes where id.zoneID == zoneID {
if error.code == .unknownItem {
guard try !store.safety.isUploadBlocked(scope, id.recordName),
let mutation = takeDeletionAcknowledgement(id)
else { continue }
guard let mutation = takeDeletionAcknowledgement(id) else { continue }
guard try !store.safety.isUploadBlocked(scope, id.recordName) else { continue }
try acknowledge(mutation)
} else {
_ = takeDeletionAcknowledgement(id)
let mutation = takeDeletionAcknowledgement(id)
if error.code == .zoneNotFound {
try handleZoneDeletion()
} else if !Self.isRetryable(error) && !Self.isCancellation(error) {
} else if Self.isRetryable(error) || Self.isCancellation(error) {
// The server may have committed an ambiguous delete. Release only
// the in-memory causal slot so it can retry; keep the durable fence.
} else {
if let mutation { try cancelDeletionHandoff(mutation) }
logFailure(error, stage: "delete")
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
@@ -23,6 +23,8 @@ struct CloudSyncLifecycleStore {
var resetForNewAccount: () throws -> Void
var resetForZone: () throws -> Void
var resetForEnvironment: () throws -> Void
var purge: (CloudSyncScope) throws -> Void = { _ in }
var completePendingPurge: () throws -> Void = {}
var replica: CloudSyncReplicaStore? = nil
func pendingRecordNames(_ scope: CloudSyncScope) throws -> [String] {
@@ -146,6 +148,11 @@ struct CloudSyncTransport {
/// One instance per category: only implementation is shared, never account or engine state.
@MainActor @Observable
final class CloudSyncLifecycle {
private struct InFlightDeletion {
var version: VaultVersion
var wasReobserved = false
}
/// CloudKit accepts at most 250 record changes in one network request.
/// Every CKSyncEngine delegate in the app builds its batches through this lifecycle.
nonisolated static let maximumRecordsPerRequest = 250
@@ -163,7 +170,7 @@ final class CloudSyncLifecycle {
private let authorizeUploads: (Bool) -> Void
private let beforeSend: () throws -> Void
private let receiveSnapshot: ([CKRecord], [CKRecord.ID]) throws -> Void
private var inFlightDeletions: [String: VaultVersion] = [:]
private var inFlightDeletions: [String: InFlightDeletion] = [:]
private var inventoryPending = false
private var inventorySeen = Set<String>()
private(set) var engine: (any CloudSyncEngineSession)?
@@ -248,7 +255,11 @@ final class CloudSyncLifecycle {
guard !recordNames.isEmpty else { return }
do {
try enqueue(recordNames)
guard isEnabled, engine != nil else { return }
guard isEnabled else { return }
if engine == nil {
start()
return
}
run { [weak self] in
try await self?.sendPendingChanges()
try self?.updateUpToDateStatus()
@@ -311,6 +322,8 @@ final class CloudSyncLifecycle {
private func prepareAndSynchronize() async throws {
let id = operationID
status = .checkingAccount
try store.completePendingPurge()
try store.save()
let accountStatus = try await transport.accountStatus()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
guard accountStatus == .available else {
@@ -330,8 +343,8 @@ final class CloudSyncLifecycle {
return
}
let scope = makeScope(account: currentUser, environment: environmentIdentifier)
let requiresRefetch = try store.safety.prepare(scope, legacyScope(for: state))
self.scope = scope
let requiresRefetch = try store.safety.prepare(scope, legacyScope(for: state))
state.boundUserRecordName = currentUser
state.pendingUserRecordName = nil
if requiresRefetch { state.engineStateData = nil }
@@ -422,9 +435,40 @@ final class CloudSyncLifecycle {
}
extension CloudSyncLifecycle {
func noteRecordsObserved(_ records: [CKRecord]) {
guard inventoryPending else { return }
inventorySeen.formUnion(records.filter { $0.recordID.zoneID == zoneID }.map { $0.recordID.recordName })
func reconcileRemoteChanges(
records: [CKRecord], deletions: [CKRecord.ID]
) throws -> (records: [CKRecord], deletions: [CKRecord.ID]) {
guard let scope, let replica = store.replica else { return (records, deletions) }
let relevant = deletions.filter {
$0.zoneID == zoneID && replica.handlesRecordName($0.recordName)
}
guard !relevant.isEmpty else { return (records, deletions) }
let deletedNames = Set(relevant.map(\.recordName))
let result = try replica.reconcileDeletions(scope, Array(deletedNames))
if !result.recreated.isEmpty {
try store.save()
try enqueue(result.recreated)
}
let accepted = Set(result.deleted)
return (
records.filter {
$0.recordID.zoneID != zoneID || !deletedNames.contains($0.recordID.recordName)
},
deletions.filter { $0.zoneID != zoneID || accepted.contains($0.recordName) }
)
}
func noteRecordsObserved(_ records: [CKRecord], excluding deletions: [CKRecord.ID] = []) {
let deletedNames = Set(
deletions.filter { $0.zoneID == zoneID }.map { $0.recordName }
)
let names = records.filter {
$0.recordID.zoneID == zoneID && !deletedNames.contains($0.recordID.recordName)
}.map { $0.recordID.recordName }
if inventoryPending { inventorySeen.formUnion(names) }
for name in names where inFlightDeletions[name] != nil {
inFlightDeletions[name]?.wasReobserved = true
}
}
func beginFetch() throws {
@@ -448,8 +492,10 @@ extension CloudSyncLifecycle {
fetchFailed = fetchFailed || !succeeded
if fetching == 0 && !fetchFailed, let scope {
if inventoryPending {
let missing = try store.replica?.missing(scope, inventorySeen) ?? []
try receiveSnapshot([], missing.map { CKRecord.ID(recordName: $0, zoneID: zoneID) })
let missing =
try store.replica?.missing(scope, inventorySeen)
?? .init(deleted: [], recreated: [])
try receiveSnapshot([], missing.deleted.map { CKRecord.ID(recordName: $0, zoneID: zoneID) })
try store.replica?.complete(scope)
try store.save()
inventoryPending = false
@@ -469,6 +515,12 @@ extension CloudSyncLifecycle {
func finishSend(succeeded: Bool = true) throws {
sending = max(0, sending - 1)
if sending == 0 {
// CKSyncEngine may finish or throw without an item result for every handed-off
// delete. The durable journal remains authoritative; release unresolved slots
// so a later send can retry them.
inFlightDeletions.removeAll()
}
try setRecoveryRequired(!succeeded || fetching > 0 || sending > 0 || fetchFailed)
}
@@ -529,19 +581,27 @@ extension CloudSyncLifecycle {
mutation.operation == operation, try canUpload(id, engine: engine),
included.insert(id.recordName).inserted
else { continue }
// Complete an in-flight delete before sending a later incarnation of the same ID.
if let sent = inFlightDeletions[id.recordName], operation == .save || sent != mutation.version {
continue
}
// One result event can identify a delete only by record ID. Never hand off another
// operation for that ID until the first result has released its causal slot.
if inFlightDeletions[id.recordName] != nil { continue }
if operation == .delete {
deletions.append(id)
inFlightDeletions[id.recordName] = mutation.version
inFlightDeletions[id.recordName] = InFlightDeletion(version: mutation.version)
} else if let record = recordProvider(id) {
records.append(record)
}
if records.count + deletions.count == Self.maximumRecordsPerRequest { break }
}
guard accepts(engine), isReadyToSend, !records.isEmpty || !deletions.isEmpty else { return nil }
for id in deletions {
guard let deletion = inFlightDeletions[id.recordName] else { continue }
try store.replica?.recordDeletionHandoff(
scope,
CloudSyncMutation(
recordName: id.recordName, operation: .delete, version: deletion.version))
}
// A delete must not leave this process before its causal incarnation is durable.
try store.save()
return CKSyncEngine.RecordZoneChangeBatch(recordsToSave: records, recordIDsToDelete: deletions)
} catch {
failClosed(error)
@@ -649,16 +709,43 @@ extension CloudSyncLifecycle {
try store.save()
}
func handleZoneDeletion(reason: CloudSyncZoneDeletionReason) throws {
switch reason {
case .deleted:
try handleZoneDeletion()
case .purged, .encryptedDataReset:
guard let scope else { throw CancellationError() }
// Retire before touching persistence so automatic callbacks cannot repopulate
// a cache that the account owner explicitly removed.
retireEngine()
do {
try store.purge(scope)
status = .upToDate(lastSuccessfulSync: nil)
} catch {
store.rollback()
throw error
}
}
}
func cancelDeletionHandoff(_ mutation: CloudSyncMutation) throws {
guard let scope else { return }
try store.replica?.cancelDeletionHandoff(scope, mutation)
}
func restartAfterExpiredToken() {
failClosed(CKError(.changeTokenExpired))
start()
}
func takeDeletionAcknowledgement(_ id: CKRecord.ID) -> CloudSyncMutation? {
guard id.zoneID == zoneID, let version = inFlightDeletions.removeValue(forKey: id.recordName) else {
guard id.zoneID == zoneID, let deletion = inFlightDeletions.removeValue(forKey: id.recordName) else {
return nil
}
return CloudSyncMutation(recordName: id.recordName, operation: .delete, version: version)
// The result completes only the handed-off delete. If the record was observed again
// afterward, the durable journal must remain pending for a new physical delete.
guard !deletion.wasReobserved else { return nil }
return CloudSyncMutation(recordName: id.recordName, operation: .delete, version: deletion.version)
}
func saveEngineState(_ serialization: CKSyncEngine.State.Serialization) throws {
@@ -57,13 +57,34 @@ nonisolated enum CloudSyncRecordCoding {
return archiver.encodedData
}
static func assetURL(recordName: String, directoryName: String) throws -> URL {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: directoryName, directoryHint: .isDirectory)
static func assetURL(
recordName: String, directoryName: String, cacheRoot: URL? = nil
) throws -> URL {
let directory = try assetDirectory(directoryName: directoryName, cacheRoot: cacheRoot)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appending(path: recordName.replacingOccurrences(of: "/", with: "-"))
}
static func purgeAssetDirectory(
directoryName: String, cacheRoot: URL? = nil,
removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }
) throws {
let directory = try assetDirectory(directoryName: directoryName, cacheRoot: cacheRoot)
guard FileManager.default.fileExists(atPath: directory.path) else { return }
try removeItem(directory)
}
private static func assetDirectory(directoryName: String, cacheRoot: URL?) throws -> URL {
let root = (cacheRoot ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0])
.standardizedFileURL
let directory = root.appending(path: directoryName, directoryHint: .isDirectory).standardizedFileURL
let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/"
guard directory.path.hasPrefix(prefix), directory != root else {
throw CocoaError(.fileWriteInvalidFileName)
}
return directory
}
private static func hash(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
@@ -5,15 +5,51 @@ import TermindPersistence
@MainActor
struct CloudSyncReplicaStore {
struct MissingRecords {
var deleted: [String]
var recreated: [String]
}
var prepare: (CloudSyncScope) throws -> Bool
var missing: (CloudSyncScope, Set<String>) throws -> [String]
var handlesRecordName: (String) -> Bool
var missing: (CloudSyncScope, Set<String>) throws -> MissingRecords
var reconcileDeletions: (CloudSyncScope, [String]) throws -> MissingRecords
var recordDeletionHandoff: (CloudSyncScope, CloudSyncMutation) throws -> Void
var cancelDeletionHandoff: (CloudSyncScope, CloudSyncMutation) throws -> Void
var complete: (CloudSyncScope) throws -> Void
var reset: (CloudSyncScope) throws -> Void
static func persistent(context: ModelContext, records: @escaping () throws -> [CloudSyncJournalRecord]) -> Self {
static func persistent(
context: ModelContext, records: @escaping () throws -> [CloudSyncJournalRecord],
clearSystemFields: @escaping (String) throws -> Void,
handlesRecordName: @escaping (String) -> Bool = { _ in true },
preservesNewerIncarnation: @escaping (String) -> Bool = { _ in true }
) -> Self {
Self(
prepare: { try CloudSyncRecordStateStore(context: context, scope: $0).prepare(records()) },
missing: { try CloudSyncRecordStateStore(context: context, scope: $0).missingRecordNames(seen: $1) },
handlesRecordName: handlesRecordName,
missing: { scope, seen in
let result = try CloudSyncRecordStateStore(context: context, scope: scope)
.missingRecordNames(
seen: seen, records: records(),
preservesNewerIncarnation: preservesNewerIncarnation)
for name in result.recreated { try clearSystemFields(name) }
return MissingRecords(deleted: result.deleted, recreated: result.recreated)
},
reconcileDeletions: { scope, names in
let result = try CloudSyncRecordStateStore(context: context, scope: scope)
.reconcileDeletions(
names, records: records(),
preservesNewerIncarnation: preservesNewerIncarnation)
for name in result.recreated { try clearSystemFields(name) }
return MissingRecords(deleted: result.deleted, recreated: result.recreated)
},
recordDeletionHandoff: {
try CloudSyncRecordStateStore(context: context, scope: $0).recordDeletionHandoff($1)
},
cancelDeletionHandoff: {
try CloudSyncRecordStateStore(context: context, scope: $0).cancelDeletionHandoff($1)
},
complete: { try CloudSyncRecordStateStore(context: context, scope: $0).completeReconciliation() },
reset: { try CloudSyncRecordStateStore(context: context, scope: $0).resetZone() })
}
@@ -97,7 +97,20 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche
resetForNewAccount: { try journal.resetForNewAccount() },
resetForZone: { try journal.resetForZone() },
resetForEnvironment: { try journal.resetForZone() },
replica: .persistent(context: context, records: journal.cloudRecords)),
purge: { [weak self] scope in
guard let self else { throw CancellationError() }
let changed = try self.persistence.purgeCloudCache(kind: kind, scope: scope)
if !changed.isEmpty { self.didApplyRemote?(changed) }
},
replica: .persistent(
context: context, records: journal.cloudRecords,
clearSystemFields: journal.clearSystemFields,
handlesRecordName: {
HistoryRecordKey(recordName: $0).map { $0.kind == kind } == true
},
preservesNewerIncarnation: { recordName in
kind == .device && HistoryRecordKey(recordName: recordName)?.kind == .device
})),
transport: makeTransport(zoneID, self),
zoneID: zoneID, containerIdentifier: Self.containerIdentifier, defaults: defaults,
enabledKey: kind == .connectionHistory ? SettingsKey.historySyncEnabled : nil,
@@ -166,7 +179,9 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche
_ records: [CKRecord], deletions: [CKRecord.ID] = [], isConflict: Bool = false
) throws {
guard let scope = lifecycle.scope else { throw CancellationError() }
lifecycle.noteRecordsObserved(records)
let reconciled = try lifecycle.reconcileRemoteChanges(records: records, deletions: deletions)
let (records, deletions) = (reconciled.records, reconciled.deletions)
lifecycle.noteRecordsObserved(records, excluding: deletions)
let deletedKeys = Set(
deletions.compactMap { id -> HistoryRecordKey? in
guard id.zoneID == zoneID, let key = HistoryRecordKey(recordName: id.recordName), includes(key)
@@ -193,7 +208,11 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche
decoded.map { $0.1 }, deletions: deletedKeys,
beforeSave: {
for (record, envelope) in decoded {
try cloud.observed(record.recordID.recordName)
let current = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.observed(
record.recordID.recordName, version: envelope.version, current: current)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
if try journal.envelope(for: envelope.key) == envelope {
try journal.markUploaded(envelope.key, version: envelope.version)
@@ -219,8 +238,14 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche
{
guard try !lifecycle.store.safety.isUploadBlocked(scope, record.recordID.recordName) else { continue }
let envelope = try Self.envelope(from: record)
try cloud.observed(record.recordID.recordName)
if try journal.envelope(for: envelope.key)?.isDeleted == true {
let current = try journal.envelope(for: envelope.key)
let currentRecord = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.acknowledgeSave(
.init(recordName: record.recordID.recordName, operation: .save, version: envelope.version),
current: currentRecord)
if current?.isDeleted == true {
// An in-flight save may finish after a local/remote deletion. Delete its server copy again.
try journal.markNeedsUpload([envelope.key])
} else {
@@ -239,7 +264,6 @@ final class HistoryRecordSyncCoordinator: NSObject, CKSyncEngineDelegate, @unche
}
try lifecycle.handleDeletedRecords(changes) { mutation in
try journal.acknowledgeDeletion(mutation, scope: scope)
try cloud.acknowledgeLegacyDeletion(mutation)
}
try context.save()
try lifecycle.reconcilePendingChanges()
@@ -15,6 +15,7 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
public nonisolated static let recordType = "MetricsItem"
public nonisolated static let subscriptionID = "termind-metrics-v1-changes"
public nonisolated static let inlinePayloadLimit = CloudSyncRecordCoding.inlinePayloadLimit
nonisolated static let assetDirectoryName = "Termind/MetricsSyncAssets"
private let context: ModelContext
private let persistence: MetricsSyncPersistence
@@ -22,6 +23,8 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
private let makeTransport: (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
private let defaults: UserDefaults
private let environmentIdentifier: String
private let assetCacheRoot: URL?
private let removeAssetDirectory: (URL) throws -> Void
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
@ObservationIgnored private(set) lazy var lifecycle = makeLifecycle()
@@ -50,6 +53,8 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
init(
persistence: MetricsSyncPersistence,
defaults: UserDefaults, environmentIdentifier: String,
assetCacheRoot: URL? = nil,
removeAssetDirectory: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) },
makeTransport: @escaping (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
) {
context = persistence.context
@@ -58,6 +63,8 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
self.makeTransport = makeTransport
self.defaults = defaults
self.environmentIdentifier = environmentIdentifier
self.assetCacheRoot = assetCacheRoot
self.removeAssetDirectory = removeAssetDirectory
super.init()
persistence.didRecordChanges = { [weak self] mutations in self?.noteLocalChanges(mutations) }
}
@@ -107,7 +114,21 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
resetForNewAccount: { try journal.markAllLiveRecordsNeedsUploadForNewAccount() },
resetForZone: { try journal.markAllEntriesNeedsUploadAfterZoneReset() },
resetForEnvironment: { try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() },
replica: .persistent(context: context, records: journal.cloudRecords)),
purge: { [weak self] scope in
guard let self else { throw CancellationError() }
let result = try self.persistence.purgeCloudCache(
scope: scope, assetCleanupKey: Self.assetDirectoryName)
self.didApplyRemote?(result.0, result.1)
try self.completePendingAssetPurge()
},
completePendingPurge: { [weak self] in
guard let self else { throw CancellationError() }
try self.completePendingAssetPurge()
},
replica: .persistent(
context: context, records: journal.cloudRecords,
clearSystemFields: journal.clearSystemFields,
handlesRecordName: { MetricsRecordKey(recordName: $0) != nil })),
transport: makeTransport(zoneID, self),
zoneID: zoneID, containerIdentifier: Self.containerIdentifier, defaults: defaults,
enabledKey: SettingsKey.metricsSyncEnabled, defaultEnabled: SettingsKey.defaultMetricsSyncEnabled,
@@ -115,6 +136,16 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
receiveSnapshot: { [weak self] in try self?.receiveRemoteRecords($0, deletions: $1) })
}
private func completePendingAssetPurge() throws {
let cleanup = CloudSyncAssetCleanupStore(context: context)
guard try cleanup.isPending(cacheKey: Self.assetDirectoryName) else { return }
try CloudSyncRecordCoding.purgeAssetDirectory(
directoryName: Self.assetDirectoryName, cacheRoot: assetCacheRoot,
removeItem: removeAssetDirectory)
try cleanup.complete(cacheKey: Self.assetDirectoryName)
try context.save()
}
public nonisolated func nextFetchChangesOptions(
_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.FetchChangesOptions {
@@ -164,10 +195,12 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
_ records: [CKRecord], deletions: [CKRecord.ID] = [], isConflict: Bool = false
) throws {
guard let scope = lifecycle.scope else { throw CancellationError() }
lifecycle.noteRecordsObserved(records)
let reconciled = try lifecycle.reconcileRemoteChanges(records: records, deletions: deletions)
let (records, deletions) = (reconciled.records, reconciled.deletions)
lifecycle.noteRecordsObserved(records, excluding: deletions)
let deletedKeys = Set(
deletions.compactMap { id -> MetricsRecordKey? in
guard id.zoneID == zoneID, let key = MetricsRecordKey(recordName: id.recordName), true
guard id.zoneID == zoneID, let key = MetricsRecordKey(recordName: id.recordName)
else { return nil }
return key
})
@@ -184,7 +217,11 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
decoded.map { $0.1 }, deletions: deletedKeys,
beforeSave: {
for (record, envelope) in decoded {
try cloud.observed(record.recordID.recordName)
let current = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.observed(
record.recordID.recordName, version: envelope.version, current: current)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
if try journal.envelope(for: envelope.key) == envelope {
try journal.markUploaded(envelope.key, version: envelope.version)
@@ -208,8 +245,14 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
guard try !safety.isUploadBlocked(recordName: record.recordID.recordName) else { continue }
let envelope = try Self.envelope(from: record)
try cloud.observed(record.recordID.recordName)
if try journal.envelope(for: envelope.key)?.isDeleted == true {
let current = try journal.envelope(for: envelope.key)
let currentRecord = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.acknowledgeSave(
.init(recordName: record.recordID.recordName, operation: .save, version: envelope.version),
current: currentRecord)
if current?.isDeleted == true {
// An in-flight save may finish after a local/remote deletion. Delete its server copy again.
try journal.markNeedsUpload([envelope.key])
} else {
@@ -229,7 +272,6 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
}
try lifecycle.handleDeletedRecords(changes) { mutation in
try journal.acknowledgeDeletion(mutation, scope: scope)
try cloud.acknowledgeLegacyDeletion(mutation)
}
try context.save()
try lifecycle.reconcilePendingChanges()
@@ -281,7 +323,7 @@ public final class MetricsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unch
}
private nonisolated static func assetURL(for key: MetricsRecordKey) throws -> URL {
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: "Termind/MetricsSyncAssets")
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: assetDirectoryName)
}
}
@@ -19,6 +19,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
public nonisolated static let workspaceRecordType = "WorkspacePreferencesItem"
public nonisolated static let subscriptionID = "termind-ai-configuration-v1-changes"
public nonisolated static let inlinePayloadLimit = CloudSyncRecordCoding.inlinePayloadLimit
nonisolated static let assetDirectoryName = "Termind/SettingsSyncAssets"
private let context: ModelContext
private let persistence: SettingsSyncPersistence
@@ -27,6 +28,8 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
private let makeTransport: (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
private let defaults: UserDefaults
private let environmentIdentifier: String
private let assetCacheRoot: URL?
private let removeAssetDirectory: (URL) throws -> Void
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
@ObservationIgnored private(set) lazy var lifecycle = makeLifecycle()
@@ -58,6 +61,8 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
persistence: SettingsSyncPersistence,
secrets: AISecretStore,
defaults: UserDefaults, environmentIdentifier: String,
assetCacheRoot: URL? = nil,
removeAssetDirectory: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) },
makeTransport: @escaping (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
) {
context = persistence.context
@@ -67,6 +72,8 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
self.makeTransport = makeTransport
self.defaults = defaults
self.environmentIdentifier = environmentIdentifier
self.assetCacheRoot = assetCacheRoot
self.removeAssetDirectory = removeAssetDirectory
super.init()
persistence.didRecordChanges = { [weak self] mutations in self?.noteLocalChanges(mutations) }
}
@@ -117,7 +124,21 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
resetForNewAccount: { try journal.markAllLiveRecordsNeedsUploadForNewAccount() },
resetForZone: { try journal.markAllEntriesNeedsUploadAfterZoneReset() },
resetForEnvironment: { try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() },
replica: .persistent(context: context, records: journal.cloudRecords)),
purge: { [weak self] scope in
guard let self else { throw CancellationError() }
let result = try self.persistence.purgeCloudCache(
scope: scope, assetCleanupKey: Self.assetDirectoryName)
self.didApplyRemote?(result.0, result.1)
try self.completePendingAssetPurge()
},
completePendingPurge: { [weak self] in
guard let self else { throw CancellationError() }
try self.completePendingAssetPurge()
},
replica: .persistent(
context: context, records: journal.cloudRecords,
clearSystemFields: journal.clearSystemFields,
handlesRecordName: { SettingsRecordKey(recordName: $0) != nil })),
transport: makeTransport(zoneID, self),
zoneID: zoneID, containerIdentifier: Self.containerIdentifier, defaults: defaults,
enabledKey: SettingsKey.settingsSyncEnabled, defaultEnabled: SettingsKey.defaultSettingsSyncEnabled,
@@ -127,6 +148,16 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
receiveSnapshot: { [weak self] in try self?.receiveRemoteRecords($0, deletions: $1) })
}
private func completePendingAssetPurge() throws {
let cleanup = CloudSyncAssetCleanupStore(context: context)
guard try cleanup.isPending(cacheKey: Self.assetDirectoryName) else { return }
try CloudSyncRecordCoding.purgeAssetDirectory(
directoryName: Self.assetDirectoryName, cacheRoot: assetCacheRoot,
removeItem: removeAssetDirectory)
try cleanup.complete(cacheKey: Self.assetDirectoryName)
try context.save()
}
private func migrateSecrets() throws {
let config = try persistence.load().configuration
try secrets.migrate(
@@ -212,15 +243,17 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
_ records: [CKRecord], deletions: [CKRecord.ID] = [], isConflict: Bool = false
) throws {
guard let scope = lifecycle.scope else { throw CancellationError() }
lifecycle.noteRecordsObserved(records)
let deletedKeys = Set(
let reconciled = try lifecycle.reconcileRemoteChanges(records: records, deletions: deletions)
let (records, deletions) = (reconciled.records, reconciled.deletions)
lifecycle.noteRecordsObserved(records, excluding: deletions)
let allDeletedKeys = Set(
deletions.compactMap { id -> SettingsRecordKey? in
guard id.zoneID == zoneID, let key = SettingsRecordKey(recordName: id.recordName),
key.kind.participatesInSync && !key.isLegacyGuardianRole
else { return nil }
guard id.zoneID == zoneID, let key = SettingsRecordKey(recordName: id.recordName) else { return nil }
return key
})
let deletedNames = Set(deletedKeys.map(\.recordName))
let deletedKeys = Set(allDeletedKeys.filter { $0.kind.participatesInSync && !$0.isLegacyGuardianRole })
let cleanupDeletedKeys = allDeletedKeys.subtracting(deletedKeys)
let deletedNames = Set(allDeletedKeys.map(\.recordName))
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
let safety = CloudSyncSafetyStore(context: context, scope: scope)
let decoded: [(record: CKRecord, envelope: SettingsEnvelope)] = try decodeCloudSyncRecords(
@@ -229,22 +262,36 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
guard record.recordID.zoneID == zoneID,
isConflict || Self.acceptsRecordType(record.recordType)
else { return false }
return SettingsRecordKey(recordName: record.recordID.recordName)?.kind.participatesInSync != false
return SettingsRecordKey(recordName: record.recordID.recordName) != nil
},
decode: Self.envelope,
decode: Self.validatedEnvelope,
quarantine: { try CloudSyncRecordCoding.quarantine($0, error: $1, in: safety) },
logFailure: { lifecycle.logFailure($0, stage: "decode-quarantine") })
let result = try applyRemote(
decoded.map { $0.1 }, deletions: deletedKeys,
decoded.map { $0.1.canonicalized }, deletions: deletedKeys,
beforeSave: {
for (record, envelope) in decoded {
try cloud.observed(record.recordID.recordName)
if record.recordID.recordName == envelope.key.recordName {
for (record, sourceEnvelope) in decoded {
let envelope = sourceEnvelope.canonicalized
if sourceEnvelope.isDeleted,
!sourceEnvelope.key.kind.participatesInSync || sourceEnvelope.key.isLegacyGuardianRole
{
try journal.queueHistoricalDeletion(sourceEnvelope)
}
if sourceEnvelope.key.kind.participatesInSync || sourceEnvelope.isDeleted {
let current = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.observed(
record.recordID.recordName, version: envelope.version, current: current)
}
if record.recordID.recordName == envelope.key.recordName,
envelope.key.kind.participatesInSync
{
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
if try journal.envelope(for: envelope.key) == envelope {
try journal.markUploaded(envelope.key, version: envelope.version)
}
} else {
} else if sourceEnvelope.key.isLegacyGuardianRole {
// Legacy Guardian data has no canonical CloudKit record; never reuse its system fields.
try journal.markNeedsUpload([envelope.key])
}
@@ -255,6 +302,11 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
try journal.acknowledgeDeletion(
.init(recordName: key.recordName, operation: .delete, version: envelope.version), scope: scope)
}
for key in cleanupDeletedKeys {
guard let envelope = try journal.envelope(for: key), envelope.isDeleted else { continue }
try journal.acknowledgeDeletion(
.init(recordName: key.recordName, operation: .delete, version: envelope.version), scope: scope)
}
})
try lifecycle.reconcilePendingChanges()
if !result.acceptedKeys.isEmpty { didApplyRemote?(result.snapshot, result.acceptedKeys) }
@@ -267,8 +319,14 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
guard try !safety.isUploadBlocked(recordName: record.recordID.recordName) else { continue }
let envelope = try Self.envelope(from: record)
try cloud.observed(record.recordID.recordName)
if try journal.envelope(for: envelope.key)?.isDeleted == true {
let current = try journal.envelope(for: envelope.key)
let currentRecord = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.acknowledgeSave(
.init(recordName: record.recordID.recordName, operation: .save, version: envelope.version),
current: currentRecord)
if current?.isDeleted == true {
// An in-flight save may finish after a local/remote deletion. Delete its server copy again.
try journal.markNeedsUpload([envelope.key])
} else {
@@ -288,7 +346,6 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
try lifecycle.handleDeletedRecords(changes) { mutation in
try journal.acknowledgeDeletion(mutation, scope: scope)
try cloud.acknowledgeLegacyDeletion(mutation)
}
try context.save()
try lifecycle.reconcilePendingChanges()
@@ -335,6 +392,10 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
public nonisolated static func envelope(from record: CKRecord) throws -> SettingsEnvelope {
try validatedEnvelope(from: record).canonicalized
}
private nonisolated static func validatedEnvelope(from record: CKRecord) throws -> SettingsEnvelope {
let data = try CloudSyncRecordCoding.validatedPayloadData(
from: record, invalidEnvelope: SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName))
try CloudSyncRecordCoding.validateFormat(in: data, supported: SettingsEnvelope.currentFormatVersion)
@@ -345,7 +406,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
else {
throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
return envelope.canonicalized
return envelope
}
public nonisolated static func payloadData(from record: CKRecord) throws -> Data {
@@ -358,7 +419,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
private nonisolated static func assetURL(for key: SettingsRecordKey) throws -> URL {
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: "Termind/SettingsSyncAssets")
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: assetDirectoryName)
}
}
@@ -13,6 +13,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
public nonisolated static let recordType = "VaultItem"
public nonisolated static let subscriptionID = "termind-vault-v1-changes"
public nonisolated static let inlinePayloadLimit = CloudSyncRecordCoding.inlinePayloadLimit
nonisolated static let assetDirectoryName = "Termind/VaultSyncAssets"
private let context: ModelContext
private let persistence: HostPersistence
@@ -20,6 +21,8 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
private let makeTransport: (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
private let defaults: UserDefaults
private let environmentIdentifier: String
private let assetCacheRoot: URL?
private let removeAssetDirectory: (URL) throws -> Void
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
@ObservationIgnored private(set) lazy var lifecycle = makeLifecycle()
@@ -49,6 +52,8 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
init(
context: ModelContext,
defaults: UserDefaults, environmentIdentifier: String,
assetCacheRoot: URL? = nil,
removeAssetDirectory: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) },
makeTransport: @escaping (CKRecordZone.ID, any CKSyncEngineDelegate) -> CloudSyncTransport
) {
self.context = context
@@ -57,6 +62,8 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
self.makeTransport = makeTransport
self.defaults = defaults
self.environmentIdentifier = environmentIdentifier
self.assetCacheRoot = assetCacheRoot
self.removeAssetDirectory = removeAssetDirectory
super.init()
}
@@ -99,7 +106,9 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
},
save: { try context.save() }, rollback: { context.rollback() },
pendingChanges: {
try CloudSyncRecordStateStore(context: context, scope: $0).pendingChanges(journal.cloudRecords())
let cloud = CloudSyncRecordStateStore(context: context, scope: $0)
try cloud.prepareLegacyDeletionsForUpload()
return try cloud.pendingChanges(journal.cloudRecords())
},
safety: .persistent(
context: context, capabilityID: SyncRecordFormats.vault.capabilityID,
@@ -107,7 +116,24 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
resetForNewAccount: { try journal.markAllLiveRecordsNeedsUploadForNewAccount() },
resetForZone: { try journal.markAllEntriesNeedsUploadAfterZoneReset() },
resetForEnvironment: { try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() },
replica: .persistent(context: context, records: journal.cloudRecords)),
purge: { [weak self] scope in
guard let self else { throw CancellationError() }
let result = try self.persistence.purgeCloudCache(
scope: scope, assetCleanupKey: Self.assetDirectoryName)
self.didApplyRemote?(result.0, result.1)
try self.completePendingAssetPurge()
},
completePendingPurge: { [weak self] in
guard let self else { throw CancellationError() }
try self.completePendingAssetPurge()
},
replica: .persistent(
context: context, records: journal.cloudRecords,
clearSystemFields: journal.clearSystemFields,
handlesRecordName: {
VaultRecordKey(recordName: $0)?.kind.isConfiguration == true
&& HistoryRecordKey(recordName: $0) == nil
})),
transport: makeTransport(zoneID, self),
zoneID: zoneID, containerIdentifier: Self.containerIdentifier, defaults: defaults,
enabledKey: nil, defaultEnabled: SettingsKey.defaultVaultSyncEnabled,
@@ -116,6 +142,16 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
receiveSnapshot: { [weak self] in try self?.receiveRemoteRecords($0, deletions: $1) })
}
private func completePendingAssetPurge() throws {
let cleanup = CloudSyncAssetCleanupStore(context: context)
guard try cleanup.isPending(cacheKey: Self.assetDirectoryName) else { return }
try CloudSyncRecordCoding.purgeAssetDirectory(
directoryName: Self.assetDirectoryName, cacheRoot: assetCacheRoot,
removeItem: removeAssetDirectory)
try cleanup.complete(cacheKey: Self.assetDirectoryName)
try context.save()
}
public nonisolated func nextFetchChangesOptions(
_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.FetchChangesOptions {
@@ -179,7 +215,8 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
_ records: [CKRecord], deletions: [CKRecord.ID] = [], isConflict: Bool = false
) throws {
guard let scope = lifecycle.scope else { throw CancellationError() }
lifecycle.noteRecordsObserved(records)
let reconciled = try lifecycle.reconcileRemoteChanges(records: records, deletions: deletions)
let (records, deletions) = (reconciled.records, reconciled.deletions)
let deletedKeys = Set(
deletions.compactMap { id -> VaultRecordKey? in
guard id.zoneID == zoneID, let key = VaultRecordKey(recordName: id.recordName), key.kind.isConfiguration
@@ -187,6 +224,10 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
return key
})
let deletedNames = Set(deletedKeys.map(\.recordName))
// Old History/Device deletion events in this retired zone are intentionally ignored,
// so they cannot cancel proof that an obsolete copy was observed again.
lifecycle.noteRecordsObserved(
records, excluding: deletions.filter { deletedNames.contains($0.recordName) })
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
let safety = CloudSyncSafetyStore(context: context, scope: scope)
// Old-copy cleanup ONLY: these two kinds moved to TermindConnectionHistoryV1.
@@ -215,11 +256,18 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
try cloud.queueLegacyDeletion(
record.recordID.recordName, version: .baseline(writerID: try journal.state().installationID))
}
// Also repairs cleanup tasks queued by an earlier app run while a scoped
// quarantine still blocked the same retired record ID.
try cloud.prepareLegacyDeletionsForUpload()
// Ignore deletion increments for obsolete Vault history/device copies.
// Only a sent-delete acknowledgement retires their cleanup task (unknownItem is also success).
// New-zone deletion increments are handled by HistoryRecordSyncCoordinator as normal user deletions.
for (record, envelope) in decoded {
try cloud.observed(record.recordID.recordName)
let current = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.observed(
record.recordID.recordName, version: envelope.version, current: current)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
if try journal.envelope(for: envelope.key) == envelope {
try journal.markUploaded(envelope.key, version: envelope.version)
@@ -246,8 +294,14 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
{
guard try !safety.isUploadBlocked(recordName: record.recordID.recordName) else { continue }
let envelope = try Self.envelope(from: record)
try cloud.observed(record.recordID.recordName)
if try journal.envelope(for: envelope.key)?.isDeleted == true {
let current = try journal.envelope(for: envelope.key)
let currentRecord = try journal.cloudRecords().first {
$0.mutation.recordName == record.recordID.recordName
}
try cloud.acknowledgeSave(
.init(recordName: record.recordID.recordName, operation: .save, version: envelope.version),
current: currentRecord)
if current?.isDeleted == true {
// An in-flight save may finish after a local/remote deletion. Delete its server copy again.
try journal.markNeedsUpload([envelope.key])
} else {
@@ -320,7 +374,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
}
private nonisolated static func assetURL(for key: VaultRecordKey) throws -> URL {
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: "Termind/VaultSyncAssets")
try CloudSyncRecordCoding.assetURL(recordName: key.recordName, directoryName: assetDirectoryName)
}
}
@@ -26,6 +26,79 @@ public final class PersistentCloudSyncReplicaState {
public init(scope: CloudSyncScope) { scopeKey = scope.storageKey }
}
/// V38 sidecar recording the exact delete incarnation handed to CloudKit.
/// It closes the crash window between a successful server delete and its local acknowledgement.
@Model
public final class PersistentCloudSyncDeletionHandoff {
@Attribute(.unique) public var storageKey = ""
public var scopeKey = ""
public var recordName = ""
public var versionData = Data()
public init(scope: CloudSyncScope, recordName: String, versionData: Data) {
scopeKey = scope.storageKey
self.recordName = recordName
self.versionData = versionData
storageKey = scope.storageKey + "\(recordName.utf8.count):\(recordName)"
}
}
/// V38 durable work item for category-owned filesystem cache cleanup.
@Model
public final class PersistentCloudSyncAssetCleanup {
@Attribute(.unique) public var storageKey = ""
public var scopeKey = ""
public var cacheKey = ""
public init(scope: CloudSyncScope, cacheKey: String) {
scopeKey = scope.storageKey
self.cacheKey = cacheKey
storageKey = scope.storageKey + "\(cacheKey.utf8.count):\(cacheKey)"
}
}
@MainActor
public final class CloudSyncAssetCleanupStore {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func markPending(scope: CloudSyncScope, cacheKey: String) throws {
guard try pending(scope: scope, cacheKey: cacheKey) == nil else { return }
context.insert(PersistentCloudSyncAssetCleanup(scope: scope, cacheKey: cacheKey))
}
public func isPending(scope: CloudSyncScope, cacheKey: String) throws -> Bool {
try pending(scope: scope, cacheKey: cacheKey) != nil
}
public func isPending(cacheKey: String) throws -> Bool {
try !pending(cacheKey: cacheKey).isEmpty
}
public func complete(scope: CloudSyncScope, cacheKey: String) throws {
if let row = try pending(scope: scope, cacheKey: cacheKey) { context.delete(row) }
}
public func complete(cacheKey: String) throws {
for row in try pending(cacheKey: cacheKey) { context.delete(row) }
}
private func pending(scope: CloudSyncScope, cacheKey: String) throws -> PersistentCloudSyncAssetCleanup? {
let scopeKey = scope.storageKey
var descriptor = FetchDescriptor<PersistentCloudSyncAssetCleanup>(
predicate: #Predicate { $0.scopeKey == scopeKey && $0.cacheKey == cacheKey })
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func pending(cacheKey: String) throws -> [PersistentCloudSyncAssetCleanup] {
try context.fetch(
FetchDescriptor<PersistentCloudSyncAssetCleanup>(
predicate: #Predicate { $0.cacheKey == cacheKey }))
}
}
@MainActor
public final class CloudSyncRecordStateStore {
private let context: ModelContext
@@ -53,10 +126,12 @@ public final class CloudSyncRecordStateStore {
let mutation = record.mutation
guard !blocked.contains(mutation.recordName) else { return nil }
if mutation.operation == .delete {
let confirmed = states[mutation.recordName]?.confirmedDeletion.flatMap {
let state = states[mutation.recordName]
let confirmed = state?.confirmedDeletion.flatMap {
try? JSONDecoder().decode(VaultVersion.self, from: $0)
}
return record.needsUpload || confirmed != mutation.version ? mutation : nil
return record.needsUpload || state?.wasPresent == true || confirmed != mutation.version
? mutation : nil
}
return record.needsUpload ? mutation : nil
}
@@ -72,10 +147,35 @@ public final class CloudSyncRecordStateStore {
return result.sorted { $0.recordName < $1.recordName }
}
public func observed(_ recordName: String) throws {
public func observed(
_ recordName: String, version: VaultVersion? = nil,
current: CloudSyncJournalRecord? = nil
) throws {
let row = try state(recordName)
let causal = try causalDeletionVersion(recordName, row: row)
if let version, let causal, let current,
current.mutation.operation == .save, current.needsUpload,
causal < current.mutation.version, version <= causal
{
return
}
row.wasPresent = true
row.confirmedDeletion = nil
if let causal, let version {
if causal < version { row.confirmedDeletion = nil }
} else {
row.confirmedDeletion = nil
}
}
/// A save acknowledgement supersedes an older handed-off delete when it proves
/// that incarnation, or an even newer local edit, exists after the delete.
public func acknowledgeSave(_ saved: CloudSyncMutation, current: CloudSyncJournalRecord?) throws {
try observed(saved.recordName, version: saved.version, current: current)
guard saved.operation == .save, let current, current.mutation.operation == .save,
saved.version <= current.mutation.version, let handoff = try handoff(saved.recordName),
try JSONDecoder().decode(VaultVersion.self, from: handoff.versionData) < saved.version
else { return }
context.delete(handoff)
}
public func wasPresent(_ recordName: String) throws -> Bool {
@@ -87,6 +187,7 @@ public final class CloudSyncRecordStateStore {
row.wasPresent = false
row.confirmedDeletion = try JSONEncoder().encode(version)
row.legacyDeletion = nil
if let handoff = try handoff(recordName) { context.delete(handoff) }
}
public func queueLegacyDeletion(_ recordName: String, version: VaultVersion) throws {
@@ -94,6 +195,18 @@ public final class CloudSyncRecordStateStore {
try state(recordName).legacyDeletion = JSONEncoder().encode(version)
}
/// A queued legacy deletion is durable proof that the Vault coordinator already
/// validated the retired zone, record type, and canonical History/Device ID.
public func prepareLegacyDeletionsForUpload() throws {
let safety = CloudSyncSafetyStore(context: context, scope: scope)
for row in try entries() where row.legacyDeletion != nil {
guard let key = HistoryRecordKey(recordName: row.recordName), key.recordName == row.recordName else {
continue
}
try safety.clearQuarantine(recordName: row.recordName)
}
}
public func acknowledgeLegacyDeletion(_ mutation: CloudSyncMutation) throws {
guard let row = try entry(mutation.recordName),
let data = row.legacyDeletion,
@@ -102,8 +215,67 @@ public final class CloudSyncRecordStateStore {
try deleted(mutation.recordName, version: mutation.version)
}
public func missingRecordNames(seen: Set<String>) throws -> [String] {
try entries().filter { $0.wasPresent && !seen.contains($0.recordName) }.map(\.recordName)
public func recordDeletionHandoff(_ mutation: CloudSyncMutation) throws {
guard mutation.operation == .delete else { return }
let data = try JSONEncoder().encode(mutation.version)
if let row = try handoff(mutation.recordName) {
row.versionData = data
} else {
context.insert(
PersistentCloudSyncDeletionHandoff(
scope: scope, recordName: mutation.recordName, versionData: data))
}
}
public func cancelDeletionHandoff(_ mutation: CloudSyncMutation) throws {
guard let row = try handoff(mutation.recordName),
try JSONDecoder().decode(VaultVersion.self, from: row.versionData) == mutation.version
else { return }
context.delete(row)
}
/// Returns physical deletions from a full inventory and live records that are a
/// newer explicit incarnation than a delete handed off before a crash.
public func missingRecordNames(
seen: Set<String>, records: [CloudSyncJournalRecord],
preservesNewerIncarnation: (String) -> Bool = { _ in true }
) throws -> (deleted: [String], recreated: [String]) {
let missing = try entries().filter { $0.wasPresent && !seen.contains($0.recordName) }.map(\.recordName)
return try reconcileDeletions(
missing, records: records, preservesNewerIncarnation: preservesNewerIncarnation)
}
/// Reconciles incremental deletion increments against the same durable
/// delete-incarnation fence used by a full inventory.
public func reconcileDeletions(
_ recordNames: [String], records: [CloudSyncJournalRecord],
preservesNewerIncarnation: (String) -> Bool = { _ in true }
) throws -> (deleted: [String], recreated: [String]) {
let current = Dictionary(uniqueKeysWithValues: records.map { ($0.mutation.recordName, $0) })
var deleted: [String] = []
var recreated: [String] = []
for recordName in Set(recordNames).sorted() {
let row = try entry(recordName)
let causal = try causalDeletionVersion(recordName, row: row)
if preservesNewerIncarnation(recordName), let causal, let record = current[recordName],
record.mutation.operation == .save, record.needsUpload, causal < record.mutation.version
{
// The deletion confirms only the old handed-off incarnation is gone.
// Forget its cloud metadata; the explicit newer live value remains pending.
let state: PersistentCloudSyncRecordState
if let row {
state = row
} else {
state = try self.state(recordName)
}
state.wasPresent = false
state.confirmedDeletion = try JSONEncoder().encode(causal)
recreated.append(recordName)
} else {
deleted.append(recordName)
}
}
return (deleted, recreated)
}
public func completeReconciliation() throws { try replica().reconciled = true }
@@ -111,9 +283,21 @@ public final class CloudSyncRecordStateStore {
/// An absent zone is a new replica, not a list of user record deletions.
public func resetZone() throws {
for row in try entries() { context.delete(row) }
for row in try handoffs() { context.delete(row) }
try replica().reconciled = false
}
public func purgeScope() throws {
for row in try entries() { context.delete(row) }
for row in try handoffs() { context.delete(row) }
let key = scope.storageKey
for row in try context.fetch(
FetchDescriptor<PersistentCloudSyncReplicaState>(predicate: #Predicate { $0.scopeKey == key }))
{
context.delete(row)
}
}
private func replica() throws -> PersistentCloudSyncReplicaState {
let key = scope.storageKey
if let row = try context.fetch(
@@ -148,4 +332,30 @@ public final class CloudSyncRecordStateStore {
FetchDescriptor<PersistentCloudSyncRecordState>(
predicate: #Predicate { $0.scopeKey == key }))
}
private func handoff(_ name: String) throws -> PersistentCloudSyncDeletionHandoff? {
let key = scope.storageKey
return try context.fetch(
FetchDescriptor<PersistentCloudSyncDeletionHandoff>(
predicate: #Predicate { $0.scopeKey == key && $0.recordName == name })
).first
}
private func handoffs() throws -> [PersistentCloudSyncDeletionHandoff] {
let key = scope.storageKey
return try context.fetch(
FetchDescriptor<PersistentCloudSyncDeletionHandoff>(predicate: #Predicate { $0.scopeKey == key }))
}
private func causalDeletionVersion(
_ recordName: String, row: PersistentCloudSyncRecordState?
) throws -> VaultVersion? {
let confirmed = row?.confirmedDeletion.flatMap {
try? JSONDecoder().decode(VaultVersion.self, from: $0)
}
let handedOff = try handoff(recordName).flatMap {
try JSONDecoder().decode(VaultVersion.self, from: $0.versionData)
}
return [confirmed, handedOff].compactMap { $0 }.max()
}
}
@@ -120,4 +120,24 @@ extension CloudSyncSafetyStore {
return row
}
/// Removes only safety/cache metadata belonging to this exact replica scope.
public func purgeScope() throws {
let key = scope.storageKey
for row in try context.fetch(
FetchDescriptor<PersistentCloudSyncQuarantine>(predicate: #Predicate { $0.scopeKey == key }))
{
context.delete(row)
}
for row in try context.fetch(
FetchDescriptor<PersistentCloudSyncRecovery>(predicate: #Predicate { $0.scopeKey == key }))
{
context.delete(row)
}
for row in try context.fetch(
FetchDescriptor<PersistentSyncCapability>(predicate: #Predicate { $0.scopeKey == key }))
{
context.delete(row)
}
}
}
@@ -32,6 +32,37 @@ public final class HistorySyncPersistence {
}
}
public func purgeCloudCache(
kind: HistoryRecordKind, scope: CloudSyncScope
) throws -> Set<HistoryRecordKey> {
var removed = Set<HistoryRecordKey>()
try context.commit {
let journal = HistorySyncJournal(context: context, kind: kind)
removed = Set(
try journal.cloudRecords().compactMap { HistoryRecordKey(recordName: $0.mutation.recordName) })
switch kind {
case .connectionHistory:
for row in try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()) {
context.delete(row)
}
case .device:
for row in try context.fetch(FetchDescriptor<PersistentDeviceRecord>()) { context.delete(row) }
}
for row in try context.fetch(FetchDescriptor<PersistentHistorySyncEntry>())
where HistoryRecordKey(recordName: row.recordName)?.kind == kind {
context.delete(row)
}
let state = try journal.state()
state.bootstrapCompleted = true
state.engineStateData = nil
state.pendingUserRecordName = nil
state.lastSuccessfulSync = nil
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
try CloudSyncRecordStateStore(context: context, scope: scope).purgeScope()
}
return removed
}
/// A fetched record never echoes back just for being read.
public func applyRemote(
_ envelopes: [HistoryEnvelope], deletions: Set<HistoryRecordKey> = [],
@@ -45,7 +76,11 @@ public final class HistorySyncPersistence {
guard remote.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(remote.key.recordName) }
let local = try journal.envelope(for: remote.key)
let accept: Bool
if remote.key.kind == .connectionHistory, let local, local.isDeleted != remote.isDeleted {
if let local, local.isDeleted, !remote.isDeleted, try journal.needsUpload(remote.key) {
// A delete already handed to CloudKit remains authoritative until its
// acknowledgement. A fetched device can be a late copy of that incarnation.
accept = false
} else if remote.key.kind == .connectionHistory, let local, local.isDeleted != remote.isDeleted {
// Event IDs are never reused. Deletion is terminal even if a delayed
// end-time update has a later clock; otherwise Clear History resurrects.
accept = remote.isDeleted
@@ -227,12 +262,6 @@ public final class HistorySyncJournal {
public func pendingEnvelopes() throws -> [HistoryEnvelope] {
try entries().filter(\.needsUpload).compactMap(\.envelope)
}
public func uploadablePendingRecordNames(in scope: CloudSyncScope) throws -> [String] {
let blocked = try CloudSyncSafetyStore(context: context, scope: scope).blockedRecordNames()
return try entries().filter {
$0.needsUpload && !blocked.contains($0.recordName)
}.map(\.recordName)
}
public func outgoingEntry(for key: HistoryRecordKey, scope: CloudSyncScope) throws -> HistoryOutgoingEntry? {
guard try !CloudSyncSafetyStore(context: context, scope: scope).isUploadBlocked(recordName: key.recordName),
let row = try entry(recordName: key.recordName), row.needsUpload, row.envelope?.isDeleted == false
@@ -243,6 +272,9 @@ public final class HistorySyncJournal {
public func envelope(for key: HistoryRecordKey) throws -> HistoryEnvelope? {
try entry(recordName: key.recordName)?.envelope
}
fileprivate func needsUpload(_ key: HistoryRecordKey) throws -> Bool {
try entry(recordName: key.recordName)?.needsUpload == true
}
public func store(_ envelope: HistoryEnvelope, needsUpload: Bool) throws {
if let row = try entry(recordName: envelope.key.recordName) {
if row.envelope?.isDeleted == true && !envelope.isDeleted { row.systemFieldsData = nil }
@@ -264,6 +296,9 @@ public final class HistorySyncJournal {
guard let row = try entry(recordName: key.recordName), row.systemFieldsData != data else { return }
row.systemFieldsData = data
}
public func clearSystemFields(recordName: String) throws {
try entry(recordName: recordName)?.systemFieldsData = nil
}
public func resetForNewAccount() throws {
for row in try entries() {
@@ -827,6 +827,14 @@ public enum TermindSchemaV37: VersionedSchema {
}
}
/// V38 persists delete-handoff causality without changing the released V37 shapes.
public enum TermindSchemaV38: VersionedSchema {
public static let versionIdentifier = Schema.Version(38, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV37.models + [PersistentCloudSyncDeletionHandoff.self, PersistentCloudSyncAssetCleanup.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -840,6 +848,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV26.self, TermindSchemaV27.self, TermindSchemaV28.self,
TermindSchemaV30.self, TermindSchemaV31.self,
TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self,
TermindSchemaV38.self,
]
}
@@ -936,6 +945,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV34.self, toVersion: TermindSchemaV35.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV35.self, toVersion: TermindSchemaV36.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV36.self, toVersion: TermindSchemaV37.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV37.self, toVersion: TermindSchemaV38.self),
]
}
}
@@ -89,11 +89,6 @@ public final class MetricsSyncJournal {
).compactMap(\.envelope)
}
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<PersistentMetricsSyncQuarantine>()) {
@@ -199,6 +194,10 @@ public final class MetricsSyncJournal {
row.systemFieldsData = data
}
public func clearSystemFields(recordName: String) throws {
try entry(recordName: recordName)?.systemFieldsData = nil
}
public func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentMetricsSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName })
@@ -214,19 +213,6 @@ public final class MetricsSyncJournal {
}
}
public func clearQuarantine(recordName: String) throws {
try context.delete(
model: PersistentMetricsSyncQuarantine.self,
where: #Predicate { $0.recordName == recordName })
}
public func firstQuarantineReason() throws -> String? {
var descriptor = FetchDescriptor<PersistentMetricsSyncQuarantine>(
sortBy: [SortDescriptor(\.receivedAt, order: .reverse)])
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.reason
}
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()) {
guard let envelope = row.envelope else { continue }
@@ -315,6 +301,29 @@ public final class MetricsSyncPersistence: MetricsConfigurationStoring {
didRecordChanges?(mutations)
}
public func purgeCloudCache(
scope: CloudSyncScope, assetCleanupKey: String? = nil
) throws -> (MetricsSyncSnapshot, Set<MetricsRecordKey>) {
var removed = Set<MetricsRecordKey>()
try context.commit {
removed = Set(
try journal.cloudRecords().compactMap { MetricsRecordKey(recordName: $0.mutation.recordName) })
for row in try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()) { context.delete(row) }
let state = try journal.state()
state.bootstrapCompleted = true
state.engineStateData = nil
state.pendingUserRecordName = nil
state.lastSuccessfulSync = nil
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
try CloudSyncRecordStateStore(context: context, scope: scope).purgeScope()
if let assetCleanupKey {
try CloudSyncAssetCleanupStore(context: context).markPending(
scope: scope, cacheKey: assetCleanupKey)
}
}
return (MetricsSyncSnapshot(), removed)
}
public func load() throws -> [MetricsSelection] { try materialize().selections }
public func save(_ selections: [MetricsSelection]) throws {
@@ -382,7 +391,6 @@ extension MetricsSyncJournal {
try context.fetch(FetchDescriptor<PersistentMetricsSyncEntry>()).compactMap { row in
guard let envelope = row.envelope else { return nil }
let key = envelope.key
guard true else { return nil }
return CloudSyncJournalRecord(
recordName: key.recordName, version: envelope.version, isDeleted: envelope.isDeleted,
needsUpload: row.needsUpload, hasSystemFields: row.systemFieldsData != nil)
@@ -391,7 +399,6 @@ extension MetricsSyncJournal {
public func deletionEnvelopes(_ keys: Set<MetricsRecordKey>) throws -> [MetricsEnvelope] {
try keys.compactMap { key in
guard true else { return nil }
let row = try entry(recordName: key.recordName)
// Preserve undecodable local data until a compatible client can reconcile it.
if let row, row.envelope == nil { return nil }
@@ -403,7 +410,7 @@ extension MetricsSyncJournal {
}
public func acknowledgeDeletion(_ mutation: CloudSyncMutation, scope: CloudSyncScope) throws {
guard let key = MetricsRecordKey(recordName: mutation.recordName), true,
guard let key = MetricsRecordKey(recordName: mutation.recordName),
let row = try entry(recordName: mutation.recordName), let envelope = row.envelope
else { return }
if !envelope.isDeleted && row.needsUpload && mutation.version < envelope.version {
@@ -57,10 +57,16 @@ public final class SettingsSyncJournal {
)
}
}
context.delete(legacyRow)
// Preserve tombstone acknowledgement state. The scoped physical-deletion sidecar
// schedules an unconfirmed historical tombstone even when needsUpload is false.
if !legacyEnvelope.isDeleted {
context.delete(legacyRow)
}
try clearQuarantine(recordName: legacyKey.recordName)
let syncState = try state()
syncState.engineStateData = nil
if canonicalChanged || !legacyEnvelope.isDeleted {
let syncState = try state()
syncState.engineStateData = nil
}
return SettingsMutationSet(keys: canonicalChanged ? [canonicalKey] : [])
}
@@ -69,7 +75,7 @@ public final class SettingsSyncJournal {
/// creating a CloudKit deletion.
public func discardInactiveEntries() throws {
let rows = try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).filter {
$0.envelope?.key.kind.participatesInSync == false
$0.envelope.map { !$0.key.kind.participatesInSync && !$0.isDeleted } == true
}
let quarantines = try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncQuarantine>()).filter {
SettingsRecordKey(recordName: $0.recordName)?.kind.participatesInSync == false
@@ -82,6 +88,34 @@ public final class SettingsSyncJournal {
}
}
/// Clears the synchronized replica while retaining non-uploadable tombstone sentinels
/// for singleton defaults that `prepare()` otherwise adds automatically.
public func purgeCloudCache() throws -> [CloudSyncMutation] {
let state = try state()
var suppressed: [CloudSyncMutation] = []
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
context.delete(row)
}
for key in [
SettingsRecordKey(kind: .webSearch, id: "default"),
SettingsRecordKey(kind: .terminal, id: "default"),
SettingsRecordKey(kind: .ssh, id: "default"),
SettingsRecordKey(kind: .sftp, id: "default"),
SettingsRecordKey(kind: .workspace, id: "default"),
] {
let envelope = SettingsEnvelope(
key: key, version: .baseline(writerID: state.installationID), isDeleted: true)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: false))
suppressed.append(
CloudSyncMutation(recordName: key.recordName, operation: .delete, version: envelope.version))
}
state.bootstrapCompleted = true
state.engineStateData = nil
state.pendingUserRecordName = nil
state.lastSuccessfulSync = nil
return suppressed
}
public func state() throws -> PersistentAIConfigurationSyncState {
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncState>()
descriptor.fetchLimit = 1
@@ -152,11 +186,6 @@ public final class SettingsSyncJournal {
).compactMap(\.envelope).filter { $0.key.kind.participatesInSync }
}
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<PersistentAIConfigurationSyncQuarantine>()) {
@@ -265,6 +294,19 @@ public final class SettingsSyncJournal {
row.needsUpload = false
}
public func queueHistoricalDeletion(_ envelope: SettingsEnvelope) throws {
guard envelope.isDeleted,
!envelope.key.kind.participatesInSync || envelope.key.isLegacyGuardianRole
else { return }
var entries = try entriesByName()
try store(envelope, needsUpload: true, in: &entries)
try updateSystemFields(nil, for: envelope.key)
}
public func clearSystemFields(recordName: String) throws {
try entry(recordName: recordName)?.systemFieldsData = nil
}
public func updateSystemFields(_ data: Data?, for key: SettingsRecordKey) throws {
guard let row = try entry(recordName: key.recordName), row.systemFieldsData != data else { return }
row.systemFieldsData = data
@@ -346,7 +388,7 @@ public final class SettingsSyncJournal {
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
guard let envelope = row.envelope else { continue }
guard envelope.key.kind.participatesInSync else {
row.needsUpload = false
context.delete(row)
continue
}
if envelope.isDeleted {
@@ -431,7 +473,7 @@ extension SettingsSyncJournal {
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).compactMap { row in
guard let envelope = row.envelope else { return nil }
let key = envelope.key
guard key.kind.participatesInSync else { return nil }
guard key.kind.participatesInSync || envelope.isDeleted else { return nil }
return CloudSyncJournalRecord(
recordName: key.recordName, version: envelope.version, isDeleted: envelope.isDeleted,
needsUpload: row.needsUpload, hasSystemFields: row.systemFieldsData != nil)
@@ -452,9 +494,10 @@ extension SettingsSyncJournal {
}
public func acknowledgeDeletion(_ mutation: CloudSyncMutation, scope: CloudSyncScope) throws {
guard let key = SettingsRecordKey(recordName: mutation.recordName), key.kind.participatesInSync,
guard let key = SettingsRecordKey(recordName: mutation.recordName),
let row = try entry(recordName: mutation.recordName), let envelope = row.envelope
else { return }
guard key.kind.participatesInSync || envelope.isDeleted else { return }
if !envelope.isDeleted && row.needsUpload && mutation.version < envelope.version {
// A later explicit recreation is still pending; acknowledge only the old server incarnation.
try updateSystemFields(nil, for: key)
@@ -97,6 +97,41 @@ public final class SettingsSyncPersistence {
didRecordChanges?(mutations)
}
public func purgeCloudCache(
scope: CloudSyncScope, assetCleanupKey: String? = nil
) throws -> (SettingsSyncSnapshot, Set<SettingsRecordKey>) {
var removed = Set<SettingsRecordKey>()
var snapshot = try load()
try context.commit {
removed = Set(
try journal.cloudRecords().compactMap { SettingsRecordKey(recordName: $0.mutation.recordName) })
let suppressed = try journal.purgeCloudCache()
snapshot = try materialize()
try ai.replace(snapshot.configuration)
try skills.replace(snapshot.skills)
try preferences().apply(snapshot.webSearch)
try terminalPreferences().apply(snapshot.terminal)
try sshPreferences().apply(snapshot.ssh)
try sftpPreferences().apply(snapshot.sftp)
try workspacePreferences().apply(snapshot.workspace)
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
let cloud = CloudSyncRecordStateStore(context: context, scope: scope)
try cloud.purgeScope()
for mutation in suppressed {
try cloud.deleted(mutation.recordName, version: mutation.version)
}
if let assetCleanupKey {
try CloudSyncAssetCleanupStore(context: context).markPending(
scope: scope, cacheKey: assetCleanupKey)
}
}
mirror(snapshot.webSearch)
snapshot.terminal.mirror(to: defaults)
snapshot.ssh.mirror(to: defaults)
snapshot.sftp.mirror(to: defaults)
return (snapshot, removed)
}
public func load() throws -> SettingsSyncSnapshot {
SettingsSyncSnapshot(
configuration: try ai.load(preservingUnresolvedReferences: true),
@@ -220,15 +220,9 @@ public final class VaultSyncJournal {
).compactMap(\.envelope).filter { $0.key.kind.isConfiguration }.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>())
where HistoryRecordKey(recordName: row.recordName) == nil {
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)
}
@@ -333,6 +327,10 @@ public final class VaultSyncJournal {
row.systemFieldsData = data
}
public func clearSystemFields(recordName: String) throws {
try entry(recordName: recordName)?.systemFieldsData = nil
}
public func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentVaultSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName }
@@ -350,21 +348,6 @@ public final class VaultSyncJournal {
}
}
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
{
@@ -105,6 +105,39 @@ public final class HostPersistence {
try VaultSyncJournal(context: context).modificationDates()
}
/// Applies an account-owner CloudKit purge as one local persistence transaction.
/// Installation identity and account binding remain stable, while no old snapshot
/// or journal row remains available for bootstrap/re-upload.
public func purgeCloudCache(
scope: CloudSyncScope, assetCleanupKey: String? = nil
) throws -> (VaultSnapshot, Set<VaultRecordKey>) {
var removed = Set<VaultRecordKey>()
do {
try context.commit {
let journal = VaultSyncJournal(context: context)
removed = Set(
try journal.cloudRecords().compactMap { VaultRecordKey(recordName: $0.mutation.recordName) })
try persist(VaultSnapshot())
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) { context.delete(row) }
let state = try journal.state()
state.bootstrapCompleted = true
state.engineStateData = nil
state.pendingUserRecordName = nil
state.lastSuccessfulSync = nil
try CloudSyncSafetyStore(context: context, scope: scope).purgeScope()
try CloudSyncRecordStateStore(context: context, scope: scope).purgeScope()
if let assetCleanupKey {
try CloudSyncAssetCleanupStore(context: context).markPending(
scope: scope, cacheKey: assetCleanupKey)
}
}
return (VaultSnapshot(), removed)
} catch {
context.rollback()
throw error
}
}
private func persist(_ snapshot: VaultSnapshot) throws {
try reconcile(
try context.fetch(FetchDescriptor<PersistentHost>()), with: snapshot.hosts,
@@ -6,7 +6,7 @@ import SwiftData
@MainActor
public enum TermindPersistence {
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV37.self)
public static let schema = Schema(versionedSchema: TermindSchemaV38.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
+1 -1
View File
@@ -229,7 +229,7 @@ struct ArchitectureBoundaryTests {
let recordReceiving = try String(
contentsOf: root.appending(path: "CloudSyncRecordReceiving.swift"), encoding: .utf8)
#expect(lifecycle.contains("maximumRecordsPerRequest = 250"))
#expect(lifecycle.contains("records.count == Self.maximumRecordsPerRequest"))
#expect(lifecycle.contains("records.count + deletions.count == Self.maximumRecordsPerRequest"))
#expect(!lifecycle.split(separator: "\n").contains("import SwiftData"))
#expect(!lifecycle.split(separator: "\n").contains("import TermindPersistence"))
#expect(!eventHandling.split(separator: "\n").contains("import SwiftData"))
+55
View File
@@ -356,6 +356,61 @@ struct SettingsSyncTests {
#expect(try store.journal.firstQuarantineReason() == nil)
}
@Test
func historicalSourceTombstonesSurvivePrepareAcknowledgeAndReopenWithoutLooping() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("SettingsSources-\(UUID())")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
let defaults = UserDefaults(suiteName: "SettingsSources.\(UUID())")!
let writer = UUID()
let legacy = SettingsEnvelope(
key: .init(kind: .role, id: SettingsRecordKey.legacySecurityReviewID),
version: .baseline(writerID: writer), isDeleted: true)
let selection = SettingsEnvelope(
key: .init(kind: .selection, id: "default"),
version: .baseline(writerID: writer), isDeleted: true)
let names = Set([legacy.key.recordName, selection.key.recordName])
do {
let container = try TermindPersistence.makeContainer(url: url)
container.mainContext.insert(try PersistentAIConfigurationSyncEntry(envelope: legacy, needsUpload: false))
container.mainContext.insert(
try PersistentAIConfigurationSyncEntry(envelope: selection, needsUpload: false))
try container.mainContext.save()
try SettingsSyncPersistence(context: container.mainContext, defaults: defaults).prepare()
#expect(
Set(try SettingsSyncJournal(context: container.mainContext).cloudRecords().map(\.mutation.recordName))
.isSuperset(of: names))
}
let scope = CloudSyncScope(
container: "iCloud.test", environment: "Production", account: "account",
zone: SettingsSyncCoordinator.zoneName, owner: CKCurrentUserDefaultName)
do {
let container = try TermindPersistence.makeContainer(url: url)
let journal = SettingsSyncJournal(context: container.mainContext)
let cloud = CloudSyncRecordStateStore(context: container.mainContext, scope: scope)
let pending = try cloud.pendingChanges(journal.cloudRecords()).filter { names.contains($0.recordName) }
#expect(Set(pending.map(\.recordName)) == names)
for mutation in pending { try journal.acknowledgeDeletion(mutation, scope: scope) }
try container.mainContext.save()
}
do {
let container = try TermindPersistence.makeContainer(url: url)
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let cloud = CloudSyncRecordStateStore(context: container.mainContext, scope: scope)
#expect(try names.isDisjoint(with: cloud.pendingChanges(store.journal.cloudRecords()).map(\.recordName)))
for envelope in [legacy, selection] {
try cloud.observed(envelope.key.recordName)
try store.journal.queueHistoricalDeletion(envelope)
}
try container.mainContext.save()
#expect(
try Set(cloud.pendingChanges(store.journal.cloudRecords()).map(\.recordName)).isSuperset(of: names))
}
}
@Test
func localEditsAreJournaledWhileDisabledAndNoOpsDoNotAdvanceVersions() throws {
let (container, store, defaults) = try fixture()