diff --git a/FrameworkTests/CloudSyncLifecycleTests.swift b/FrameworkTests/CloudSyncLifecycleTests.swift index 272bca12..c50be78b 100644 --- a/FrameworkTests/CloudSyncLifecycleTests.swift +++ b/FrameworkTests/CloudSyncLifecycleTests.swift @@ -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 diff --git a/FrameworkTests/CloudSyncPhysicalDeletionTests.swift b/FrameworkTests/CloudSyncPhysicalDeletionTests.swift index dea7f2e2..32336292 100644 --- a/FrameworkTests/CloudSyncPhysicalDeletionTests.swift +++ b/FrameworkTests/CloudSyncPhysicalDeletionTests.swift @@ -10,6 +10,487 @@ import Testing @Suite @MainActor struct CloudSyncPhysicalDeletionTests { + @Test(arguments: SyncSafetyCategory.allCases) + func destructiveZoneReasonsPurgeOnlyTheActiveReplicaAndCannotResendOldSnapshot( + category: SyncSafetyCategory + ) async throws { + for reason in [CloudSyncZoneDeletionReason.purged, .encryptedDataReset] { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let records = try fixture.recordIDs.map { try fixture.newerRecord($0) } + try fixture.receive(records, []) + #expect(try fixture.projectionIsPopulated()) + #expect(fixture.remoteApplyCount == 1) + let scope = try #require(fixture.lifecycle.scope) + let safety = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: scope) + try safety.quarantine(recordName: "active-only", payloadData: Data([1]), reason: "purge") + var otherScope = scope + otherScope.account = "other-account" + let other = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: otherScope) + try other.quarantine(recordName: "active-only", payloadData: Data([2]), reason: "keep") + try fixture.container.mainContext.save() + let engine = fixture.network.nextEngine + + fixture.lifecycle.handle( + .fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, reason)]), engine: engine, + receiveRemoteRecords: { try fixture.receive($0, $1) }, + handleSentChanges: { try fixture.sent($0) }) + + #expect(fixture.lifecycle.engine == nil) + #expect(try fixture.projectionIsCleared()) + #expect(fixture.remoteApplyCount == 2) + #expect(fixture.remoteApplyObservedCleared) + #expect(try safety.blockedRecordNames().isEmpty) + #expect(try other.entry(recordName: "active-only")?.payloadData == Data([2])) + fixture.network.nextEngine = LifecycleEngine() + fixture.lifecycle.start() + await fixture.lifecycle.operation?.value + let pending = try fixture.lifecycle.store.pendingChanges(try #require(fixture.lifecycle.scope)) + #expect(Set(pending.map(\.recordName)).isDisjoint(with: fixture.recordIDs.map(\.recordName))) + #expect( + fixture.batch( + fixture.recordIDs.flatMap { [.saveRecord($0), .deleteRecord($0)] }) == nil) + } + } + + @Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics]) + func destructivePurgeRemovesOnlyTheCategoryOwnedLargePayloadAssets( + category: SyncSafetyCategory + ) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = try CloudSyncSafetyFixture(category: category, assetCacheRoot: root) + defer { fixture.stop() } + try await fixture.start() + + let directoryName = try #require(Self.assetDirectoryName(for: category)) + let assetURL = try CloudSyncRecordCoding.assetURL( + recordName: fixture.recordIDs[0].recordName, directoryName: directoryName, cacheRoot: root) + let record = CKRecord(recordType: "LargePayload", recordID: fixture.recordIDs[0]) + try CloudSyncRecordCoding.setPayload( + Data(repeating: 0x5a, count: CloudSyncRecordCoding.inlinePayloadLimit + 1), + on: record, assetURL: assetURL) + #expect(record["payloadAsset"] is CKAsset) + #expect(FileManager.default.fileExists(atPath: assetURL.path)) + + let unrelated = root.appending(path: "Unrelated/keep") + try FileManager.default.createDirectory( + at: unrelated.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data([1]).write(to: unrelated) + + let engine = fixture.network.nextEngine + fixture.lifecycle.handle( + .fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .purged)]), engine: engine, + receiveRemoteRecords: { try fixture.receive($0, $1) }, + handleSentChanges: { try fixture.sent($0) }) + + #expect(!FileManager.default.fileExists(atPath: assetURL.deletingLastPathComponent().path)) + #expect(FileManager.default.fileExists(atPath: unrelated.path)) + } + + @Test + func assetPurgeFailureRetiresSyncAfterDurableProjectionPurge() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let root = directory.appending(path: "Caches") + let storeURL = directory.appendingPathComponent("AssetCleanup.store") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let assetURL = try CloudSyncRecordCoding.assetURL( + recordName: "large/payload", + directoryName: VaultSyncCoordinator.assetDirectoryName, cacheRoot: root) + let assetID = CKRecord.ID(recordName: "large/payload") + let record = CKRecord(recordType: "LargePayload", recordID: assetID) + try CloudSyncRecordCoding.setPayload( + Data(repeating: 0x5a, count: CloudSyncRecordCoding.inlinePayloadLimit + 1), + on: record, assetURL: assetURL) + var savedScope: CloudSyncScope? + + do { + let fixture = try CloudSyncSafetyFixture( + category: .vault, url: storeURL, assetCacheRoot: root, + removeAssetDirectory: { _ in throw CocoaError(.fileWriteUnknown) }) + try await fixture.start() + let records = try fixture.recordIDs.map { try fixture.newerRecord($0) } + try fixture.receive(records, []) + #expect(try fixture.projectionIsPopulated()) + let scope = try #require(fixture.lifecycle.scope) + savedScope = scope + + let engine = fixture.network.nextEngine + fixture.lifecycle.handle( + .fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .purged)]), engine: engine, + receiveRemoteRecords: { try fixture.receive($0, $1) }, + handleSentChanges: { try fixture.sent($0) }) + + #expect(fixture.lifecycle.engine == nil) + #expect(try fixture.projectionIsCleared()) + #expect(FileManager.default.fileExists(atPath: assetURL.path)) + #expect(fixture.remoteApplyCount == 2) + #expect(fixture.remoteApplyObservedCleared) + #expect(try fixture.lifecycle.store.pendingChanges(scope).isEmpty) + #expect( + try CloudSyncAssetCleanupStore(context: fixture.container.mainContext).isPending( + scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName)) + fixture.stop() + } + + let scope = try #require(savedScope) + do { + let failedRestart = try CloudSyncSafetyFixture( + category: .vault, url: storeURL, seed: false, assetCacheRoot: root, + removeAssetDirectory: { _ in throw CocoaError(.fileWriteUnknown) }) + failedRestart.lifecycle.start() + await failedRestart.lifecycle.operation?.value + #expect(failedRestart.lifecycle.engine == nil) + #expect(!failedRestart.lifecycle.isReadyToSend) + let isUpToDate = if case .upToDate = failedRestart.lifecycle.status { true } else { false } + #expect(!isUpToDate) + #expect(FileManager.default.fileExists(atPath: assetURL.path)) + #expect( + try CloudSyncAssetCleanupStore(context: failedRestart.container.mainContext).isPending( + scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName)) + #expect(failedRestart.batch(failedRestart.recordIDs.map { .saveRecord($0) }) == nil) + failedRestart.stop() + } + + let recovered = try CloudSyncSafetyFixture( + category: .vault, url: storeURL, seed: false, assetCacheRoot: root) + defer { recovered.stop() } + let changedBinding = try VaultSyncJournal(context: recovered.container.mainContext).state() + changedBinding.boundUserRecordName = "previous-account" + recovered.defaults.set("PreviousEnvironment", forKey: SettingsKey.vaultSyncEnvironment) + try recovered.container.mainContext.save() + recovered.lifecycle.start() + await recovered.lifecycle.operation?.value + #expect(!FileManager.default.fileExists(atPath: assetURL.deletingLastPathComponent().path)) + #expect( + try !CloudSyncAssetCleanupStore(context: recovered.container.mainContext).isPending( + scope: scope, cacheKey: VaultSyncCoordinator.assetDirectoryName)) + #expect(recovered.lifecycle.status == .accountChangeConfirmationRequired) + #expect(recovered.lifecycle.engine == nil) + + recovered.lifecycle.confirmAccountSwitchMerge() + await recovered.lifecycle.operation?.value + #expect(recovered.lifecycle.isReadyToSend) + #expect(recovered.batch(recovered.recordIDs.map { .saveRecord($0) }) == nil) + } + + @Test + func vaultPurgeRemovesAttributedLegacyHistoryAndDeviceQuarantineOnlyInActiveScope() async throws { + let fixture = try CloudSyncSafetyFixture(category: .vault) + defer { fixture.stop() } + let state = try VaultSyncJournal(context: fixture.container.mainContext).state() + state.boundUserRecordName = "current-user" + fixture.defaults.set("Test", forKey: SettingsKey.vaultSyncEnvironment) + let legacyNames = [ + HistoryRecordKey(kind: .connectionHistory, id: UUID()).recordName, + HistoryRecordKey(kind: .device, id: UUID()).recordName, + ] + for name in legacyNames { + fixture.container.mainContext.insert( + PersistentVaultSyncQuarantine( + recordName: name, payloadData: Data(repeating: 7, count: 32), reason: "legacy")) + } + try fixture.container.mainContext.save() + try await fixture.start() + let scope = try #require(fixture.lifecycle.scope) + let safety = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: scope) + #expect( + try fixture.container.mainContext.fetchCount(FetchDescriptor()) == 0) + for name in legacyNames { #expect(try safety.entry(recordName: name) != nil) } + + var otherScope = scope + otherScope.account = "other-account" + let other = CloudSyncSafetyStore(context: fixture.container.mainContext, scope: otherScope) + try other.quarantine(recordName: legacyNames[0], payloadData: Data([9]), reason: "other") + try fixture.container.mainContext.save() + + let engine = fixture.network.nextEngine + fixture.lifecycle.handle( + .fetchedDatabaseChanges(deletions: [(fixture.lifecycle.zoneID, .encryptedDataReset)]), engine: engine, + receiveRemoteRecords: { try fixture.receive($0, $1) }, + handleSentChanges: { try fixture.sent($0) }) + + for name in legacyNames { #expect(try safety.entry(recordName: name) == nil) } + #expect(try other.entry(recordName: legacyNames[0])?.payloadData == Data([9])) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func restartAfterHandedOffDeleteKeepsExplicitNewIncarnationsAndTerminalHistory( + category: SyncSafetyCategory + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("DeleteHandoff.store") + let recordName: String + + do { + let fixture = try CloudSyncSafetyFixture(category: category, url: url) + try await fixture.start() + let id = fixture.recordIDs[0] + recordName = id.recordName + try fixture.receive([try fixture.newerRecord(id)], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + let batch = try #require(fixture.batch([.saveRecord(id), .deleteRecord(id)])) + #expect(batch.recordsToSave.isEmpty) + #expect(batch.recordIDsToDelete == [id]) + fixture.stop() + } + + let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false) + defer { reopened.stop() } + let recreated = try reopened.recreateFirst() + try await reopened.start() + let id = reopened.recordIDs[0] + #expect(id.recordName == recordName) + let journalRecord = try #require( + reopened.cloudRecords().first { $0.mutation.recordName == recordName }) + + if category == .history { + #expect(!recreated) + #expect(journalRecord.mutation.operation == .delete) + #expect(reopened.record(id) == nil) + } else { + #expect(recreated) + #expect(journalRecord.mutation.operation == .save) + #expect(journalRecord.needsUpload) + #expect(!journalRecord.hasSystemFields) + let batch = try #require(reopened.batch([.saveRecord(id), .deleteRecord(id)])) + #expect(batch.recordsToSave.map(\.recordID) == [id]) + #expect(batch.recordIDsToDelete.isEmpty) + } + } + + @Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device]) + func staleSaveAndDeleteAcknowledgementsCannotEraseARecreatedIncarnation( + category: SyncSafetyCategory + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("StaleAcknowledgement.store") + + do { + let fixture = try CloudSyncSafetyFixture(category: category, url: url) + try await fixture.start() + let id = fixture.recordIDs[0] + let delayedSave = try #require(fixture.record(id)) + try fixture.receive([delayedSave], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + #expect(try fixture.recreateFirst()) + try fixture.lifecycle.reconcilePendingChanges() + try fixture.sent( + .init(savedRecords: [delayedSave], failedRecordSaves: [], deletedRecordIDs: [id])) + fixture.stop() + } + + let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false) + defer { reopened.stop() } + try await reopened.start() + let id = reopened.recordIDs[0] + let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(row.mutation.operation == .save) + #expect(row.needsUpload) + #expect(reopened.record(id) != nil) + } + + @Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device]) + func acknowledgedRecreationMakesLaterEditsSubjectToRealRemoteDeletion( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + try fixture.receive([try #require(fixture.record(id))], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + try fixture.sent( + .init( + savedRecords: [], failedRecordSaves: [], + failedRecordDeletes: [id: CKError(.networkFailure)])) + + #expect(try fixture.recreateFirst()) + try fixture.lifecycle.reconcilePendingChanges() + let recreated = try #require(fixture.batch([.saveRecord(id)])) + #expect(recreated.recordsToSave.map(\.recordID) == [id]) + #expect(try fixture.editFirstAgain()) + try fixture.lifecycle.reconcilePendingChanges() + + try fixture.sent(.init(savedRecords: recreated.recordsToSave, failedRecordSaves: [])) + try fixture.receive([], [id]) + + let row = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(row.mutation.operation == .delete) + #expect(fixture.record(id) == nil) + #expect(fixture.batch([.saveRecord(id)]) == nil) + } + + @Test(arguments: [SyncSafetyCategory.vault, .settings, .metrics, .device]) + func ambiguousDeleteFailureKeepsRecreatedIncarnationAcrossRestart( + category: SyncSafetyCategory + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("AmbiguousDelete.store") + + do { + let fixture = try CloudSyncSafetyFixture(category: category, url: url) + try await fixture.start() + let id = fixture.recordIDs[0] + try fixture.receive([try #require(fixture.record(id))], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + #expect(try fixture.recreateFirst()) + try fixture.lifecycle.reconcilePendingChanges() + try fixture.sent( + .init( + savedRecords: [], failedRecordSaves: [], + failedRecordDeletes: [id: CKError(.networkFailure)])) + fixture.stop() + } + + let reopened = try CloudSyncSafetyFixture(category: category, url: url, seed: false) + defer { reopened.stop() } + try await reopened.start() + let id = reopened.recordIDs[0] + let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(row.mutation.operation == .save) + #expect(row.needsUpload) + #expect(reopened.record(id) != nil) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func incrementalDeletionConfirmsOnlyTheHandedOffIncarnation(category: SyncSafetyCategory) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + try fixture.receive([try #require(fixture.record(id))], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + let recreated = try fixture.recreateFirst() + try fixture.lifecycle.reconcilePendingChanges() + + try fixture.receive([], [id]) + try fixture.receive([], [id]) + let row = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }) + if category == .history { + #expect(!recreated) + #expect(row.mutation.operation == .delete) + #expect(fixture.record(id) == nil) + } else { + #expect(recreated) + #expect(row.mutation.operation == .save) + #expect(row.needsUpload) + #expect(fixture.record(id) != nil) + } + } + + @Test(arguments: SyncSafetyCategory.allCases) + func confirmedTombstoneReobservationSchedulesAnotherPhysicalDelete( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let tombstone = try Self.tombstone(try #require(fixture.record(id))) + try fixture.receive([tombstone], []) + let first = try #require(fixture.batch([.deleteRecord(id)])) + try fixture.sent( + .init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: first.recordIDsToDelete)) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + + try fixture.receive([tombstone], []) + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + } + + @Test + func unknownItemForOldSettingsSavePreservesRecreatedIncarnationAcrossRestart() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("UnknownItem.store") + + do { + let fixture = try CloudSyncSafetyFixture(category: .settings, url: url) + try await fixture.start() + let id = fixture.recordIDs[0] + let delayedSave = try #require(fixture.record(id)) + try fixture.receive([delayedSave], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + #expect(try fixture.recreateFirst()) + try fixture.lifecycle.reconcilePendingChanges() + try fixture.sent( + .init( + savedRecords: [], + failedRecordSaves: [.init(record: delayedSave, error: CKError(.unknownItem))])) + fixture.stop() + } + + let reopened = try CloudSyncSafetyFixture(category: .settings, url: url, seed: false) + defer { reopened.stop() } + try await reopened.start() + let id = reopened.recordIDs[0] + let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(row.mutation.operation == .save) + #expect(row.needsUpload) + #expect(reopened.record(id) != nil) + } + + @Test(arguments: [false, true]) + func oldLiveAfterIncrementalDeleteCannotResetNewIncarnationEvidence( + asSaveAcknowledgement: Bool + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("OldLiveAfterDelete.store") + + do { + let fixture = try CloudSyncSafetyFixture(category: .settings, url: url) + try await fixture.start() + let id = fixture.recordIDs[0] + let oldLive = try #require(fixture.record(id)) + try fixture.receive([oldLive], []) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + #expect(try fixture.recreateFirst()) + try fixture.lifecycle.reconcilePendingChanges() + try fixture.receive([], [id]) + if asSaveAcknowledgement { + try fixture.sent(.init(savedRecords: [oldLive], failedRecordSaves: [])) + } else { + try fixture.receive([oldLive], []) + } + fixture.stop() + } + + let reopened = try CloudSyncSafetyFixture(category: .settings, url: url, seed: false) + defer { reopened.stop() } + try await reopened.start() + let id = reopened.recordIDs[0] + let row = try #require(reopened.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(row.mutation.operation == .save) + #expect(row.needsUpload) + #expect(reopened.record(id) != nil) + } + @Test(arguments: SyncSafetyCategory.allCases) func remoteDeletionCancelsPendingUpdatesAndSurvivesRestart(category: SyncSafetyCategory) async throws { let fixture = try CloudSyncSafetyFixture(category: category) @@ -47,6 +528,7 @@ struct CloudSyncPhysicalDeletionTests { #expect(batch.recordsToSave.isEmpty) #expect(batch.recordIDsToDelete == [id]) #expect(fixture.record(id) == nil) + #expect(fixture.batch([.deleteRecord(id)]) == nil) try fixture.sent( .init(savedRecords: [], failedRecordSaves: [], failedRecordDeletes: [id: CKError(.networkFailure)])) @@ -60,6 +542,48 @@ struct CloudSyncPhysicalDeletionTests { #expect(fixture.batch([.deleteRecord(id)]) == nil) } + @Test(arguments: SyncSafetyCategory.allCases) + func sendCompletionWithoutARecordResultReleasesPhysicalDeleteForRetry( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + try fixture.lifecycle.beginSend() + _ = try #require(fixture.batch([.deleteRecord(id)])) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + + try fixture.lifecycle.finishSend(succeeded: false) + + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func overlappingSendsReleasePhysicalDeleteOnlyAfterTheLastCompletion( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + try fixture.lifecycle.beginSend() + try fixture.lifecycle.beginSend() + _ = try #require(fixture.batch([.deleteRecord(id)])) + + try fixture.lifecycle.finishSend(succeeded: false) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + + try fixture.lifecycle.finishSend(succeeded: false) + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + } + @Test(arguments: SyncSafetyCategory.allCases) func lateSaveAcknowledgementCannotUndoDeletion(category: SyncSafetyCategory) async throws { let fixture = try CloudSyncSafetyFixture(category: category) @@ -76,6 +600,181 @@ struct CloudSyncPhysicalDeletionTests { #expect(fixture.batch([.deleteRecord(id)]) == nil) } + @Test(arguments: SyncSafetyCategory.allCases) + func fetchedRecordObservedAfterDeleteHandoffRequiresAnotherPhysicalDelete( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let delayedRecord = try #require(fixture.record(id)) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + let firstDelete = try #require(fixture.batch([.deleteRecord(id)])) + #expect(firstDelete.recordIDsToDelete == [id]) + + try fixture.receive([delayedRecord], []) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(fixture.record(id) == nil) + let scope = try #require(fixture.lifecycle.scope) + #expect(try fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName)) + + // A replayed acknowledgement has no handed-off operation to acknowledge. + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(try fixture.lifecycle.store.pendingRecordNames(scope).contains(id.recordName)) + fixture.lifecycle.pause() + fixture.network.nextEngine = LifecycleEngine() + fixture.network.nextEngine.fetchBody = { try fixture.receive([delayedRecord], []) } + try await fixture.start() + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func fetchedDeletionWinsOverSameBatchRecordDuringDeleteHandoff( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let staleRecord = try #require(fixture.record(id)) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + + try fixture.receive([staleRecord], [id]) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + + #expect(fixture.record(id) == nil) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func savedRecordObservedAfterDeleteHandoffRequiresAnotherPhysicalDelete( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let delayedSave = try #require(fixture.record(id)) + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + + try fixture.sent(.init(savedRecords: [delayedSave], failedRecordSaves: [])) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(fixture.record(id) == nil) + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(fixture.batch([.deleteRecord(id)]) == nil) + } + + @Test(arguments: SyncSafetyCategory.allCases) + func olderAndEqualLiveRecordsCannotReviveAConfirmedPhysicalDeletion( + category: SyncSafetyCategory + ) async throws { + for (useRemoteDeletion, useEqualVersion) in [ + (false, false), (false, true), (true, false), (true, true), + ] { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + let id = fixture.recordIDs[0] + try await fixture.start() + let original = try #require(fixture.record(id)) + if useRemoteDeletion { + try fixture.receive([], [id]) + } else { + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + } + let deletion = try #require( + fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation) + + let delayed = + try useEqualVersion + ? Self.liveRecord(original, version: deletion.version) + : fixture.olderRecord(id) + try fixture.receive([delayed], []) + let local = try #require( + fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }) + #expect(local.mutation.operation == .delete) + #expect(try fixture.pendingNames().contains(id.recordName)) + // An ID-only result that has no handed-off delete cannot clear this retry. + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(try fixture.pendingNames().contains(id.recordName)) + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + } + } + + @Test(arguments: SyncSafetyCategory.allCases) + func trulyNewerLiveRecordFollowsTheCategoryProtocolAfterConfirmedDeletion( + category: SyncSafetyCategory + ) async throws { + for useRemoteDeletion in [false, true] { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let original = try #require(fixture.record(id)) + if useRemoteDeletion { + try fixture.receive([], [id]) + } else { + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + } + let deletion = try #require( + fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }?.mutation) + var newerVersion = deletion.version + newerVersion.counter += 1 + + try fixture.receive([try Self.liveRecord(original, version: newerVersion)], []) + + let local = try #require(fixture.cloudRecords().first { $0.mutation.recordName == id.recordName }) + if category == .history { + #expect(local.mutation.operation == .delete) + #expect(try fixture.pendingNames().contains(id.recordName)) + } else { + #expect(local.mutation.operation == .save) + #expect(try !fixture.pendingNames().contains(id.recordName)) + } + } + } + + @Test(arguments: SyncSafetyCategory.allCases) + func quarantinedObservationReleasesCompletedDeleteBeforeSupportedRecovery( + category: SyncSafetyCategory + ) async throws { + let fixture = try CloudSyncSafetyFixture(category: category) + defer { fixture.stop() } + try await fixture.start() + let id = fixture.recordIDs[0] + let supported = try #require(fixture.record(id)) + let future = try fixture.futureRecord() + try fixture.markTombstone() + try fixture.lifecycle.reconcilePendingChanges() + _ = try #require(fixture.batch([.deleteRecord(id)])) + + try fixture.receive([future], []) + #expect(try fixture.safety().isUploadBlocked(recordName: id.recordName)) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + try fixture.receive([supported], []) + #expect(try !fixture.safety().isUploadBlocked(recordName: id.recordName)) + let retry = try #require(fixture.batch([.deleteRecord(id)])) + #expect(retry.recordIDsToDelete == [id]) + } + @Test(arguments: SyncSafetyCategory.allCases) func oldCloudTombstoneIsDeletedEvenWhenItsJournalWasAcknowledged(category: SyncSafetyCategory) async throws { let fixture = try CloudSyncSafetyFixture(category: category) @@ -251,15 +950,29 @@ struct CloudSyncPhysicalDeletionTests { record["payload"] = Data([0xff]) as NSData return record } + let safety = try fixture.safety() + for record in oldRecords { + try safety.quarantine( + recordName: record.recordID.recordName, payloadData: Data([0xff]), reason: "Retired copy") + } + try context.save() try fixture.receive(oldRecords, []) try fixture.receive([], oldRecords.map(\.recordID)) #expect(try context.fetchCount(FetchDescriptor()) == 1) #expect(try context.fetchCount(FetchDescriptor()) == 1) #expect(fixture.remoteApplyCount == 0) + #expect(try safety.blockedRecordNames().isEmpty) let batch = try #require(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) })) #expect(Set(batch.recordIDsToDelete) == Set(oldRecords.map(\.recordID))) #expect(batch.recordsToSave.isEmpty) + // If the obsolete copies reappear after handoff, the first result cannot retire + // the newly observed cleanup requirement even when ignored old-zone deletion events + // share that fetched batch and the journal version is unchanged. + try fixture.receive(oldRecords, oldRecords.map(\.recordID)) try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: batch.recordIDsToDelete)) + let retry = try #require(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) })) + #expect(Set(retry.recordIDsToDelete) == Set(oldRecords.map(\.recordID))) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: retry.recordIDsToDelete)) #expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil) let activeZone = CKRecordZone.ID(zoneName: ConnectionHistorySyncCoordinator.zoneName) @@ -274,6 +987,113 @@ struct CloudSyncPhysicalDeletionTests { #expect(try context.fetchCount(FetchDescriptor()) == 1) } + @Test + func readableLegacyVaultHistoryAndDeviceCopiesAreDurablyDeletedForLiveAndTombstoneStates() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("LegacyCopies.store") + var acknowledgedScope: CloudSyncScope? + var recordNames = Set() + + do { + let fixture = try CloudSyncSafetyFixture(category: .vault, url: url) + try await fixture.start() + let context = fixture.container.mainContext + let cases: [(HistoryRecordPayload, Bool)] = [ + (.connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .ssh)), false), + (.connectionHistory(ConnectionHistoryEntry(hostID: UUID(), kind: .sftp)), true), + (.device(DeviceRecord(id: UUID(), displayName: "Live legacy device")), false), + (.device(DeviceRecord(id: UUID(), displayName: "Deleted legacy device")), true), + ] + let version = VaultVersion.baseline(writerID: UUID()) + let current = cases.map { HistoryEnvelope(key: $0.0.key, version: version, payload: $0.0) } + _ = try HistorySyncPersistence(context: context).applyRemote(current) + let oldRecords = try cases.map { payload, isDeleted in + let oldPayload: VaultRecordPayload + switch payload { + case .connectionHistory(let value): oldPayload = .connectionHistory(value) + case .device(let value): oldPayload = .device(value) + } + let envelope = VaultEnvelope( + key: oldPayload.key, version: version, isDeleted: isDeleted, + payload: isDeleted ? nil : oldPayload) + return try VaultSyncCoordinator.makeRecord( + .init(envelope: envelope), + recordID: .init(recordName: envelope.key.recordName, zoneID: fixture.lifecycle.zoneID)) + } + recordNames = Set(oldRecords.map(\.recordID.recordName)) + + try fixture.receive(oldRecords, []) + try fixture.receive([], oldRecords.map(\.recordID)) + #expect(try context.fetchCount(FetchDescriptor()) == 2) + #expect(try context.fetchCount(FetchDescriptor()) == 2) + #expect(fixture.remoteApplyCount == 0) + let first = try #require(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) })) + #expect(Set(first.recordIDsToDelete.map(\.recordName)) == recordNames) + + acknowledgedScope = fixture.lifecycle.scope + fixture.lifecycle.pause() + fixture.network.nextEngine = LifecycleEngine() + try await fixture.start() + let retry = try #require(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) })) + #expect(Set(retry.recordIDsToDelete.map(\.recordName)) == recordNames) + let unknownItems = Dictionary( + uniqueKeysWithValues: retry.recordIDsToDelete.map { ($0, CKError(.unknownItem)) }) + try fixture.sent( + .init(savedRecords: [], failedRecordSaves: [], failedRecordDeletes: unknownItems)) + #expect(fixture.batch(oldRecords.map { .deleteRecord($0.recordID) }) == nil) + fixture.stop() + } + + let scope = try #require(acknowledgedScope) + let reopened = try TermindPersistence.makeContainer(url: url) + let records = try VaultSyncJournal(context: reopened.mainContext).cloudRecords() + let pending = try CloudSyncRecordStateStore(context: reopened.mainContext, scope: scope).pendingChanges(records) + #expect(recordNames.isDisjoint(with: pending.map(\.recordName))) + } + + @Test + func obsoleteVaultCleanupDoesNotBypassSafetyWithoutExactZoneTypeAndCanonicalID() async throws { + let fixture = try CloudSyncSafetyFixture(category: .vault) + defer { fixture.stop() } + try await fixture.start() + let vaultZone = fixture.lifecycle.zoneID + let wrongTypeID = CKRecord.ID( + recordName: HistoryRecordKey(kind: .device, id: UUID()).recordName, zoneID: vaultZone) + let wrongZoneID = CKRecord.ID( + recordName: HistoryRecordKey(kind: .connectionHistory, id: UUID()).recordName, + zoneID: .init(zoneName: ConnectionHistorySyncCoordinator.zoneName)) + let noncanonicalUUID = UUID().uuidString.uppercased() + let noncanonicalID = CKRecord.ID(recordName: "v1/device/\(noncanonicalUUID)", zoneID: vaultZone) + let queuedCanonicalID = CKRecord.ID( + recordName: HistoryRecordKey(kind: .device, id: UUID()).recordName, zoneID: vaultZone) + let records = [ + CKRecord(recordType: "DeviceItem", recordID: wrongTypeID), + CKRecord(recordType: VaultSyncCoordinator.recordType, recordID: wrongZoneID), + CKRecord(recordType: VaultSyncCoordinator.recordType, recordID: noncanonicalID), + ] + let safety = try fixture.safety() + for record in records { + try safety.quarantine(recordName: record.recordID.recordName, payloadData: Data([1]), reason: "Keep") + } + try safety.quarantine(recordName: queuedCanonicalID.recordName, payloadData: Data([2]), reason: "Obsolete") + let scope = try #require(fixture.lifecycle.scope) + try CloudSyncRecordStateStore(context: fixture.container.mainContext, scope: scope).queueLegacyDeletion( + queuedCanonicalID.recordName, version: .baseline(writerID: UUID())) + try fixture.container.mainContext.save() + + try fixture.receive(records, []) + try fixture.lifecycle.reconcilePendingChanges() + + #expect(try safety.blockedRecordNames() == Set(records.map(\.recordID.recordName))) + let pending = + records.map { CKSyncEngine.PendingRecordZoneChange.deleteRecord($0.recordID) } + + [.deleteRecord(queuedCanonicalID)] + let cleanup = try #require(fixture.batch(pending)) + #expect(cleanup.recordIDsToDelete == [queuedCanonicalID]) + } + @Test func deviceLaunchRegistrationDoesNotRecreateADeletedDeviceButRenameCan() async throws { let fixture = try CloudSyncSafetyFixture(category: .device) @@ -321,54 +1141,104 @@ struct CloudSyncPhysicalDeletionTests { #expect(fixture.batch([.saveRecord(id)])?.recordsToSave.count == 1) } - @Test - func version36MigrationPreservesJournalsAndPersistsScopedDeletionAcknowledgements() throws { + @Test(arguments: SyncSafetyCategory.allCases) + func version36MigrationPhysicallyDeletesConfirmedTombstonesAcrossEveryCategory( + category: SyncSafetyCategory + ) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("Migration.store") - let key = HistoryRecordKey(kind: .connectionHistory, id: UUID()) - let version = VaultVersion.baseline(writerID: UUID()) - try Self.writeVersion36Tombstone(url: url, key: key, version: version) - let scope = CloudSyncScope( - container: "test", environment: "Production", account: "account", zone: "history", owner: "owner") - try Self.migrateAndAcknowledge(url: url, key: key, version: version, scope: scope) + let tombstone = try Self.writeVersion36Tombstone(url: url, category: category) + let scope: CloudSyncScope + do { + let fixture = try MigratedPhysicalDeletionFixture(category: category, url: url) + try await fixture.start() + scope = try #require(fixture.lifecycle.scope) + #expect( + try fixture.lifecycle.store.pendingChanges(scope) + == [tombstone]) + let id = CKRecord.ID(recordName: tombstone.recordName, zoneID: fixture.lifecycle.zoneID) + let batch = try #require(fixture.batch([.deleteRecord(id)])) + #expect(batch.recordsToSave.isEmpty) + #expect(batch.recordIDsToDelete == [id]) + try fixture.sent(.init(savedRecords: [], failedRecordSaves: [], deletedRecordIDs: [id])) + #expect(try fixture.lifecycle.store.pendingChanges(scope).isEmpty) + let acknowledgedRecords = try fixture.cloudRecords() + #expect(acknowledgedRecords.contains { $0.mutation == tombstone && !$0.needsUpload }) + fixture.stop() + } + let reopened = try TermindPersistence.makeContainer(url: url) - let journal = HistorySyncJournal(context: reopened.mainContext) - let records = try journal.cloudRecords() - #expect(try journal.envelope(for: key)?.isDeleted == true) + let records = try Self.cloudRecords(category: category, context: reopened.mainContext) #expect( try CloudSyncRecordStateStore(context: reopened.mainContext, scope: scope).pendingChanges(records).isEmpty) var differentAccount = scope differentAccount.account = "another-account" #expect( try CloudSyncRecordStateStore(context: reopened.mainContext, scope: differentAccount).pendingChanges( - records - ).count == 1) + records) == [tombstone]) } - private static func writeVersion36Tombstone(url: URL, key: HistoryRecordKey, version: VaultVersion) throws { + private static func writeVersion36Tombstone( + url: URL, category: SyncSafetyCategory + ) throws -> CloudSyncMutation { let schema = Schema(versionedSchema: TermindSchemaV36.self) let configuration = ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none) let container = try ModelContainer(for: schema, configurations: [configuration]) - container.mainContext.insert( - try PersistentHistorySyncEntry( - envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false)) + let version = VaultVersion.baseline(writerID: UUID()) + let recordName: String + switch category { + case .vault: + let key = VaultRecordPayload.hostGroup(HostGroup(name: "Migrated")).key + container.mainContext.insert( + try PersistentVaultSyncEntry( + envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false)) + recordName = key.recordName + case .settings: + let key = SettingsRecordPayload.sftp(SFTPPreferences()).key + container.mainContext.insert( + try PersistentAIConfigurationSyncEntry( + envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false)) + recordName = key.recordName + case .metrics: + let payload = MetricsRecordPayload.selection( + SyncedMetricsSelection(selection: MetricsSelection(id: UUID()), sortIndex: 0)) + container.mainContext.insert( + try PersistentMetricsSyncEntry( + envelope: .init(key: payload.key, version: version, isDeleted: true), needsUpload: false)) + recordName = payload.key.recordName + case .history, .device: + let key = HistoryRecordKey( + kind: category == .device ? .device : .connectionHistory, id: UUID()) + container.mainContext.insert( + try PersistentHistorySyncEntry( + envelope: .init(key: key, version: version, isDeleted: true), needsUpload: false)) + recordName = key.recordName + } try container.mainContext.save() + return CloudSyncMutation(recordName: recordName, operation: .delete, version: version) } - private static func migrateAndAcknowledge( - url: URL, key: HistoryRecordKey, version: VaultVersion, scope: CloudSyncScope - ) throws { - let container = try TermindPersistence.makeContainer(url: url) - let journal = HistorySyncJournal(context: container.mainContext) - let cloud = CloudSyncRecordStateStore(context: container.mainContext, scope: scope) - #expect(try cloud.prepare(journal.cloudRecords())) - let changes = try cloud.pendingChanges(journal.cloudRecords()) - #expect(changes == [.init(recordName: key.recordName, operation: .delete, version: version)]) - try journal.acknowledgeDeletion(try #require(changes.first), scope: scope) - try cloud.completeReconciliation() - try container.mainContext.save() + private static func cloudRecords( + category: SyncSafetyCategory, context: ModelContext + ) throws -> [CloudSyncJournalRecord] { + switch category { + case .vault: try VaultSyncJournal(context: context).cloudRecords() + case .settings: try SettingsSyncJournal(context: context).cloudRecords() + case .metrics: try MetricsSyncJournal(context: context).cloudRecords() + case .history: try HistorySyncJournal(context: context, kind: .connectionHistory).cloudRecords() + case .device: try HistorySyncJournal(context: context, kind: .device).cloudRecords() + } + } + + private static func assetDirectoryName(for category: SyncSafetyCategory) -> String? { + switch category { + case .vault: VaultSyncCoordinator.assetDirectoryName + case .settings: SettingsSyncCoordinator.assetDirectoryName + case .metrics: MetricsSyncCoordinator.assetDirectoryName + case .history, .device: nil + } } private static func tombstone(_ record: CKRecord) throws -> CKRecord { @@ -399,4 +1269,114 @@ struct CloudSyncPhysicalDeletionTests { return try SettingsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID) } } + + private static func liveRecord(_ record: CKRecord, version: VaultVersion) throws -> CKRecord { + switch record.recordType { + case "VaultItem": + var envelope = try VaultSyncCoordinator.envelope(from: record) + envelope.version = version + return try VaultSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID) + case "MetricsItem": + var envelope = try MetricsSyncCoordinator.envelope(from: record) + envelope.version = version + return try MetricsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID) + case "ConnectionHistoryItem", "DeviceItem": + var envelope = try HistoryRecordSyncCoordinator.envelope(from: record) + envelope.version = version + return try HistoryRecordSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID) + default: + var envelope = try SettingsSyncCoordinator.envelope(from: record) + envelope.version = version + return try SettingsSyncCoordinator.makeRecord(.init(envelope: envelope), recordID: record.recordID) + } + } +} + +@MainActor +private struct MigratedDeletionAdapter { + var lifecycle: CloudSyncLifecycle + var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch? + var sent: (CloudSyncSentChanges) throws -> Void + var cloudRecords: () throws -> [CloudSyncJournalRecord] +} + +@MainActor +private final class MigratedPhysicalDeletionFixture { + let container: ModelContainer + let defaults: UserDefaults + let defaultsName = "MigratedPhysicalDeletionTests.\(UUID())" + let network: LifecycleNetwork + private let adapter: MigratedDeletionAdapter + + var lifecycle: CloudSyncLifecycle { adapter.lifecycle } + var batch: ([CKSyncEngine.PendingRecordZoneChange]) -> CKSyncEngine.RecordZoneChangeBatch? { adapter.batch } + var sent: (CloudSyncSentChanges) throws -> Void { adapter.sent } + var cloudRecords: () throws -> [CloudSyncJournalRecord] { adapter.cloudRecords } + + init(category: SyncSafetyCategory, url: URL) throws { + container = try TermindPersistence.makeContainer(url: url) + defaults = UserDefaults(suiteName: defaultsName)! + let fixtureNetwork = LifecycleNetwork() + network = fixtureNetwork + for key in [ + SettingsKey.iCloudSyncEnabled, SettingsKey.vaultSyncEnabled, + SettingsKey.settingsSyncEnabled, SettingsKey.metricsSyncEnabled, SettingsKey.historySyncEnabled, + ] { defaults.set(true, forKey: key) } + let context = container.mainContext + switch category { + case .vault: + let journal = VaultSyncJournal(context: context) + let coordinator = VaultSyncCoordinator( + context: context, defaults: defaults, environmentIdentifier: "Test", + makeTransport: { _, _ in fixtureNetwork.transport }) + adapter = MigratedDeletionAdapter( + lifecycle: coordinator.lifecycle, + batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) }, + sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords) + case .settings: + let persistence = SettingsSyncPersistence(context: context, defaults: defaults) + let journal = persistence.journal + let secrets = AISecretStore(context: context, keychain: InMemorySecretStore(), defaults: defaults) + let coordinator = SettingsSyncCoordinator( + persistence: persistence, secrets: secrets, defaults: defaults, environmentIdentifier: "Test", + makeTransport: { _, _ in fixtureNetwork.transport }) + adapter = MigratedDeletionAdapter( + lifecycle: coordinator.lifecycle, + batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) }, + sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords) + case .metrics: + let persistence = MetricsSyncPersistence(context: context, defaults: defaults) + let journal = persistence.journal + let coordinator = MetricsSyncCoordinator( + persistence: persistence, defaults: defaults, environmentIdentifier: "Test", + makeTransport: { _, _ in fixtureNetwork.transport }) + adapter = MigratedDeletionAdapter( + lifecycle: coordinator.lifecycle, + batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) }, + sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords) + case .history, .device: + let kind: HistoryRecordKind = category == .device ? .device : .connectionHistory + let persistence = HistorySyncPersistence(context: context) + try persistence.prepare() + let journal = HistorySyncJournal(context: context, kind: kind) + let coordinator = HistoryRecordSyncCoordinator( + persistence: persistence, kind: kind, defaults: defaults, environmentIdentifier: "Test", + makeTransport: { _, _ in fixtureNetwork.transport }) + adapter = MigratedDeletionAdapter( + lifecycle: coordinator.lifecycle, + batch: { coordinator.makeChangeBatch($0, engine: fixtureNetwork.nextEngine) }, + sent: coordinator.handleSentChanges, cloudRecords: journal.cloudRecords) + } + } + + func start() async throws { + lifecycle.start() + await lifecycle.operation?.value + #expect(lifecycle.isReadyToSend) + } + + func stop() { + lifecycle.pause() + defaults.removePersistentDomain(forName: defaultsName) + } } diff --git a/FrameworkTests/CloudSyncSafetyFixture.swift b/FrameworkTests/CloudSyncSafetyFixture.swift index 80a47b84..c25961c8 100644 --- a/FrameworkTests/CloudSyncSafetyFixture.swift +++ b/FrameworkTests/CloudSyncSafetyFixture.swift @@ -28,13 +28,19 @@ private struct SyncSafetyAdapter { var sameVersionLosingRecord: (CKRecord.ID) throws -> CKRecord var pendingNames: () throws -> Set 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 { 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())) ?? -1 + } else { + count = (try? context.fetchCount(FetchDescriptor())) ?? -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()) == envelopes.count + } + return try context.fetchCount(FetchDescriptor()) == envelopes.count + }, + projectionIsCleared: { + if devices { + return try context.fetchCount(FetchDescriptor()) == 0 + } + return try context.fetchCount(FetchDescriptor()) == 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 { diff --git a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift index 296ef3a0..e5063b3d 100644 --- a/FrameworkTests/CloudSyncSafetyPersistenceTests.swift +++ b/FrameworkTests/CloudSyncSafetyPersistenceTests.swift @@ -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()).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 } + } diff --git a/Frameworks/TermindInfrastructure/Cloud/CloudSyncEventHandling.swift b/Frameworks/TermindInfrastructure/Cloud/CloudSyncEventHandling.swift index d4b979f0..c7ab140d 100644 --- a/Frameworks/TermindInfrastructure/Cloud/CloudSyncEventHandling.swift +++ b/Frameworks/TermindInfrastructure/Cloud/CloudSyncEventHandling.swift @@ -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) diff --git a/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift b/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift index 58e298ec..3ca65898 100644 --- a/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift +++ b/Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift @@ -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() 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 { diff --git a/Frameworks/TermindInfrastructure/Cloud/CloudSyncRecordCoding.swift b/Frameworks/TermindInfrastructure/Cloud/CloudSyncRecordCoding.swift index c388e638..fa0c9e69 100644 --- a/Frameworks/TermindInfrastructure/Cloud/CloudSyncRecordCoding.swift +++ b/Frameworks/TermindInfrastructure/Cloud/CloudSyncRecordCoding.swift @@ -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() } diff --git a/Frameworks/TermindInfrastructure/Cloud/CloudSyncReplicaStore.swift b/Frameworks/TermindInfrastructure/Cloud/CloudSyncReplicaStore.swift index b47dd2e7..0833eecc 100644 --- a/Frameworks/TermindInfrastructure/Cloud/CloudSyncReplicaStore.swift +++ b/Frameworks/TermindInfrastructure/Cloud/CloudSyncReplicaStore.swift @@ -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) throws -> [String] + var handlesRecordName: (String) -> Bool + var missing: (CloudSyncScope, Set) 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() }) } diff --git a/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift index b338aaec..12e5cf51 100644 --- a/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift @@ -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() diff --git a/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift index c1d8d35e..6a65da6f 100644 --- a/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift @@ -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) } } diff --git a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift index 7cf5197e..1b528331 100644 --- a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift @@ -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) } } diff --git a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift index 2d12e7bc..d90f4ee6 100644 --- a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift @@ -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) } } diff --git a/Frameworks/TermindPersistence/CloudSyncRecordStateStore.swift b/Frameworks/TermindPersistence/CloudSyncRecordStateStore.swift index 8cb996c2..a42e0623 100644 --- a/Frameworks/TermindPersistence/CloudSyncRecordStateStore.swift +++ b/Frameworks/TermindPersistence/CloudSyncRecordStateStore.swift @@ -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( + 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( + 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) 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, 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(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( predicate: #Predicate { $0.scopeKey == key })) } + + private func handoff(_ name: String) throws -> PersistentCloudSyncDeletionHandoff? { + let key = scope.storageKey + return try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.scopeKey == key && $0.recordName == name }) + ).first + } + + private func handoffs() throws -> [PersistentCloudSyncDeletionHandoff] { + let key = scope.storageKey + return try context.fetch( + FetchDescriptor(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() + } } diff --git a/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift b/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift index d9d28f05..ddd2c352 100644 --- a/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift +++ b/Frameworks/TermindPersistence/CloudSyncSafetyStore.swift @@ -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(predicate: #Predicate { $0.scopeKey == key })) + { + context.delete(row) + } + for row in try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.scopeKey == key })) + { + context.delete(row) + } + for row in try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.scopeKey == key })) + { + context.delete(row) + } + } + } diff --git a/Frameworks/TermindPersistence/HistorySyncStore.swift b/Frameworks/TermindPersistence/HistorySyncStore.swift index 2f737b68..956745e8 100644 --- a/Frameworks/TermindPersistence/HistorySyncStore.swift +++ b/Frameworks/TermindPersistence/HistorySyncStore.swift @@ -32,6 +32,37 @@ public final class HistorySyncPersistence { } } + public func purgeCloudCache( + kind: HistoryRecordKind, scope: CloudSyncScope + ) throws -> Set { + var removed = Set() + 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()) { + context.delete(row) + } + case .device: + for row in try context.fetch(FetchDescriptor()) { context.delete(row) } + } + for row in try context.fetch(FetchDescriptor()) + 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 = [], @@ -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() { diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index a70e4d0b..d0d84bd4 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/MetricsSyncStore.swift b/Frameworks/TermindPersistence/MetricsSyncStore.swift index 7343ea0f..6ce06e22 100644 --- a/Frameworks/TermindPersistence/MetricsSyncStore.swift +++ b/Frameworks/TermindPersistence/MetricsSyncStore.swift @@ -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()) { @@ -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( 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( - 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()) { 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) { + var removed = Set() + try context.commit { + removed = Set( + try journal.cloudRecords().compactMap { MetricsRecordKey(recordName: $0.mutation.recordName) }) + for row in try context.fetch(FetchDescriptor()) { 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()).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) 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 { diff --git a/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift index 865c5310..ca4bdaf6 100644 --- a/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift +++ b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift @@ -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()).filter { - $0.envelope?.key.kind.participatesInSync == false + $0.envelope.map { !$0.key.kind.participatesInSync && !$0.isDeleted } == true } let quarantines = try context.fetch(FetchDescriptor()).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()) { + 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() 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()) { @@ -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()) { 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()).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) diff --git a/Frameworks/TermindPersistence/SettingsSyncStore.swift b/Frameworks/TermindPersistence/SettingsSyncStore.swift index 002967c2..0dc1d294 100644 --- a/Frameworks/TermindPersistence/SettingsSyncStore.swift +++ b/Frameworks/TermindPersistence/SettingsSyncStore.swift @@ -97,6 +97,41 @@ public final class SettingsSyncPersistence { didRecordChanges?(mutations) } + public func purgeCloudCache( + scope: CloudSyncScope, assetCleanupKey: String? = nil + ) throws -> (SettingsSyncSnapshot, Set) { + var removed = Set() + 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), diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index bd2944fb..e26200ec 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -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()) - where HistoryRecordKey(recordName: row.recordName) == nil { + for row in try context.fetch(FetchDescriptor()) { 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( 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( - 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 { diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index ebffa6b4..059eb6f7 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -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) { + var removed = Set() + 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()) { 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()), with: snapshot.hosts, diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 93172515..5acbf22d 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index c2ba48c1..a707c447 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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")) diff --git a/TFMTests/SettingsSyncTests.swift b/TFMTests/SettingsSyncTests.swift index 9a5015f1..eaf15764 100644 --- a/TFMTests/SettingsSyncTests.swift +++ b/TFMTests/SettingsSyncTests.swift @@ -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()