feat: delete old history and devices
This commit is contained in:
1 parent
3250aa55fa
commit
60d8822df9
24 files changed
+2231
-210
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user