Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <doc:CloudKitSync> for more information.
Expand Down Expand Up @@ -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? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this really need to be optional? You already know if the engine is running or not via the public isRunning property, and so shouldn't this property always reflect the pending changes, regardless of running or not?

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.
Expand All @@ -440,6 +488,7 @@
$0 = SyncEngines()
}
}
pendingChangeCountsDidChange()
}

/// Determines if the sync engine is currently running or not.
Expand All @@ -461,6 +510,7 @@
)
}
}
pendingChangeCountsDidChange()

let previousRecordTypes = try metadatabase.read { db in
try RecordType.all.fetchAll(db)
Expand Down Expand Up @@ -943,6 +993,10 @@
}
}
}

private func pendingChangeCountsDidChange() {
observationRegistrar.withMutation(of: self, keyPath: \.pendingChangeCounts) {}
}
}

extension PrimaryKeyedTable {
Expand Down Expand Up @@ -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(
Expand Down
52 changes: 52 additions & 0 deletions Tests/SQLiteDataTests/CloudKitTests/PendingChangeCountsTests.swift
Original file line number Diff line number Diff line change
@@ -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