diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 98b904e2..ad6db4c5 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -1,7 +1,6 @@ import CloudKit import SQLiteData import SwiftUI -import SwiftUINavigation struct CountersListView: View { @FetchAll var counters: [Counter] diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index c8138bf0..019950cf 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -9,6 +9,7 @@ package let databaseScope: CKDatabase.Scope let _container = IsolatedWeakVar() let dataManager = Dependency(\.dataManager) + let quota: LockIsolated struct AssetID: Hashable { let recordID: CKRecord.ID @@ -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) { @@ -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) @@ -311,6 +332,7 @@ // All storage changes are reverted in zone. storage[zoneID]?.records = previousStorage[zoneID]?.records ?? [:] } + return (saveResults: saveResults, deleteResults: deleteResults) } } @@ -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 diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index c9488697..c0505133 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -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) @@ -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 } @@ -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) { @@ -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) } @@ -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 diff --git a/Sources/SQLiteData/CloudKit/SyncEngineDelegate.swift b/Sources/SQLiteData/CloudKit/SyncEngineDelegate.swift index b373dfc4..a236d16f 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngineDelegate.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngineDelegate.swift @@ -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. + /// + /// - 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 + ) } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @@ -97,5 +123,12 @@ break } } + + public func syncEngine( + _ syncEngine: SyncEngine, + quotaExceeded: Bool, + scope: CKDatabase.Scope + ) { + } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift index c97c95ee..b1c3977c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift @@ -14,7 +14,7 @@ final class SyncEngineDelegateTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.syncEngineDelegate(MyDelegate())) + @Test(.syncEngineDelegate(AccountChangedDelegate())) func accountChanged() async throws { try await userDatabase.userWrite { db in try db.seed { @@ -218,10 +218,187 @@ """ } } + + @Test(.quota(0), .syncEngineDelegate(QuotaExceededDelegate())) + func quotaExceeded() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ id: SyncMetadata.ID( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists" │ + │ ), │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:remindersLists", │ + │ parentRecordID: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + + syncEngine.private.database.setQuota(1) + try await syncEngine.sendChanges() + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @Test(.quota(1), .syncEngineDelegate(QuotaExceededDelegate())) + func quotaNotExceeded() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(RemindersList.all, database: userDatabase.database) { + """ + ┌─────────────────────┐ + │ RemindersList( │ + │ id: 1, │ + │ title: "Personal" │ + │ ) │ + └─────────────────────┘ + """ + } + assertQuery(SyncMetadata.all, database: syncEngine.metadatabase) { + """ + ┌────────────────────────────────────────────────────────────────────┐ + │ SyncMetadata( │ + │ id: SyncMetadata.ID( │ + │ recordPrimaryKey: "1", │ + │ recordType: "remindersLists" │ + │ ), │ + │ zoneName: "zone", │ + │ ownerName: "__defaultOwner__", │ + │ recordName: "1:remindersLists", │ + │ parentRecordID: nil, │ + │ parentRecordName: nil, │ + │ lastKnownServerRecord: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil │ + │ ), │ + │ _lastKnownServerRecordAllFields: CKRecord( │ + │ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), │ + │ recordType: "remindersLists", │ + │ parent: nil, │ + │ share: nil, │ + │ id: 1, │ + │ title: "Personal" │ + │ ), │ + │ share: nil, │ + │ _isDeleted: false, │ + │ hasLastKnownServerRecord: true, │ + │ isShared: false, │ + │ userModificationTime: 0 │ + │ ) │ + └────────────────────────────────────────────────────────────────────┘ + """ + } + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } - final class MyDelegate: SyncEngineDelegate { + final class AccountChangedDelegate: SyncEngineDelegate { let wasCalled = LockIsolated(false) func syncEngine( _ syncEngine: SQLiteData.SyncEngine, @@ -238,6 +415,20 @@ } } + final class QuotaExceededDelegate: SyncEngineDelegate { + let wasCalled = LockIsolated(false) + func syncEngine(_ syncEngine: SyncEngine, quotaExceeded scope: CKDatabase.Scope) { + wasCalled.withValue { $0 = true } + } + deinit { + guard wasCalled.withValue(\.self) + else { + Issue.record("Delegate method 'syncEngine(_:quotaExceeded:)' was not called.") + return + } + } + } + final class DefaultImplementationDelegate: SyncEngineDelegate { } #endif diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 4aec1bc8..64dd3d23 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -46,8 +46,14 @@ class BaseCloudKitTests: @unchecked Sendable { ) ) try await _PrepareDatabaseTrait.prepareDatabase(userDatabase) - let privateDatabase = MockCloudDatabase(databaseScope: .private) - let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let privateDatabase = MockCloudDatabase( + databaseScope: .private, + quota: _DatabaseQuotaScope.quota + ) + let sharedDatabase = MockCloudDatabase( + databaseScope: .shared, + quota: _DatabaseQuotaScope.quota + ) let container = MockCloudContainer( accountStatus: _AccountStatusScope.accountStatus, containerIdentifier: testContainerIdentifier, @@ -139,7 +145,8 @@ class BaseCloudKitTests: @unchecked Sendable { syncEngine.private.assertAcceptedShareMetadata([]) try! syncEngine.metadatabase.read { db in - try #expect(UnsyncedRecordID.count().fetchOne(db) == 0) + try #expect(UnsyncedRecordID.fetchCount(db) == 0) + try #expect(PendingRecordZoneChange.fetchCount(db) == 0) } } else { Issue.record("Tests must be run on iOS 17+, macOS 14+, tvOS 17+ and watchOS 10+.") diff --git a/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift b/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift deleted file mode 100644 index 067815c6..00000000 --- a/Tests/SQLiteDataTests/Internal/PrintTimestampsScope.swift +++ /dev/null @@ -1,28 +0,0 @@ -#if canImport(CloudKit) - import CloudKit - import Testing - - struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { - let printTimestamps: Bool - init(_ printTimestamps: Bool = true) { - self.printTimestamps = printTimestamps - } - - func provideScope( - for test: Test, - testCase: Test.Case?, - performing function: @Sendable () async throws -> Void - ) async throws { - try await CKRecord.$printTimestamps.withValue(true) { - try await function() - } - } - } - - extension Trait where Self == _PrintTimestampsScope { - static var printTimestamps: Self { Self() } - static func printTimestamps(_ printTimestamps: Bool) -> Self { - Self(printTimestamps) - } - } -#endif diff --git a/Tests/SQLiteDataTests/Internal/TestScopes.swift b/Tests/SQLiteDataTests/Internal/TestScopes.swift index 7c1c34e6..cffcb387 100644 --- a/Tests/SQLiteDataTests/Internal/TestScopes.swift +++ b/Tests/SQLiteDataTests/Internal/TestScopes.swift @@ -126,4 +126,53 @@ Self(syncEngineDelegate: syncEngineDelegate) } } + + struct _PrintTimestampsScope: SuiteTrait, TestScoping, TestTrait { + let printTimestamps: Bool + init(_ printTimestamps: Bool = true) { + self.printTimestamps = printTimestamps + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await CKRecord.$printTimestamps.withValue(printTimestamps) { + try await function() + } + } + } + + extension Trait where Self == _PrintTimestampsScope { + static var printTimestamps: Self { Self() } + static func printTimestamps(_ printTimestamps: Bool) -> Self { + Self(printTimestamps) + } + } + + struct _DatabaseQuotaScope: SuiteTrait, TestScoping, TestTrait { + @TaskLocal static var quota = Int.max + let quota: Int + init(_ quota: Int = Int.max) { + self.quota = quota + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await Self.$quota.withValue(quota) { + try await function() + } + } + } + + extension Trait where Self == _DatabaseQuotaScope { + static var quota: Self { Self() } + static func quota(_ quota: Int) -> Self { + Self(quota) + } + } #endif