From d146103920fff09ff1892fcab265edfbe371ea50 Mon Sep 17 00:00:00 2001 From: Justin Sanders Date: Thu, 16 Jul 2026 00:49:16 -0500 Subject: [PATCH] Add SyncEngine.pendingChangeCounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes the pending save, delete, and database change counts available for things like debug and diagnostics views. It returns nil when the sync engine isn’t running and updates when CKSyncEngine reports a state change. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 55 +++++++++++++++++++ .../PendingChangeCountsTests.swift | 52 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/PendingChangeCountsTests.swift 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