diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index b30f3390..9a2cc088 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -199,7 +199,15 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension SyncEngine { - package func processPendingRecordZoneChanges( + package struct SendRecordsCallback { + fileprivate let operation: @Sendable () async -> Void + @discardableResult + package func receive() async { + await operation() + } + } + + package func sendPendingRecordZoneChanges( options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), scope: CKDatabase.Scope, forceAtomicByZone: Bool? = nil, @@ -207,7 +215,7 @@ filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column - ) async throws { + ) async throws -> SendRecordsCallback { let syncEngine = syncEngine(for: scope) guard !syncEngine.state.pendingRecordZoneChanges.isEmpty else { @@ -218,7 +226,7 @@ line: line, column: column ) - return + return SendRecordsCallback {} } guard try await container.accountStatus() == .available else { @@ -231,7 +239,7 @@ line: line, column: column ) - return + return SendRecordsCallback {} } var batch = await nextRecordZoneChangeBatch( @@ -254,7 +262,9 @@ batch?.atomicByZone = forceAtomicByZone } guard let batch - else { return } + else { + return SendRecordsCallback {} + } let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( saving: batch.recordsToSave, @@ -302,16 +312,39 @@ pendingRecordZoneChanges: failedRecordDeletes.keys.map { .deleteRecord($0) } ) - await syncEngine.parentSyncEngine - .handleEvent( - .sentRecordZoneChanges( - savedRecords: savedRecords, - failedRecordSaves: failedRecordSaves, - deletedRecordIDs: deletedRecordIDs, - failedRecordDeletes: failedRecordDeletes - ), - syncEngine: syncEngine - ) + return SendRecordsCallback { [savedRecords, failedRecordSaves, deletedRecordIDs, failedRecordDeletes] in + await syncEngine.parentSyncEngine + .handleEvent( + .sentRecordZoneChanges( + savedRecords: savedRecords, + failedRecordSaves: failedRecordSaves, + deletedRecordIDs: deletedRecordIDs, + failedRecordDeletes: failedRecordDeletes + ), + syncEngine: syncEngine + ) + } + } + + package func processPendingRecordZoneChanges( + options: CKSyncEngine.SendChangesOptions = CKSyncEngine.SendChangesOptions(), + scope: CKDatabase.Scope, + forceAtomicByZone: Bool? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) async throws { + try await sendPendingRecordZoneChanges( + options: options, + scope: scope, + forceAtomicByZone: forceAtomicByZone, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + .receive() } package func processPendingDatabaseChanges( diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 878e832f..5efe7063 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2424,7 +2424,9 @@ self.lastKnownServerRecord = lastKnownServerRecord self._lastKnownServerRecordAllFields = lastKnownServerRecord if let lastKnownServerRecord { - self.userModificationTime = lastKnownServerRecord.userModificationTime + self.userModificationTime = #sql(""" + max(\(self.userModificationTime), \(lastKnownServerRecord.userModificationTime)) + """) } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift index b36cbd8a..098eafb1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift @@ -262,6 +262,71 @@ """ } } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.printTimestamps(true), .printRecordChangeTag) + func editBetweenBatchAndSentRecordZoneChanges() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Personal 2" }.execute(db) + } + + let changes = try await syncEngine.sendPendingRecordZoneChanges(scope: .private) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Personal 3" }.execute(db) + } + await changes.receive() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.read { db in + expectNoDifference( + try RemindersList.fetchAll(db), + [RemindersList(id: 1, title: "Personal 3")] + ) + } + assertInlineSnapshot(of: syncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + recordChangeTag: 3, + id: 1, + id🗓️: 0, + title: "Personal 3", + title🗓️: 2, + 🗓️: 2 + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } } } diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 36ae5fc8..338db9df 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -20,6 +20,7 @@ extension CKRecord { @TaskLocal static var printTimestamps = false + @TaskLocal static var printRecordChangeTag = false } @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) @@ -50,14 +51,18 @@ let nonEncryptedKeys = Set(allKeys()) .subtracting(encryptedValues.allKeys()) .subtracting(["_recordChangeTag"]) + var baseChildren = [ + ("recordID", recordID as Any), + ("recordType", recordType as Any), + ("parent", parent as Any), + ("share", share as Any), + ] + if Self.printRecordChangeTag { + baseChildren.append(("recordChangeTag", _recordChangeTag as Any)) + } return Mirror( self, - children: [ - ("recordID", recordID as Any), - ("recordType", recordType as Any), - ("parent", parent as Any), - ("share", share as Any), - ] + children: baseChildren + keys .map { $0.hasPrefix(CKRecord.userModificationTimeKey) 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..64b1f92f 100644 --- a/Tests/SQLiteDataTests/Internal/TestScopes.swift +++ b/Tests/SQLiteDataTests/Internal/TestScopes.swift @@ -126,4 +126,53 @@ Self(syncEngineDelegate: syncEngineDelegate) } } + + struct _PrintRecordChangeTag: SuiteTrait, TestScoping, TestTrait { + let printRecordChangeTag: Bool + init(_ printRecordChangeTag: Bool = true) { + self.printRecordChangeTag = printRecordChangeTag + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + try await CKRecord.$printRecordChangeTag.withValue(true) { + try await function() + } + } + } + + extension Trait where Self == _PrintRecordChangeTag { + static var printRecordChangeTag: Self { Self() } + static func printRecordChangeTag(_ printRecordChangeTag: Bool) -> Self { + Self(printRecordChangeTag) + } + } + + 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