diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index c03123a1..4446322a 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -24,9 +24,7 @@ public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encodeSystemFields(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - } + queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver) return archiver.encodedData.queryBinding } @@ -44,16 +42,12 @@ } private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + guard let queryOutput = Record(coder: unarchiver) else { throw DecodingError() } - if isTesting { - queryOutput._recordChangeTag = - coder - .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue - } + queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver) self.init(queryOutput: queryOutput) } @@ -66,9 +60,7 @@ public var queryBinding: QueryBinding { let archiver = NSKeyedArchiver(requiringSecureCoding: true) queryOutput.encode(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - } + queryOutput.encodeMockSystemFieldsIfNeeded(with: archiver) return archiver.encodedData.queryBinding } @@ -86,22 +78,44 @@ } private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = true + guard let queryOutput = Record(coder: unarchiver) else { throw DecodingError() } - if isTesting { - queryOutput._recordChangeTag = - coder - .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue - } + queryOutput.decodeMockSystemFieldsIfNeeded(from: unarchiver) self.init(queryOutput: queryOutput) } private struct DecodingError: Error {} } + extension CKRecord { + fileprivate func encodeMockSystemFieldsIfNeeded(with coder: NSKeyedArchiver) { + guard isTesting else { return } + coder.encode( + self._recordChangeTag, + forKey: "_recordChangeTag" + ) + coder.encode( + self._modificationDate.map { $0 as NSDate }, + forKey: "_modificationDate" + ) + } + + fileprivate func decodeMockSystemFieldsIfNeeded(from coder: NSKeyedUnarchiver) { + guard isTesting else { return } + self._recordChangeTag = coder.decodeObject( + of: NSNumber.self, + forKey: "_recordChangeTag" + )?.intValue + self._modificationDate = coder.decodeObject( + of: NSDate.self, + forKey: "_modificationDate" + ) as Date? + } + } + extension CKDatabase.Scope { public struct RawValueRepresentation: QueryBindable, QueryRepresentable { public let queryOutput: CKDatabase.Scope diff --git a/Sources/SQLiteData/CloudKit/Internal/CKRecord+MockSystemFields.swift b/Sources/SQLiteData/CloudKit/Internal/CKRecord+MockSystemFields.swift new file mode 100644 index 00000000..15fedcb0 --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/CKRecord+MockSystemFields.swift @@ -0,0 +1,47 @@ +#if canImport(CloudKit) + import CloudKit + import IssueReporting + import ObjectiveC + + nonisolated(unsafe) private var modificationDateKey: UInt8 = 0 + + extension CKRecord { + var _modificationDate: Date? { + get { + objc_getAssociatedObject(self, &modificationDateKey) as? Date + } + set { + installMockSystemFieldOverridesOnce() + objc_setAssociatedObject(self, &modificationDateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + @objc fileprivate dynamic func _swizzled_modificationDate() -> Date? { + if let override = objc_getAssociatedObject(self, &modificationDateKey) as? Date { + return override + } + return self._swizzled_modificationDate() + } + } + + private func installMockSystemFieldOverridesOnce() { + _ = token + } + + private let token: Void = { + guard + let originalMethod = class_getInstanceMethod( + CKRecord.self, + #selector(getter: CKRecord.modificationDate) + ), + let swizzledMethod = class_getInstanceMethod( + CKRecord.self, + #selector(CKRecord._swizzled_modificationDate) + ) + else { + reportIssue("Failed to swizzle CKRecord.modificationDate") + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() +#endif diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 50f20237..3730e25a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -11,6 +11,7 @@ package struct State { private var lastRecordChangeTag = 0 + private var lastModificationDate = 0 package var storage: [CKRecordZone.ID: Zone] = [:] var assets: [AssetID: Data] = [:] var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = [] @@ -18,6 +19,10 @@ lastRecordChangeTag += 1 return lastRecordChangeTag } + mutating func nextModificationDate() -> Date { + lastModificationDate += 1 + return Date(timeIntervalSinceReferenceDate: TimeInterval(lastModificationDate)) + } } struct AssetID: Hashable { @@ -112,6 +117,7 @@ switch savePolicy { case .ifServerRecordUnchanged: + let batchModificationDate = state.nextModificationDate() for recordToSave in recordsToSave { if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { @@ -189,6 +195,7 @@ guard let copy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } copy._recordChangeTag = state.nextRecordChangeTag() + copy._modificationDate = batchModificationDate for key in copy.allKeys() { guard let assetURL = (copy[key] as? CKAsset)?.fileURL diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 34530951..321ae43f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -14,7 +14,7 @@ @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { + @Test func clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "") @@ -143,7 +143,7 @@ id: 1, id🗓️: 0, isCompleted: 1, - isCompleted🗓️: 30, + isCompleted🗓️: 60, priority🗓️: 0, remindersListID: 1, remindersListID🗓️: 0, diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index d019ae9e..2891425f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -705,6 +705,31 @@ #expect(error?.code == .limitExceeded) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func modificationDateIncreasesOnSubsequentSaves() throws { + let record = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + + let (results1, _) = try syncEngine.private.database.modifyRecords(saving: [record]) + let saved1 = try #require(try results1[record.recordID]?.get()) + #expect(saved1.modificationDate == Date(timeIntervalSinceReferenceDate: 1)) + + let (results2, _) = try syncEngine.private.database.modifyRecords(saving: [saved1]) + let saved2 = try #require(try results2[record.recordID]?.get()) + #expect(saved2.modificationDate == Date(timeIntervalSinceReferenceDate: 2)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func modificationDateIsSharedWithinBatch() throws { + let recordA = CKRecord(recordType: "A", recordID: CKRecord.ID(recordName: "A")) + let recordB = CKRecord(recordType: "B", recordID: CKRecord.ID(recordName: "B")) + + let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [recordA, recordB]) + let savedA = try #require(try saveResults[recordA.recordID]?.get()) + let savedB = try #require(try saveResults[recordB.recordID]?.get()) + + #expect(savedA.modificationDate == savedB.modificationDate) + } + @Test func records_limitExceeded() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockSystemFieldsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockSystemFieldsTests.swift new file mode 100644 index 00000000..90e5b6b6 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MockSystemFieldsTests.swift @@ -0,0 +1,42 @@ +#if canImport(CloudKit) + import CloudKit + @testable import SQLiteData + import Testing + + @Suite + struct MockSystemFieldsTests { + @Test func modificationDateOverride() { + let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A")) + #expect(record.modificationDate == nil) + + record._modificationDate = Date(timeIntervalSinceReferenceDate: 1) + #expect(record.modificationDate == Date(timeIntervalSinceReferenceDate: 1)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func systemFieldsRepresentationRoundtrip() throws { + let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A")) + record._recordChangeTag = 42 + record._modificationDate = Date(timeIntervalSinceReferenceDate: 1) + + let representation = CKRecord.SystemFieldsRepresentation(queryOutput: record) + let result = try #require(CKRecord.SystemFieldsRepresentation(queryBinding: representation.queryBinding)) + + #expect(result.queryOutput._recordChangeTag == 42) + #expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func allFieldsRepresentationRoundtrip() throws { + let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A")) + record._recordChangeTag = 42 + record._modificationDate = Date(timeIntervalSinceReferenceDate: 1) + + let representation = CKRecord._AllFieldsRepresentation(queryOutput: record) + let result = try #require(CKRecord._AllFieldsRepresentation(queryBinding: representation.queryBinding)) + + #expect(result.queryOutput._recordChangeTag == 42) + #expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1)) + } + } +#endif