From 29339e0c419046464734bc17a9c210ce15d54bfe Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 10:14:32 -0600 Subject: [PATCH 1/9] Support hasPendingUntrackedChanges in mock database. --- .../Internal/MockCloudContainer.swift | 4 - .../CloudKit/Internal/MockSyncEngine.swift | 241 ++++++++++-------- .../Internal/SyncEngineProtocol.swift | 1 + .../CloudKitTests/MockSyncEngineTests.swift | 37 +++ .../Internal/CloudKit+CustomDump.swift | 43 ++-- .../Internal/CloudKitTestHelpers.swift | 12 +- 6 files changed, 197 insertions(+), 141 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index e730278a..abc6df29 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -36,10 +36,6 @@ fatalError("This should never be called in tests.") } - package func accountStatus() async throws -> CKAccountStatus { - _accountStatus.withValue { $0 } - } - package func shareMetadata( for share: CKShare, shouldFetchRootRecord: Bool diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index e3f1f804..9e28065f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -44,19 +44,20 @@ fatalError() } - modifications = database.state.withValue { state in - zoneIDs.reduce(into: [CKRecord]()) { - accum, - zoneID in - accum += ((state.storage[zoneID]?.records.values).map { Array($0) } ?? []) - .map { $0.copy() as! CKRecord } - .filter { - precondition( - $0._recordChangeTag != nil, - "Records stored in database should have their 'recordChangeTag' assigned." - ) - return $0._recordChangeTag! > self.state.changeTag.value - } + modifications = state.withState { syncEngineState in + let currentChangeTag = syncEngineState.changeTag + return database.state.withValue { dbState in + zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in + accum += ((dbState.storage[zoneID]?.records.values).map { Array($0) } ?? []) + .map { $0.copy() as! CKRecord } + .filter { + precondition( + $0._recordChangeTag != nil, + "Records stored in database should have their 'recordChangeTag' assigned." + ) + return $0._recordChangeTag! > currentChangeTag + } + } } } @@ -73,8 +74,8 @@ guard !modifications.isEmpty || !deletions.isEmpty else { return } - state.changeTag.withValue { changeTag in - changeTag = modifications.compactMap(\._recordChangeTag).max() ?? changeTag + state.withState { + $0.changeTag = modifications.compactMap(\._recordChangeTag).max() ?? $0.changeTag } await parentSyncEngine.handleEvent( @@ -84,18 +85,14 @@ } package func sendChanges(_ options: CKSyncEngine.SendChangesOptions) async throws { - - if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingDatabaseChanges - .isEmpty - { - + if !state.pendingDatabaseChanges.isEmpty { try await parentSyncEngine.processPendingDatabaseChanges(scope: database.databaseScope) } - if !parentSyncEngine.syncEngine(for: database.databaseScope).state.pendingRecordZoneChanges - .isEmpty - { - - try await parentSyncEngine.processPendingRecordZoneChanges(scope: database.databaseScope) + if !state.pendingRecordZoneChanges.isEmpty || state.hasPendingUntrackedChanges { + try await parentSyncEngine.processPendingRecordZoneChanges( + options: options, + scope: database.databaseScope + ) } } @@ -136,14 +133,14 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockSyncEngineState: CKSyncEngineStateProtocol { - package let changeTag = LockIsolated(0) - package let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] - ) - package let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) + package struct State: Sendable { + package var changeTag = 0 + package var hasPendingUntrackedChanges = false + package var pendingDatabaseChanges: OrderedSet = [] + package var pendingRecordZoneChanges: OrderedSet = [] + } + + private let state = LockIsolated(State()) private let fileID: StaticString private let filePath: StaticString private let line: UInt @@ -161,40 +158,54 @@ self.column = column } + package func withState( + _ operation: @Sendable (inout State) throws -> T + ) rethrows -> T { + try state.withValue(operation) + } + package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue { Array($0) } + withState { Array($0.pendingRecordZoneChanges) } } package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue { Array($0) } + withState { Array($0.pendingDatabaseChanges) } + } + + package var hasPendingUntrackedChanges: Bool { + get { withState(\.hasPendingUntrackedChanges) } + set { withState { $0.hasPendingUntrackedChanges = newValue } } } package func removePendingChanges() { - _pendingDatabaseChanges.withValue { $0.removeAll() } - _pendingRecordZoneChanges.withValue { $0.removeAll() } + withState { + $0.pendingDatabaseChanges.removeAll() + $0.pendingRecordZoneChanges.removeAll() + $0.hasPendingUntrackedChanges = false + } } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.append(contentsOf: pendingRecordZoneChanges) + withState { + $0.pendingRecordZoneChanges.append(contentsOf: pendingRecordZoneChanges) } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.subtract(pendingRecordZoneChanges) + withState { + $0.pendingRecordZoneChanges.subtract(pendingRecordZoneChanges) } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.append(contentsOf: pendingDatabaseChanges) + withState { + $0.pendingDatabaseChanges.append(contentsOf: pendingDatabaseChanges) } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.subtract(pendingDatabaseChanges) + withState { + $0.pendingDatabaseChanges.subtract(pendingDatabaseChanges) } } } @@ -218,7 +229,9 @@ column: UInt = #column ) async throws -> SendRecordsCallback { let syncEngine = syncEngine(for: scope) - guard !syncEngine.state.pendingRecordZoneChanges.isEmpty + guard + !syncEngine.state.withState(\.pendingRecordZoneChanges.isEmpty) + || syncEngine.state.withState(\.hasPendingUntrackedChanges) else { reportIssue( "Processing empty set of record zone changes.", @@ -264,6 +277,7 @@ } guard let batch else { + syncEngine.state.withState { $0.hasPendingUntrackedChanges = false } return SendRecordsCallback {} } @@ -315,7 +329,8 @@ pendingRecordZoneChanges: failedRecordDeletes.keys.map { .deleteRecord($0) } ) - return SendRecordsCallback { [savedRecords, failedRecordSaves, deletedRecordIDs, failedRecordDeletes] in + return SendRecordsCallback { + [savedRecords, failedRecordSaves, deletedRecordIDs, failedRecordDeletes] in await syncEngine.parentSyncEngine .handleEvent( .sentRecordZoneChanges( @@ -357,80 +372,84 @@ line: UInt = #line, column: UInt = #column ) async throws { + nonisolated(unsafe) var zonesToSave: [CKRecordZone] = [] + nonisolated(unsafe) var zoneIDsToDelete: [CKRecordZone.ID] = [] + nonisolated(unsafe) var savedZones: [CKRecordZone] = [] + nonisolated(unsafe) var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] + nonisolated(unsafe) var deletedZoneIDs: [CKRecordZone.ID] = [] + nonisolated(unsafe) var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:] let syncEngine = syncEngine(for: scope) - guard !syncEngine.state.pendingDatabaseChanges.isEmpty - else { - reportIssue( - "Processing empty set of database changes.", - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - return - } - guard try await container.accountStatus() == .available - else { - reportIssue( - "User must be logged in to process pending changes.", - fileID: fileID, - filePath: filePath, - line: line, - column: column - ) - return - } - - var zonesToSave: [CKRecordZone] = [] - var zoneIDsToDelete: [CKRecordZone.ID] = [] - for pendingDatabaseChange in syncEngine.state.pendingDatabaseChanges { - switch pendingDatabaseChange { - case .saveZone(let zone): - zonesToSave.append(zone) - case .deleteZone(let zoneID): - zoneIDsToDelete.append(zoneID) - @unknown default: - fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)") + try syncEngine.state.withState { syncEngineState in + guard !syncEngineState.pendingDatabaseChanges.isEmpty + else { + reportIssue( + "Processing empty set of database changes.", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + return } - } - let results: - ( - saveResults: [CKRecordZone.ID: Result], - deleteResults: [CKRecordZone.ID: Result] - ) = try $_isRateLimitingDisabled.withValue(true) { - try syncEngine.database.modifyRecordZones( - saving: zonesToSave, - deleting: zoneIDsToDelete + guard + let mockContainer = container as? MockCloudContainer, + mockContainer.accountStatus() == .available + else { + reportIssue( + "User must be logged in to process pending changes.", + fileID: fileID, + filePath: filePath, + line: line, + column: column ) + return } - var savedZones: [CKRecordZone] = [] - var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] - var deletedZoneIDs: [CKRecordZone.ID] = [] - var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:] - for (zoneID, saveResult) in results.saveResults { - switch saveResult { - case .success(let zone): - savedZones.append(zone) - case .failure(let error as CKError): - failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error)) - case .failure(let error): - reportIssue("Error thrown not CKError: \(error)") + + for pendingDatabaseChange in syncEngineState.pendingDatabaseChanges { + switch pendingDatabaseChange { + case .saveZone(let zone): + zonesToSave.append(zone) + case .deleteZone(let zoneID): + zoneIDsToDelete.append(zoneID) + @unknown default: + fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)") + } } - } - for (zoneID, deleteResult) in results.deleteResults { - switch deleteResult { - case .success: - deletedZoneIDs.append(zoneID) - case .failure(let error as CKError): - failedZoneDeletes[zoneID] = error - case .failure(let error): - reportIssue("Error thrown not CKError: \(error)") + let results: + ( + saveResults: [CKRecordZone.ID: Result], + deleteResults: [CKRecordZone.ID: Result] + ) = try $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.modifyRecordZones( + saving: zonesToSave, + deleting: zoneIDsToDelete + ) + } + for (zoneID, saveResult) in results.saveResults { + switch saveResult { + case .success(let zone): + savedZones.append(zone) + case .failure(let error as CKError): + failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error)) + case .failure(let error): + reportIssue("Error thrown not CKError: \(error)") + } + } + for (zoneID, deleteResult) in results.deleteResults { + switch deleteResult { + case .success: + deletedZoneIDs.append(zoneID) + case .failure(let error as CKError): + failedZoneDeletes[zoneID] = error + case .failure(let error): + reportIssue("Error thrown not CKError: \(error)") + } } + } syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) - await syncEngine.parentSyncEngine .handleEvent( .sentDatabaseChanges( diff --git a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift index c62ebf37..9562acaf 100644 --- a/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift +++ b/Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift @@ -22,6 +22,7 @@ package protocol CKSyncEngineStateProtocol: Sendable { var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { get } var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { get } + var hasPendingUntrackedChanges: Bool { get nonmutating set } func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift index 55074668..03e94d9d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineTests.swift @@ -39,6 +39,43 @@ try await syncEngine.processPendingRecordZoneChanges(scope: .private) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `untracked changes ask delegate for next batch`() async throws { + #expect(syncEngine.private.state.pendingRecordZoneChanges.isEmpty) + syncEngine.private.state.hasPendingUntrackedChanges = true + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + #expect(!syncEngine.private.state.hasPendingUntrackedChanges) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `sendChanges sends when only untracked changes are pending`() async throws { + syncEngine.private.state.hasPendingUntrackedChanges = true + + try await syncEngine.private.sendChanges(CKSyncEngine.SendChangesOptions()) + + #expect(!syncEngine.private.state.hasPendingUntrackedChanges) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func `untracked changes flag clears once delegate has nothing to send`() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + syncEngine.private.state.hasPendingUntrackedChanges = true + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + #expect(record.encryptedValues["title"] as? String == "Personal") + #expect(syncEngine.private.state.hasPendingUntrackedChanges) + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + #expect(!syncEngine.private.state.hasPendingUntrackedChanges) + } } } #endif diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 16d1903b..80736f58 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -172,19 +172,22 @@ package var customDumpMirror: Mirror { return Mirror( self, - children: [ - ( - "pendingRecordZoneChanges", - _pendingRecordZoneChanges.withValue(\.self) - .sorted(by: comparePendingRecordZoneChange) - as Any - ), - ( - "pendingDatabaseChanges", - _pendingDatabaseChanges.withValue(\.self) - .sorted(by: comparePendingDatabaseChange) as Any - ), - ], + children: withState { + [ + ( + "pendingRecordZoneChanges", + $0.pendingRecordZoneChanges + .sorted(by: comparePendingRecordZoneChange) + as any Sendable + ), + ( + "pendingDatabaseChanges", + $0.pendingDatabaseChanges + .sorted(by: comparePendingDatabaseChange) + as any Sendable + ), + ] as [(label: String?, value: any Sendable)] + }, displayStyle: .struct ) } @@ -248,13 +251,13 @@ self, children: [ "databaseScope": databaseScope, - "storage": state - .value - .storage - .flatMap { _, value in value.records.values } - .sorted { - ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) - }, + "storage": state.withValue { + $0.storage + .flatMap { _, value in value.records.values } + .sorted { + ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) + } + }, ], displayStyle: .struct ) diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index ff17bc80..bf34a26a 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -165,16 +165,16 @@ extension MockSyncEngineState { line: UInt = #line, column: UInt = #column ) { - _pendingRecordZoneChanges.withValue { + withState { expectNoDifference( Set(changes), - Set($0), + Set($0.pendingRecordZoneChanges), fileID: fileID, filePath: filePath, line: line, column: column ) - $0.removeAll() + $0.pendingRecordZoneChanges.removeAll() } } @@ -185,16 +185,16 @@ extension MockSyncEngineState { line: UInt = #line, column: UInt = #column ) { - _pendingDatabaseChanges.withValue { + withState { expectNoDifference( Set(changes), - Set($0), + Set($0.pendingDatabaseChanges), fileID: fileID, filePath: filePath, line: line, column: column ) - $0.removeAll() + $0.pendingDatabaseChanges.removeAll() } } From 675a57ac7d00972d3cc6b57bd571cd1894fd40f2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 09:47:42 -0600 Subject: [PATCH 2/9] Always enqueue pending changes when preparing batches. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 35 +++++++--- .../ReferenceViolationTests.swift | 65 +++++++++++++++++++ .../SyncEngineDelegateTests.swift | 1 - 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index e1dc5d1a..ea4869f6 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -643,24 +643,41 @@ } } - private func enqueueLocallyPendingChanges() async throws { + private func enqueueLocallyPendingChanges(scope: CKDatabase.Scope? = nil) async throws { let pendingRecordZoneChanges = try await metadatabase.write { db in try PendingRecordZoneChange .delete() .returning(\.pendingRecordZoneChange) .fetchAll(db) } - let changesByIsPrivate = Dictionary(grouping: pendingRecordZoneChanges) { + let changesByScope = [CKDatabase.Scope?: _](grouping: pendingRecordZoneChanges) { switch $0 { case .deleteRecord(let recordID), .saveRecord(let recordID): recordID.zoneID.ownerName == CKCurrentUserDefaultName + ? CKDatabase.Scope.private + : .shared @unknown default: - false + nil } } - syncEngines.withValue { - $0.private?.state.add(pendingRecordZoneChanges: changesByIsPrivate[true] ?? []) - $0.shared?.state.add(pendingRecordZoneChanges: changesByIsPrivate[false] ?? []) + switch scope { + case .private: + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByScope[.private] ?? []) + } + case .shared: + syncEngines.withValue { + $0.shared?.state.add(pendingRecordZoneChanges: changesByScope[.shared] ?? []) + } + case .none: + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByScope[.private] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByScope[.shared] ?? []) + } + case .public: + fatalError("Only 'private' and 'shared' scopes are supported.") + @unknown default: + fatalError("Only 'private' and 'shared' scopes are supported.") } } @@ -1087,7 +1104,6 @@ var changes = await pendingRecordZoneChanges(options: options, syncEngine: syncEngine) guard !changes.isEmpty else { return nil } - changes.sort { lhs, rhs in switch (lhs, rhs) { case (.saveRecord(let lhs), .saveRecord(let rhs)): @@ -1238,6 +1254,9 @@ options: CKSyncEngine.SendChangesOptions, syncEngine: any SyncEngineProtocol ) async -> [CKSyncEngine.PendingRecordZoneChange] { + await withErrorReporting(.sqliteDataCloudKitFailure) { + try await enqueueLocallyPendingChanges(scope: syncEngine.database.databaseScope) + } var changes = syncEngine.state.pendingRecordZoneChanges.filter(options.scope.contains) guard !changes.isEmpty else { return [] } @@ -1888,7 +1907,7 @@ scope: syncEngine.database.databaseScope ) await withErrorReporting(.sqliteDataCloudKitFailure) { - try await enqueueLocallyPendingChanges() + try await enqueueLocallyPendingChanges(scope: syncEngine.database.databaseScope) } } func updateQuotaExceeded(isExceeded: Bool, scope: CKDatabase.Scope) { diff --git a/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift index eaabf060..c518fb1a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift @@ -7,6 +7,7 @@ import SQLiteDataTestSupport import SnapshotTestingCustomDump import Testing + import TestLocals extension BaseCloudKitTests { @MainActor @@ -408,6 +409,70 @@ } } } + + // * The user's iCloud storage is full. + // * Local client creates a list and a reminder in it. Both saves fail with + // 'quotaExceeded' and wait until a later save succeeds. + // * The user frees up space, then adds another reminder to the list. + // => The new reminder is kept, and the list and both reminders are uploaded. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.taskLocal($quota, 0)) + func addReminderAfterListFailedToSaveWithQuotaExceeded() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.private.database.state.withValue { $0.quota = .max } + try await userDatabase.userWrite { db in + try Reminder.insert { Reminder(id: 1, title: "Get milk", remindersListID: 1) } + .execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let reminders = try await userDatabase.read { db in + try Reminder.order(by: \.id).fetchAll(db) + } + expectNoDifference( + reminders, + [Reminder(id: 1, title: "Get milk", remindersListID: 1)] + ) + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + id: 1, + isCompleted: 0, + remindersListID: 1, + title: "Get milk" + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Personal" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift index ce675bed..b932b6f2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift @@ -533,7 +533,6 @@ .execute(db) } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( From 1214faa9748a5606669bd5ec6f4cb6b09f22634b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 10:48:23 -0600 Subject: [PATCH 3/9] Set hasPendingUntrackedChanges before sending changes. --- .../CloudKit/Internal/Metadatabase.swift | 9 +++ .../Internal/PendingRecordZoneChange.swift | 8 ++- Sources/SQLiteData/CloudKit/SyncEngine.swift | 56 +++++++++++-------- .../SyncEngineLifecycleTests.swift | 11 ++-- 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift index 5b075097..a6b1cbde 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Metadatabase.swift @@ -138,6 +138,15 @@ ) .execute(db) } + migrator.registerMigration("Add 'databaseScope' to 'pendingRecordZoneChanges'") { db in + try #sql( + """ + ALTER TABLE "\(raw: .sqliteDataCloudKitSchemaName)_pendingRecordZoneChanges" + ADD COLUMN "databaseScope" INTEGER + """ + ) + .execute(db) + } #if DEBUG try metadatabase.read { db in let hasSchemaChanges = try migrator.hasSchemaChanges(db) diff --git a/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift index 43e24cdc..c3927a5e 100644 --- a/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift +++ b/Sources/SQLiteData/CloudKit/Internal/PendingRecordZoneChange.swift @@ -10,12 +10,18 @@ package struct PendingRecordZoneChange { @Column(as: CKSyncEngine.PendingRecordZoneChange.DataRepresentation.self) package let pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange + @Column(as: CKDatabase.Scope?.RawRepresentation.self) + package let databaseScope: CKDatabase.Scope? } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension PendingRecordZoneChange { - package init(_ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange) { + package init( + _ pendingRecordZoneChange: CKSyncEngine.PendingRecordZoneChange, + databaseScope: CKDatabase.Scope? + ) { self.pendingRecordZoneChange = pendingRecordZoneChange + self.databaseScope = databaseScope } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index ea4869f6..86f159b4 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -593,7 +593,8 @@ } guard let privateSyncEngine, let sharedSyncEngine else { return } - try await enqueueLocallyPendingChanges() + privateSyncEngine.state.hasPendingUntrackedChanges = true + sharedSyncEngine.state.hasPendingUntrackedChanges = true async let `private`: Void = privateSyncEngine.sendChanges(options) async let shared: Void = sharedSyncEngine.sendChanges(options) _ = try await (`private`, shared) @@ -646,6 +647,10 @@ private func enqueueLocallyPendingChanges(scope: CKDatabase.Scope? = nil) async throws { let pendingRecordZoneChanges = try await metadatabase.write { db in try PendingRecordZoneChange + .where { + $0.databaseScope.is(#bind(scope)) + .or(BindQueryExpression(scope, as: CKDatabase.Scope?.RawRepresentation.self).is(nil)) + } .delete() .returning(\.pendingRecordZoneChange) .fetchAll(db) @@ -660,24 +665,9 @@ nil } } - switch scope { - case .private: - syncEngines.withValue { - $0.private?.state.add(pendingRecordZoneChanges: changesByScope[.private] ?? []) - } - case .shared: - syncEngines.withValue { - $0.shared?.state.add(pendingRecordZoneChanges: changesByScope[.shared] ?? []) - } - case .none: - syncEngines.withValue { - $0.private?.state.add(pendingRecordZoneChanges: changesByScope[.private] ?? []) - $0.shared?.state.add(pendingRecordZoneChanges: changesByScope[.shared] ?? []) - } - case .public: - fatalError("Only 'private' and 'shared' scopes are supported.") - @unknown default: - fatalError("Only 'private' and 'shared' scopes are supported.") + syncEngines.withValue { + $0.private?.state.add(pendingRecordZoneChanges: changesByScope[.private] ?? []) + $0.shared?.state.add(pendingRecordZoneChanges: changesByScope[.shared] ?? []) } } @@ -847,7 +837,10 @@ try PendingRecordZoneChange .insert { for change in changes { - PendingRecordZoneChange(change) + PendingRecordZoneChange( + change, + databaseScope: change.id?.zoneID.databaseScope + ) } } .execute(db) @@ -889,7 +882,14 @@ await withErrorReporting(.sqliteDataCloudKitFailure) { try await userDatabase.write { db in try PendingRecordZoneChange - .insert { changes.map { PendingRecordZoneChange($0) } } + .insert { + changes.map { change in + PendingRecordZoneChange( + change, + databaseScope: change.id?.zoneID.databaseScope + ) + } + } .execute(db) } } @@ -1813,7 +1813,10 @@ await withErrorReporting { try await metadatabase.write { db in try PendingRecordZoneChange.insert { - PendingRecordZoneChange(.saveRecord(failedRecord.recordID)) + PendingRecordZoneChange( + .saveRecord(failedRecord.recordID), + databaseScope: failedRecord.recordID.zoneID.databaseScope + ) } .execute(db) } @@ -2165,6 +2168,9 @@ case .deleteRecord(let id): return id @unknown default: + reportIssue(""" + Unrecognized PendingRecordZoneChange + """) return nil } } @@ -2193,6 +2199,12 @@ } } +extension CKRecordZone.ID { + fileprivate var databaseScope: CKDatabase.Scope { + ownerName == CKCurrentUserDefaultName ? .private : .shared + } +} + extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" package static let sqliteDataCloudKitFailure = "SQLiteData CloudKit Failure" diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift index cf22cdd2..00dced7f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineLifecycleTests.swift @@ -201,11 +201,12 @@ assertQuery(PendingRecordZoneChange.all, database: syncEngine.metadatabase) { """ - ┌─────────────────────────────────────────────────────────────────────────────────────────────┐ - │ PendingRecordZoneChange( │ - │ pendingRecordZoneChange: .saveRecord(CKRecord.ID(1:remindersLists/zone/__defaultOwner__)) │ - │ ) │ - └─────────────────────────────────────────────────────────────────────────────────────────────┘ + ┌──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ PendingRecordZoneChange( │ + │ pendingRecordZoneChange: .saveRecord(CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), │ + │ databaseScope: .private │ + │ ) │ + └──────────────────────────────────────────────────────────────────────────────────────────────┘ """ } assertQuery(RemindersList.all, database: userDatabase.database) { From f8e09d5d27db46c40895870e967a80f009c548cf Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 11:54:17 -0600 Subject: [PATCH 4/9] Clean up --- .../CloudKit/Internal/MockSyncEngine.swift | 55 ++++++++++--------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 + 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 9e28065f..0d9731eb 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -230,8 +230,9 @@ ) async throws -> SendRecordsCallback { let syncEngine = syncEngine(for: scope) guard - !syncEngine.state.withState(\.pendingRecordZoneChanges.isEmpty) - || syncEngine.state.withState(\.hasPendingUntrackedChanges) + syncEngine.state.withState({ + !$0.pendingRecordZoneChanges.isEmpty || $0.hasPendingUntrackedChanges + }) else { reportIssue( "Processing empty set of record zone changes.", @@ -372,14 +373,17 @@ line: UInt = #line, column: UInt = #column ) async throws { - nonisolated(unsafe) var zonesToSave: [CKRecordZone] = [] - nonisolated(unsafe) var zoneIDsToDelete: [CKRecordZone.ID] = [] - nonisolated(unsafe) var savedZones: [CKRecordZone] = [] - nonisolated(unsafe) var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] - nonisolated(unsafe) var deletedZoneIDs: [CKRecordZone.ID] = [] - nonisolated(unsafe) var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:] + struct Changes { + var zonesToSave: [CKRecordZone] = [] + var zoneIDsToDelete: [CKRecordZone.ID] = [] + var savedZones: [CKRecordZone] = [] + var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] + var deletedZoneIDs: [CKRecordZone.ID] = [] + var failedZoneDeletes: [CKRecordZone.ID: CKError] = [:] + } let syncEngine = syncEngine(for: scope) - try syncEngine.state.withState { syncEngineState in + let changes = try syncEngine.state.withState { syncEngineState -> Changes in + var changes = Changes() guard !syncEngineState.pendingDatabaseChanges.isEmpty else { reportIssue( @@ -389,7 +393,7 @@ line: line, column: column ) - return + return changes } guard let mockContainer = container as? MockCloudContainer, @@ -402,15 +406,15 @@ line: line, column: column ) - return + return changes } for pendingDatabaseChange in syncEngineState.pendingDatabaseChanges { switch pendingDatabaseChange { case .saveZone(let zone): - zonesToSave.append(zone) + changes.zonesToSave.append(zone) case .deleteZone(let zoneID): - zoneIDsToDelete.append(zoneID) + changes.zoneIDsToDelete.append(zoneID) @unknown default: fatalError("Unsupported pendingDatabaseChange: \(pendingDatabaseChange)") } @@ -421,16 +425,16 @@ deleteResults: [CKRecordZone.ID: Result] ) = try $_isRateLimitingDisabled.withValue(true) { try syncEngine.database.modifyRecordZones( - saving: zonesToSave, - deleting: zoneIDsToDelete + saving: changes.zonesToSave, + deleting: changes.zoneIDsToDelete ) } for (zoneID, saveResult) in results.saveResults { switch saveResult { case .success(let zone): - savedZones.append(zone) + changes.savedZones.append(zone) case .failure(let error as CKError): - failedZoneSaves.append((zonesToSave.first(where: { $0.zoneID == zoneID })!, error)) + changes.failedZoneSaves.append((changes.zonesToSave.first(where: { $0.zoneID == zoneID })!, error)) case .failure(let error): reportIssue("Error thrown not CKError: \(error)") } @@ -438,25 +442,26 @@ for (zoneID, deleteResult) in results.deleteResults { switch deleteResult { case .success: - deletedZoneIDs.append(zoneID) + changes.deletedZoneIDs.append(zoneID) case .failure(let error as CKError): - failedZoneDeletes[zoneID] = error + changes.failedZoneDeletes[zoneID] = error case .failure(let error): reportIssue("Error thrown not CKError: \(error)") } } + return changes } - syncEngine.state.remove(pendingDatabaseChanges: savedZones.map { .saveZone($0) }) - syncEngine.state.remove(pendingDatabaseChanges: deletedZoneIDs.map { .deleteZone($0) }) + syncEngine.state.remove(pendingDatabaseChanges: changes.savedZones.map { .saveZone($0) }) + syncEngine.state.remove(pendingDatabaseChanges: changes.deletedZoneIDs.map { .deleteZone($0) }) await syncEngine.parentSyncEngine .handleEvent( .sentDatabaseChanges( - savedZones: savedZones, - failedZoneSaves: failedZoneSaves, - deletedZoneIDs: deletedZoneIDs, - failedZoneDeletes: failedZoneDeletes + savedZones: changes.savedZones, + failedZoneSaves: changes.failedZoneSaves, + deletedZoneIDs: changes.deletedZoneIDs, + failedZoneDeletes: changes.failedZoneDeletes ), syncEngine: syncEngine ) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 86f159b4..c1add1c8 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -598,6 +598,8 @@ async let `private`: Void = privateSyncEngine.sendChanges(options) async let shared: Void = sharedSyncEngine.sendChanges(options) _ = try await (`private`, shared) + // TODO: revert hasPendingUntrackedChanges + // TODO: fail in deinit of base test if hasPendingUntrackedChanges is true } /// Synchronizes local and remote pending changes. From 88ace2228bf772ae87955028a813097bb139a575 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 12:05:16 -0600 Subject: [PATCH 5/9] clean up --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index c1add1c8..a6cb9ae3 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -651,7 +651,7 @@ try PendingRecordZoneChange .where { $0.databaseScope.is(#bind(scope)) - .or(BindQueryExpression(scope, as: CKDatabase.Scope?.RawRepresentation.self).is(nil)) + .or(#bind(scope, as: CKDatabase.Scope?.RawRepresentation.self).is(nil)) } .delete() .returning(\.pendingRecordZoneChange) @@ -2171,7 +2171,8 @@ return id @unknown default: reportIssue(""" - Unrecognized PendingRecordZoneChange + Unrecognized 'PendingRecordZoneChange'. Open an issue on \ + https://github.com/pointfreeco/sqlite-data if you run into this. """) return nil } From 65404104edf18add90c9e90639f1477b4fe5b05b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 13:56:54 -0600 Subject: [PATCH 6/9] Fix sqlite busy mode. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index a6cb9ae3..54ea6dec 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -647,7 +647,7 @@ } private func enqueueLocallyPendingChanges(scope: CKDatabase.Scope? = nil) async throws { - let pendingRecordZoneChanges = try await metadatabase.write { db in + let pendingRecordZoneChanges = try await userDatabase.write { db in try PendingRecordZoneChange .where { $0.databaseScope.is(#bind(scope)) @@ -1813,7 +1813,7 @@ case .quotaExceeded: await withErrorReporting { - try await metadatabase.write { db in + try await userDatabase.write { db in try PendingRecordZoneChange.insert { PendingRecordZoneChange( .saveRecord(failedRecord.recordID), From 5581ad31b1d94fbcf1e82be08c9477011bef0ab4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 14:17:55 -0600 Subject: [PATCH 7/9] format --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 54ea6dec..32a5da7b 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2170,10 +2170,12 @@ case .deleteRecord(let id): return id @unknown default: - reportIssue(""" + reportIssue( + """ Unrecognized 'PendingRecordZoneChange'. Open an issue on \ https://github.com/pointfreeco/sqlite-data if you run into this. - """) + """ + ) return nil } } @@ -2202,11 +2204,11 @@ } } -extension CKRecordZone.ID { - fileprivate var databaseScope: CKDatabase.Scope { - ownerName == CKCurrentUserDefaultName ? .private : .shared + extension CKRecordZone.ID { + fileprivate var databaseScope: CKDatabase.Scope { + ownerName == CKCurrentUserDefaultName ? .private : .shared + } } -} extension String { package static let sqliteDataCloudKitSchemaName = "sqlitedata_icloud" From 2a3737459271f642ae9b347af14c9ca9dd1dbb25 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 5 Nov 2026 12:31:36 -0800 Subject: [PATCH 8/9] Fix typo in error message for PendingRecordZoneChange --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 32a5da7b..848a353c 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2172,7 +2172,7 @@ @unknown default: reportIssue( """ - Unrecognized 'PendingRecordZoneChange'. Open an issue on \ + Unrecognized 'PendingRecordZoneChange'. Open an issue at \ https://github.com/pointfreeco/sqlite-data if you run into this. """ ) From cb78bb8a883d6730fbcd6a9d9601e620d6674b13 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 14:34:44 -0600 Subject: [PATCH 9/9] Reset hasUntrackedChanges after sending changes. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 6 +- .../MockCloudDatabaseRateLimitTests.swift | 14 ++- .../MockCloudDatabaseTests.swift | 14 ++- .../Internal/BaseCloudKitTests.swift | 97 +++++++++++++++++-- .../Internal/CloudKitTestHelpers.swift | 1 - 5 files changed, 116 insertions(+), 16 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 848a353c..c8a296ea 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -595,11 +595,13 @@ else { return } privateSyncEngine.state.hasPendingUntrackedChanges = true sharedSyncEngine.state.hasPendingUntrackedChanges = true + defer { + privateSyncEngine.state.hasPendingUntrackedChanges = false + sharedSyncEngine.state.hasPendingUntrackedChanges = false + } async let `private`: Void = privateSyncEngine.sendChanges(options) async let shared: Void = sharedSyncEngine.sendChanges(options) _ = try await (`private`, shared) - // TODO: revert hasPendingUntrackedChanges - // TODO: fail in deinit of base test if hasPendingUntrackedChanges is true } /// Synchronizes local and remote pending changes. diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift index 087d5358..006bea17 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift @@ -21,8 +21,18 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - override init() async throws { - try await super.init() + override init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + try await super.init( + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) try $_isRateLimitingDisabled.withValue(true) { _ = try database.modifyRecords( saving: [CKRecord(recordType: "A", recordID: recordID)], diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 465163b9..2d391221 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -12,8 +12,18 @@ @MainActor final class MockCloudDatabaseTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - override init() async throws { - try await super.init() + override init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + try await super.init( + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) let (saveZoneResults, _) = try syncEngine.private.database.modifyRecordZones( saving: [ CKRecordZone( diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 97d9a5c2..aec72d1c 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -24,6 +24,10 @@ let userDatabase: UserDatabase private let _syncEngine: any Sendable private let _container: any Sendable + private let filePath: StaticString + private let fileID: StaticString + private let line: UInt + private let column: UInt @Dependency(\.continuousClock) var clock @Dependency(\.currentTime.now) var now @@ -47,7 +51,16 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - init() async throws { + init( + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column let testContainerIdentifier = "iCloud.co.pointfree.Testing.\(UUID())" self.userDatabase = UserDatabase( @@ -149,14 +162,80 @@ guard syncEngine.isRunning else { return } - syncEngine.shared.assertFetchChangesScopes([]) - syncEngine.shared.state.assertPendingDatabaseChanges([]) - syncEngine.shared.state.assertPendingRecordZoneChanges([]) - syncEngine.shared.assertAcceptedShareMetadata([]) - syncEngine.private.assertFetchChangesScopes([]) - syncEngine.private.state.assertPendingDatabaseChanges([]) - syncEngine.private.state.assertPendingRecordZoneChanges([]) - syncEngine.private.assertAcceptedShareMetadata([]) + syncEngine.shared.assertFetchChangesScopes( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.shared.state.assertPendingDatabaseChanges( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.shared.state.assertPendingRecordZoneChanges( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.shared.assertAcceptedShareMetadata( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.private.assertFetchChangesScopes( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.private.state.assertPendingDatabaseChanges( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.private.state.assertPendingRecordZoneChanges( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + syncEngine.private.assertAcceptedShareMetadata( + [], + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + #expect( + !syncEngine.private.state.hasPendingUntrackedChanges, + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) + #expect( + !syncEngine.shared.state.hasPendingUntrackedChanges, + sourceLocation: SourceLocation( + fileID: String(describing: fileID), + filePath: String(describing: filePath), + line: Int(line), + column: Int(column) + ) + ) try! syncEngine.metadatabase.read { db in try #expect(UnsyncedRecordID.fetchCount(db) == 0) diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index bf34a26a..0939831d 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -197,5 +197,4 @@ extension MockSyncEngineState { $0.pendingDatabaseChanges.removeAll() } } - }