diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index ddcb3f32..4ebb9280 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -19,6 +19,19 @@ import UIKit #endif + /// Counts of changes waiting to be sent to CloudKit. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public struct PendingChangeCounts: Equatable, Sendable { + /// The number of records waiting to be saved. + public let recordSaveCount: Int + + /// The number of records waiting to be deleted. + public let recordDeleteCount: Int + + /// The number of database changes waiting to be sent. + public let databaseChangeCount: Int + } + /// An object that manages the synchronization of local and remote SQLite data. /// /// See for more information. @@ -423,6 +436,41 @@ isSendingChanges || isFetchingChanges } + /// Counts of changes waiting to be sent to CloudKit. + /// + /// This value is `nil` when the sync engine is not running. It is observable, and so accessing + /// it from a SwiftUI view will cause the view to update as the pending changes change. + public var pendingChangeCounts: PendingChangeCounts? { + observationRegistrar.access(self, keyPath: \.pendingChangeCounts) + return syncEngines.withValue { syncEngines in + guard let privateSyncEngine = syncEngines.private, + let sharedSyncEngine = syncEngines.shared + else { return nil } + + var recordSaveCount = 0 + var recordDeleteCount = 0 + var databaseChangeCount = 0 + for syncEngine in [privateSyncEngine, sharedSyncEngine] { + for change in syncEngine.state.pendingRecordZoneChanges { + switch change { + case .saveRecord: + recordSaveCount += 1 + case .deleteRecord: + recordDeleteCount += 1 + @unknown default: + break + } + } + databaseChangeCount += syncEngine.state.pendingDatabaseChanges.count + } + return PendingChangeCounts( + recordSaveCount: recordSaveCount, + recordDeleteCount: recordDeleteCount, + databaseChangeCount: databaseChangeCount + ) + } + } + /// Stops the sync engine if it is running. /// /// All edits made after stopping the sync engine will not be synchronized to CloudKit. @@ -440,6 +488,7 @@ $0 = SyncEngines() } } + pendingChangeCountsDidChange() } /// Determines if the sync engine is currently running or not. @@ -461,6 +510,7 @@ ) } } + pendingChangeCountsDidChange() let previousRecordTypes = try metadatabase.read { db in try RecordType.all.fetchAll(db) @@ -943,6 +993,10 @@ } } } + + private func pendingChangeCountsDidChange() { + observationRegistrar.withMutation(of: self, keyPath: \.pendingChangeCounts) {} + } } extension PrimaryKeyedTable { @@ -1005,6 +1059,7 @@ case .accountChange(let changeType): await handleAccountChange(changeType: changeType, syncEngine: syncEngine) case .stateUpdate(let stateSerialization): + pendingChangeCountsDidChange() await handleStateUpdate(stateSerialization: stateSerialization, syncEngine: syncEngine) case .fetchedDatabaseChanges(let modifications, let deletions): await handleFetchedDatabaseChanges( diff --git a/Tests/SQLiteDataTests/CloudKitTests/PendingChangeCountsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/PendingChangeCountsTests.swift new file mode 100644 index 00000000..7d8acef1 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/PendingChangeCountsTests.swift @@ -0,0 +1,52 @@ +#if canImport(CloudKit) + import CloudKit + import Observation + import SQLiteData + import Testing + + extension BaseCloudKitTests { + @MainActor + final class PendingChangeCountsTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func counts() throws { + let privateSave = CKRecord.ID(recordName: "private-save") + let privateDelete = CKRecord.ID(recordName: "private-delete") + let sharedSave = CKRecord.ID(recordName: "shared-save") + let zone = CKRecordZone(zoneName: "zone") + + syncEngine.private.state.add( + pendingRecordZoneChanges: [.saveRecord(privateSave), .deleteRecord(privateDelete)] + ) + syncEngine.shared.state.add(pendingRecordZoneChanges: [.saveRecord(sharedSave)]) + syncEngine.private.state.add(pendingDatabaseChanges: [.saveZone(zone)]) + syncEngine.shared.state.add(pendingDatabaseChanges: [.deleteZone(zone.zoneID)]) + + let counts = try #require(syncEngine.pendingChangeCounts) + #expect(counts.recordSaveCount == 2) + #expect(counts.recordDeleteCount == 1) + #expect(counts.databaseChangeCount == 2) + + syncEngine.private.state.assertPendingRecordZoneChanges([ + .saveRecord(privateSave), .deleteRecord(privateDelete), + ]) + syncEngine.shared.state.assertPendingRecordZoneChanges([.saveRecord(sharedSave)]) + syncEngine.private.state.assertPendingDatabaseChanges([.saveZone(zone)]) + syncEngine.shared.state.assertPendingDatabaseChanges([.deleteZone(zone.zoneID)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func observation() async { + await confirmation { changed in + withObservationTracking { + _ = syncEngine.pendingChangeCounts + } onChange: { + changed() + } + syncEngine.stop() + } + + #expect(syncEngine.pendingChangeCounts == nil) + } + } + } +#endif