diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index d2f0726e..01fe3104 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -26,9 +26,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 } @@ -46,16 +44,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) } @@ -68,9 +62,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 } @@ -88,22 +80,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 archiver: NSKeyedArchiver) { + guard isTesting else { return } + archiver.encode( + self._recordChangeTag, + forKey: "_recordChangeTag" + ) + archiver.encode( + self._modificationDate.map { $0 as NSDate }, + forKey: "_modificationDate" + ) + } + + fileprivate func decodeMockSystemFieldsIfNeeded(from unarchiver: NSKeyedUnarchiver) { + guard isTesting else { return } + self._recordChangeTag = unarchiver.decodeObject( + of: NSNumber.self, + forKey: "_recordChangeTag" + )?.intValue + self._modificationDate = unarchiver.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 b0149fd2..eee68956 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -13,6 +13,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)] = [] @@ -20,6 +21,10 @@ lastRecordChangeTag += 1 return lastRecordChangeTag } + mutating func nextModificationDate() -> Date { + lastModificationDate += 1 + return Date(timeIntervalSinceReferenceDate: TimeInterval(lastModificationDate)) + } } struct AssetID: Hashable { @@ -114,6 +119,7 @@ switch savePolicy { case .ifServerRecordUnchanged: + let batchModificationDate = state.nextModificationDate() for recordToSave in recordsToSave { if let share = recordToSave as? CKShare { let isSavingRootRecord = recordsToSave.contains(where: { @@ -191,6 +197,7 @@ guard let databaseCopy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } databaseCopy._recordChangeTag = state.nextRecordChangeTag() + databaseCopy._modificationDate = batchModificationDate for key in databaseCopy.allKeys() { guard let assetURL = (databaseCopy[key] as? CKAsset)?.fileURL diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index b9840148..926c1eb7 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -15,7 +15,7 @@ @Suite(.taskLocal(CKRecord._$printTimestamps, true)) 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: "") @@ -144,7 +144,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 465163b9..ab58bdbb 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