Skip to content
Draft
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
1 change: 0 additions & 1 deletion Examples/CloudKitDemo/CountersListFeature.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import CloudKit
import SQLiteData
import SwiftUI
import SwiftUINavigation

struct CountersListView: View {
@FetchAll var counters: [Counter]
Expand Down
33 changes: 32 additions & 1 deletion Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package let databaseScope: CKDatabase.Scope
let _container = IsolatedWeakVar<MockCloudContainer>()
let dataManager = Dependency(\.dataManager)
let quota: LockIsolated<Int>

struct AssetID: Hashable {
let recordID: CKRecord.ID
Expand All @@ -20,8 +21,13 @@
package var records: [CKRecord.ID: CKRecord] = [:]
}

package init(databaseScope: CKDatabase.Scope) {
package init(databaseScope: CKDatabase.Scope, quota: Int = Int.max) {
self.databaseScope = databaseScope
self.quota = LockIsolated(quota)
}

package func setQuota(_ quota: Int) {
self.quota.withValue { $0 = quota }
}

package func set(container: MockCloudContainer) {
Expand Down Expand Up @@ -276,6 +282,21 @@
}
}

// Emulate quotas by reverting all changes if the total number of records stored exceeds
// the quota. This is a very rough approximation of how iCloud handles this in the real
// database.
guard storage.totalRecords <= quota.withValue(\.self)
else {
storage = previousStorage
for saveSuccessRecordID in saveResults.keys {
saveResults[saveSuccessRecordID] = .failure(CKError(.quotaExceeded))
}
for deleteSuccessRecordID in deleteResults.keys {
deleteResults[deleteSuccessRecordID] = .failure(CKError(.quotaExceeded))
}
return (saveResults: saveResults, deleteResults: deleteResults)
}

guard atomically
else {
return (saveResults: saveResults, deleteResults: deleteResults)
Expand Down Expand Up @@ -311,6 +332,7 @@
// All storage changes are reverted in zone.
storage[zoneID]?.records = previousStorage[zoneID]?.records ?? [:]
}

return (saveResults: saveResults, deleteResults: deleteResults)
}
}
Expand Down Expand Up @@ -372,4 +394,13 @@
fatalError()
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension [CKRecordZone.ID: MockCloudDatabase.Zone] {
fileprivate var totalRecords: Int {
values.reduce(into: 0) { total, zone in
total += zone.records.count
}
}
}
#endif
26 changes: 21 additions & 5 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@
}
guard let privateSyncEngine, let sharedSyncEngine
else { return }
try await enqueueLocallyPendingChanges()
async let `private`: Void = privateSyncEngine.sendChanges(options)
async let shared: Void = sharedSyncEngine.sendChanges(options)
_ = try await (`private`, shared)
Expand Down Expand Up @@ -590,8 +591,6 @@
) async throws {
try await enqueueLocallyPendingChanges()
try await userDatabase.write { db in
try PendingRecordZoneChange.delete().execute(db)

let newTableNames = currentRecordTypeByTableName.keys.filter { tableName in
previousRecordTypeByTableName[tableName] == nil
}
Expand All @@ -605,9 +604,10 @@
}

private func enqueueLocallyPendingChanges() async throws {
let pendingRecordZoneChanges = try await metadatabase.read { db in
let pendingRecordZoneChanges = try await metadatabase.write { db in
try PendingRecordZoneChange
.select(\.pendingRecordZoneChange)
.delete()
.returning(\.pendingRecordZoneChange)
.fetchAll(db)
}
let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) {
Expand Down Expand Up @@ -1577,6 +1577,12 @@
var newPendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = []
var newPendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] = []
defer {
let quotaExceeded = failedRecordSaves.contains(where: { $0.error.code == .quotaExceeded })
delegate?.syncEngine(
self,
quotaExceeded: quotaExceeded,
scope: syncEngine.database.databaseScope
)
syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges)
syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges)
}
Expand Down Expand Up @@ -1699,12 +1705,22 @@
newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
break

case .quotaExceeded:
await withErrorReporting {
try await userDatabase.write { db in
try PendingRecordZoneChange.insert {
PendingRecordZoneChange(.saveRecord(failedRecord.recordID))
}
.execute(db)
}
}

case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable,
.notAuthenticated, .operationCancelled,
.internalError, .partialFailure, .badContainer, .requestRateLimited, .missingEntitlement,
.invalidArguments, .resultsTruncated, .assetFileNotFound,
.assetFileModified, .incompatibleVersion, .constraintViolation, .changeTokenExpired,
.badDatabase, .quotaExceeded, .limitExceeded, .userDeletedZone, .tooManyParticipants,
.badDatabase, .limitExceeded, .userDeletedZone, .tooManyParticipants,
.alreadyShared, .managedAccountRestricted, .participantMayNeedVerification,
.serverResponseLost, .assetNotAvailable, .accountTemporarilyUnavailable:
continue
Expand Down
33 changes: 33 additions & 0 deletions Sources/SQLiteData/CloudKit/SyncEngineDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@
_ syncEngine: SyncEngine,
accountChanged changeType: CKSyncEngine.Event.AccountChange.ChangeType
) async

/// An event indicating that the iCloud database associated with `scope` is full and cannot
/// store any more records.
///
/// You can use this method to be notified when records can no longer be stored in the user's
/// iCloud database. The `scope` argument determines which database is full:
///
/// * If `scope` is `.private`, then the currently logged in user's database is full, and you
/// can let the user know they need to clear up space on their iCloud account or upgrade for
/// more storage.
/// * If the `scope` is `.shared`, then an external user has shared a record with the logged
/// in user, and _their_ iCloud storage is full. You can let the user know that they may want
/// to contact the owner about upgrading their storage or cleaning up their iCloud account.
///
/// This method can be called many times, and so you will want to de-duplicate the
/// `quotaExceeded` boolean so as to not alert your users multiple times.
Comment on lines +95 to +96
Copy link
Member Author

Choose a reason for hiding this comment

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

I wonder if we should also have a quotaExceeded: Bool observable value on the SyncEngine? That would allow showing/hiding a banner in the UI.

Choose a reason for hiding this comment

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

That would be very handy!

///
/// - Parameters:
/// - syncEngine: The sync engine that generates the event.
/// - quotaExceeded: Determines if records failed to save due to a 'quotaExceeded` error.
/// - scope: The database that the event occured on.
func syncEngine(
_ syncEngine: SyncEngine,
quotaExceeded: Bool,
scope: CKDatabase.Scope
)
Comment on lines +102 to +106
Copy link
Member Author

@mbrandonw mbrandonw Dec 19, 2025

Choose a reason for hiding this comment

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

Should we surface the record/participant/CKShare that is responsible for the error?

}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
Expand All @@ -97,5 +123,12 @@
break
}
}

public func syncEngine(
_ syncEngine: SyncEngine,
quotaExceeded: Bool,
scope: CKDatabase.Scope
) {
}
}
#endif
Loading
Loading