diff --git a/FrameworkTests/HistorySyncTests.swift b/FrameworkTests/HistorySyncTests.swift index 4e6392b0..0efb2b4e 100644 --- a/FrameworkTests/HistorySyncTests.swift +++ b/FrameworkTests/HistorySyncTests.swift @@ -173,6 +173,53 @@ struct HistorySyncTests { #expect(fixture.record(fixture.event.key) != nil) } + @Test func sourceQuarantineUpdatesAnAlreadySuccessfulStatusWithoutNewUploads() async throws { + let fixture = try HistoryFixture() + defer { fixture.stop() } + try await fixture.start() + try fixture.persistence.journal.markUploaded(fixture.event.key, version: fixture.event.version) + try fixture.persistence.journal.markUploaded(fixture.device.key, version: fixture.device.version) + try fixture.context.save() + try fixture.coordinator.lifecycle.updateUpToDateStatus() + let initialSync = try fixture.persistence.journal.state().lastSuccessfulSync + #expect(fixture.coordinator.historyStatus == .upToDate(lastSuccessfulSync: initialSync)) + + let future = try fixture.legacyRecord(fixture.event) + var json = try #require( + JSONSerialization.jsonObject(with: VaultSyncCoordinator.payloadData(from: future)) as? [String: Any]) + json["formatVersion"] = 2 + CloudSyncSafetyFixture.setPayload(try JSONSerialization.data(withJSONObject: json), in: future) + future["formatVersion"] = NSNumber(value: 2) + try fixture.coordinator.legacyImporter.beginFetch() + try fixture.coordinator.legacyImporter.receive([future]) + try fixture.coordinator.legacyImporter.finishFetch(succeeded: true) + #expect( + fixture.coordinator.historyStatus + == .partiallyBlocked(pendingCount: 0, blockedCount: 1, requiresUpdate: true)) + #expect(fixture.coordinator.deviceStatus == .upToDate(lastSuccessfulSync: initialSync)) + + try fixture.coordinator.legacyImporter.beginFetch() + try fixture.coordinator.legacyImporter.receive([fixture.legacyRecord(fixture.event)]) + try fixture.coordinator.legacyImporter.finishFetch(succeeded: true) + let resumedSync = try fixture.persistence.journal.state().lastSuccessfulSync + #expect(fixture.coordinator.historyStatus == .upToDate(lastSuccessfulSync: resumedSync)) + } + + @Test func unrelatedRecordTypeInLegacyZoneDoesNotQuarantineOrBlockHistory() async throws { + let fixture = try HistoryFixture() + defer { fixture.stop() } + try await fixture.start() + let record = CKRecord( + recordType: "FutureVaultFeature", + recordID: CKRecord.ID( + recordName: "v1/futureFeature/\(UUID().uuidString.lowercased())", + zoneID: CKRecordZone.ID(zoneName: VaultSyncCoordinator.zoneName, ownerName: CKCurrentUserDefaultName))) + try fixture.coordinator.legacyImporter.receive([record]) + let sourceSafety = CloudSyncSafetyStore(context: fixture.context, scope: fixture.scope.historyImportScope) + #expect(try sourceSafety.blockedRecordNames().isEmpty) + #expect(try fixture.persistence.journal.envelope(for: fixture.event.key) == fixture.event) + } + @Test func initialImporterFailurePreventsAllDestinationUploadsAndRetryRecovers() async throws { let fixture = try HistoryFixture() defer { fixture.stop() } diff --git a/Frameworks/TermindInfrastructure/Cloud/HistorySyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/HistorySyncCoordinator.swift index 447178b6..26d34470 100644 --- a/Frameworks/TermindInfrastructure/Cloud/HistorySyncCoordinator.swift +++ b/Frameworks/TermindInfrastructure/Cloud/HistorySyncCoordinator.swift @@ -44,45 +44,6 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch public var historyStatus: VaultSyncStatus { includesHistory ? status(for: .connectionHistory) : .paused } public var deviceStatus: VaultSyncStatus { status(for: .device) } - private func status(for kind: HistoryRecordKind) -> VaultSyncStatus { - guard let scope = lifecycle.scope else { return lifecycle.status } - if case .upToDate = lifecycle.status, !legacyImporter.ready(for: scope) { return .syncing(pendingCount: 0) } - switch lifecycle.status { - case .syncing, .partiallyBlocked: - do { - let pending = try journal.uploadablePendingRecordNames(in: scope).filter { - HistoryRecordKey(recordName: $0)?.kind == kind - }.count - var blocked = Set() - var requiresUpdate = false - for replica in [scope, scope.historyImportScope] { - let safety = CloudSyncSafetyStore(context: context, scope: replica) - for name in try safety.blockedRecordNames() { - let recordKind = HistoryRecordKey(recordName: name)?.kind - // Unknown kinds belong to the always-selected portion so an - // optional History toggle cannot hide an upgrade requirement. - guard recordKind == kind || (recordKind == nil && kind == .device) else { continue } - blocked.insert(name) - if try safety.entry(recordName: name)?.reasonCode - == CloudSyncQuarantineReason.unsupportedFormat.rawValue - { - requiresUpdate = true - } - } - } - if !blocked.isEmpty { - return .partiallyBlocked( - pendingCount: pending, blockedCount: blocked.count, requiresUpdate: requiresUpdate) - } - if case .partiallyBlocked = lifecycle.status, pending == 0 { - return .upToDate(lastSuccessfulSync: try journal.state().lastSuccessfulSync) - } - return .syncing(pendingCount: pending) - } catch { return .failed(error.localizedDescription) } - default: return lifecycle.status - } - } - public convenience init( persistence: HistorySyncPersistence, container: CKContainer = CKContainer(identifier: HistorySyncCoordinator.containerIdentifier), @@ -168,7 +129,13 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch importer.didApply = { [weak self] keys in self?.didApplyRemote?(keys) } importer.didFinish = { [weak self, weak importer] in guard let self, let importer, let scope = lifecycle.scope, importer.ready(for: scope) else { return } - do { lifecycle.noteLocalChanges(try journal.uploadablePendingRecordNames(in: scope)) } catch { + do { + lifecycle.noteLocalChanges(try journal.uploadablePendingRecordNames(in: scope)) + // The source may have gained or cleared a quarantine without + // producing any destination mutations. Recompute the visible + // status after its checkpoint commits, even with no uploads. + try lifecycle.updateUpToDateStatus() + } catch { lifecycle.failClosed(error) } } @@ -416,5 +383,45 @@ public final class HistorySyncCoordinator: NSObject, CKSyncEngineDelegate, @unch public nonisolated static func systemFields(of record: CKRecord) throws -> Data { try CloudSyncRecordCoding.systemFields(of: record) } - +} + +extension HistorySyncCoordinator { + private func status(for kind: HistoryRecordKind) -> VaultSyncStatus { + guard let scope = lifecycle.scope else { return lifecycle.status } + if case .upToDate = lifecycle.status, !legacyImporter.ready(for: scope) { return .syncing(pendingCount: 0) } + switch lifecycle.status { + case .syncing, .partiallyBlocked: + do { + let pending = try journal.uploadablePendingRecordNames(in: scope).filter { + HistoryRecordKey(recordName: $0)?.kind == kind + }.count + var blocked = Set() + var requiresUpdate = false + for replica in [scope, scope.historyImportScope] { + let safety = CloudSyncSafetyStore(context: context, scope: replica) + for name in try safety.blockedRecordNames() { + let recordKind = HistoryRecordKey(recordName: name)?.kind + // Unknown kinds belong to the always-selected portion so an + // optional History toggle cannot hide an upgrade requirement. + guard recordKind == kind || (recordKind == nil && kind == .device) else { continue } + blocked.insert(name) + if try safety.entry(recordName: name)?.reasonCode + == CloudSyncQuarantineReason.unsupportedFormat.rawValue + { + requiresUpdate = true + } + } + } + if !blocked.isEmpty { + return .partiallyBlocked( + pendingCount: pending, blockedCount: blocked.count, requiresUpdate: requiresUpdate) + } + if case .partiallyBlocked = lifecycle.status, pending == 0 { + return .upToDate(lastSuccessfulSync: try journal.state().lastSuccessfulSync) + } + return .syncing(pendingCount: pending) + } catch { return .failed(error.localizedDescription) } + default: return lifecycle.status + } + } } diff --git a/Frameworks/TermindInfrastructure/Cloud/LegacyHistoryImporter.swift b/Frameworks/TermindInfrastructure/Cloud/LegacyHistoryImporter.swift index 46a9399a..424f7ee5 100644 --- a/Frameworks/TermindInfrastructure/Cloud/LegacyHistoryImporter.swift +++ b/Frameworks/TermindInfrastructure/Cloud/LegacyHistoryImporter.swift @@ -173,7 +173,10 @@ final class LegacyHistoryImporter: NSObject, CKSyncEngineDelegate, @unchecked Se let safety = CloudSyncSafetyStore(context: context, scope: scope) var decoded: [HistoryEnvelope] = [] var rejected = Set() - for record in records where record.recordID.zoneID == zoneID { + // A separate record type in the legacy zone is not part of the Vault V1 + // history stream. Do not quarantine unrelated future features. + for record in records + where record.recordID.zoneID == zoneID && record.recordType == VaultSyncCoordinator.recordType { if let key = VaultRecordKey(recordName: record.recordID.recordName), key.kind.isConfiguration { continue } if let key = HistoryRecordKey(recordName: record.recordID.recordName), key.kind == .connectionHistory && !includesHistory()