Skip to content

Commit 8277983

Browse files
committed
wip3
1 parent befd4f5 commit 8277983

9 files changed

Lines changed: 138 additions & 42 deletions

Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,21 @@
9494
fileprivate func encodeMockSystemFieldsIfNeeded(with coder: NSKeyedArchiver) {
9595
guard isTesting else { return }
9696
coder.encode(
97-
self._recordChangeTag,
97+
self._recordChangeTag as NSString?,
9898
forKey: "_recordChangeTag"
9999
)
100100
coder.encode(
101-
self._modificationDate.map { $0 as NSDate },
101+
self._modificationDate as NSDate?,
102102
forKey: "_modificationDate"
103103
)
104104
}
105105

106106
fileprivate func decodeMockSystemFieldsIfNeeded(from coder: NSKeyedUnarchiver) {
107107
guard isTesting else { return }
108108
self._recordChangeTag = coder.decodeObject(
109-
of: NSNumber.self,
109+
of: NSString.self,
110110
forKey: "_recordChangeTag"
111-
)?.intValue
111+
) as String?
112112
self._modificationDate = coder.decodeObject(
113113
of: NSDate.self,
114114
forKey: "_modificationDate"
@@ -395,13 +395,6 @@
395395

396396
private struct Unbindable: Error {}
397397

398-
extension CKRecord {
399-
package var _recordChangeTag: Int? {
400-
get { self[#function] }
401-
set { self[#function] = newValue }
402-
}
403-
}
404-
405398
extension DataProtocol {
406399
fileprivate var sha256: Data {
407400
Data(SHA256.hash(data: self))

Sources/SQLiteData/CloudKit/Internal/CKRecord+MockSystemFields.swift

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
import IssueReporting
44
import ObjectiveC
55

6-
nonisolated(unsafe) private var modificationDateKey: UInt8 = 0
7-
86
extension CKRecord {
9-
var _modificationDate: Date? {
7+
package var _modificationDate: Date? {
108
get {
119
objc_getAssociatedObject(self, &modificationDateKey) as? Date
1210
}
@@ -16,12 +14,38 @@
1614
}
1715
}
1816

17+
package var _recordChangeTag: String? {
18+
get {
19+
objc_getAssociatedObject(self, &recordChangeTagKey) as? String
20+
}
21+
set {
22+
installMockSystemFieldOverridesOnce()
23+
objc_setAssociatedObject(self, &recordChangeTagKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
24+
}
25+
}
26+
1927
@objc fileprivate dynamic func _swizzled_modificationDate() -> Date? {
2028
if let override = objc_getAssociatedObject(self, &modificationDateKey) as? Date {
2129
return override
2230
}
2331
return self._swizzled_modificationDate()
2432
}
33+
34+
@objc fileprivate dynamic func _swizzled_recordChangeTag() -> String? {
35+
if let override = objc_getAssociatedObject(self, &recordChangeTagKey) as? String {
36+
return override
37+
}
38+
return self._swizzled_recordChangeTag()
39+
}
40+
41+
@objc fileprivate dynamic func _swizzled_copy(with zone: NSZone?) -> Any {
42+
let copy = self._swizzled_copy(with: zone)
43+
if let copy = copy as? CKRecord {
44+
copy._recordChangeTag = self._recordChangeTag
45+
copy._modificationDate = self._modificationDate
46+
}
47+
return copy
48+
}
2549
}
2650

2751
private func installMockSystemFieldOverridesOnce() {
@@ -30,18 +54,51 @@
3054

3155
private let token: Void = {
3256
guard
33-
let originalMethod = class_getInstanceMethod(
57+
let originalModificationDate = class_getInstanceMethod(
3458
CKRecord.self,
3559
#selector(getter: CKRecord.modificationDate)
3660
),
37-
let swizzledMethod = class_getInstanceMethod(
61+
let swizzledModificationDate = class_getInstanceMethod(
3862
CKRecord.self,
3963
#selector(CKRecord._swizzled_modificationDate)
4064
)
4165
else {
4266
reportIssue("Failed to swizzle CKRecord.modificationDate")
4367
return
4468
}
45-
method_exchangeImplementations(originalMethod, swizzledMethod)
69+
method_exchangeImplementations(originalModificationDate, swizzledModificationDate)
70+
71+
guard
72+
let originalRecordChangeTag = class_getInstanceMethod(
73+
CKRecord.self,
74+
#selector(getter: CKRecord.recordChangeTag)
75+
),
76+
let swizzledRecordChangeTag = class_getInstanceMethod(
77+
CKRecord.self,
78+
#selector(CKRecord._swizzled_recordChangeTag)
79+
)
80+
else {
81+
reportIssue("Failed to swizzle CKRecord.recordChangeTag")
82+
return
83+
}
84+
method_exchangeImplementations(originalRecordChangeTag, swizzledRecordChangeTag)
85+
86+
guard
87+
let originalCopy = class_getInstanceMethod(
88+
CKRecord.self,
89+
#selector(CKRecord.copy(with:))
90+
),
91+
let swizzledCopy = class_getInstanceMethod(
92+
CKRecord.self,
93+
#selector(CKRecord._swizzled_copy(with:))
94+
)
95+
else {
96+
reportIssue("Failed to swizzle CKRecord.copy(with:)")
97+
return
98+
}
99+
method_exchangeImplementations(originalCopy, swizzledCopy)
46100
}()
101+
102+
nonisolated(unsafe) private var modificationDateKey: UInt8 = 0
103+
nonisolated(unsafe) private var recordChangeTagKey: UInt8 = 0
47104
#endif

Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@
1010
let dataManager = Dependency(\.dataManager)
1111

1212
package struct State {
13-
private var lastRecordChangeTag = 0
14-
private var lastModificationDate = 0
13+
private var changeTagCounter = 0
14+
private var modificationDateCounter = 0
1515
package var storage: [CKRecordZone.ID: Zone] = [:]
1616
var assets: [AssetID: Data] = [:]
1717
var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = []
18-
mutating func nextRecordChangeTag() -> Int {
19-
lastRecordChangeTag += 1
20-
return lastRecordChangeTag
18+
// NB: CloudKit uses base-36 change tags (0…9, a…z, 10…zz, 100…).
19+
mutating func nextRecordChangeTag() -> String {
20+
defer { changeTagCounter += 1 }
21+
return String(changeTagCounter, radix: 36)
2122
}
2223
mutating func nextModificationDate() -> Date {
23-
lastModificationDate += 1
24-
return Date(timeIntervalSinceReferenceDate: TimeInterval(lastModificationDate))
24+
defer { modificationDateCounter += 1 }
25+
return Date(timeIntervalSinceReferenceDate: TimeInterval(modificationDateCounter))
2526
}
2627
}
2728

Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,10 @@
4949
zoneID in
5050
accum += ((state.storage[zoneID]?.records.values).map { Array($0) } ?? [])
5151
.filter {
52-
precondition(
53-
$0._recordChangeTag != nil,
54-
"Records stored in database should have their 'recordChangeTag' assigned."
55-
)
56-
return $0._recordChangeTag! > self.state.changeTag.value
52+
guard let recordChangeTag = $0.recordChangeTag else {
53+
fatalError("Records stored in database should have their 'recordChangeTag' assigned.")
54+
}
55+
return recordChangeTag.isNewerChangeTag(than: self.state.currentChangeTag.value)
5756
}
5857
}
5958
}
@@ -71,8 +70,12 @@
7170
guard !modifications.isEmpty || !deletions.isEmpty
7271
else { return }
7372

74-
state.changeTag.withValue { changeTag in
75-
changeTag = modifications.compactMap(\._recordChangeTag).max() ?? changeTag
73+
state.currentChangeTag.withValue { currentChangeTag in
74+
for changeTag in modifications.compactMap(\.recordChangeTag) {
75+
if changeTag.isNewerChangeTag(than: currentChangeTag) {
76+
currentChangeTag = changeTag
77+
}
78+
}
7679
}
7780

7881
await parentSyncEngine.handleEvent(
@@ -134,7 +137,7 @@
134137

135138
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
136139
package final class MockSyncEngineState: CKSyncEngineStateProtocol {
137-
package let changeTag = LockIsolated(0)
140+
package let currentChangeTag = LockIsolated<String?>(nil)
138141
package let _pendingRecordZoneChanges = LockIsolated<
139142
OrderedSet<CKSyncEngine.PendingRecordZoneChange>
140143
>([]
@@ -457,4 +460,11 @@
457460
}
458461
}
459462
}
463+
464+
extension String {
465+
package func isNewerChangeTag(than other: String?) -> Bool {
466+
guard let other else { return true }
467+
return self.count != other.count ? self.count > other.count : self > other
468+
}
469+
}
460470
#endif

Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@
707707
let record = try syncEngine.private.database.record(for: RemindersList.recordID(for: 1))
708708
record.setValue("Work", forKey: "title", at: now)
709709
// NB: Manually setting '_recordChangeTag' simulates another device saving a record.
710-
record._recordChangeTag = .random(in: 9999 ... .max)
710+
record._recordChangeTag = "zz"
711711
try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify()
712712

713713
assertQuery(Reminder.all, database: userDatabase.database) {

Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,11 +711,11 @@
711711

712712
let (results1, _) = try syncEngine.private.database.modifyRecords(saving: [record])
713713
let saved1 = try #require(try results1[record.recordID]?.get())
714-
#expect(saved1.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
714+
#expect(saved1.modificationDate == Date(timeIntervalSinceReferenceDate: 0))
715715

716716
let (results2, _) = try syncEngine.private.database.modifyRecords(saving: [saved1])
717717
let saved2 = try #require(try results2[record.recordID]?.get())
718-
#expect(saved2.modificationDate == Date(timeIntervalSinceReferenceDate: 2))
718+
#expect(saved2.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
719719
}
720720

721721
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#if canImport(CloudKit)
22
import CloudKit
3-
@testable import SQLiteData
3+
import SQLiteData
44
import Testing
55

66
@Suite
@@ -13,30 +13,66 @@
1313
#expect(record.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
1414
}
1515

16+
@Test func recordChangeTagOverride() {
17+
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
18+
#expect(record.recordChangeTag == nil)
19+
20+
record._recordChangeTag = "ab"
21+
#expect(record.recordChangeTag == "ab")
22+
}
23+
24+
@Test func copyPreservesMockSystemFields() {
25+
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
26+
record._recordChangeTag = "ab"
27+
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
28+
29+
let copy = record.copy() as! CKRecord
30+
#expect(copy.recordChangeTag == "ab")
31+
#expect(copy.modificationDate == Date(timeIntervalSinceReferenceDate: 1))
32+
}
33+
1634
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
1735
@Test func systemFieldsRepresentationRoundtrip() throws {
1836
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
19-
record._recordChangeTag = 42
37+
record._recordChangeTag = "ab"
2038
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
2139

2240
let representation = CKRecord.SystemFieldsRepresentation(queryOutput: record)
2341
let result = try #require(CKRecord.SystemFieldsRepresentation(queryBinding: representation.queryBinding))
2442

25-
#expect(result.queryOutput._recordChangeTag == 42)
43+
#expect(result.queryOutput._recordChangeTag == "ab")
2644
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
2745
}
2846

2947
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
3048
@Test func allFieldsRepresentationRoundtrip() throws {
3149
let record = CKRecord(recordType: "record", recordID: CKRecord.ID(recordName: "A"))
32-
record._recordChangeTag = 42
50+
record._recordChangeTag = "ab"
3351
record._modificationDate = Date(timeIntervalSinceReferenceDate: 1)
3452

3553
let representation = CKRecord._AllFieldsRepresentation(queryOutput: record)
3654
let result = try #require(CKRecord._AllFieldsRepresentation(queryBinding: representation.queryBinding))
3755

38-
#expect(result.queryOutput._recordChangeTag == 42)
56+
#expect(result.queryOutput._recordChangeTag == "ab")
3957
#expect(result.queryOutput._modificationDate == Date(timeIntervalSinceReferenceDate: 1))
4058
}
59+
60+
@Test func isNewerChangeTag() {
61+
#expect("0".isNewerChangeTag(than: nil))
62+
63+
#expect(!"0".isNewerChangeTag(than: "0"))
64+
#expect(!"z".isNewerChangeTag(than: "z"))
65+
66+
#expect("1".isNewerChangeTag(than: "0"))
67+
#expect(!"0".isNewerChangeTag(than: "1"))
68+
#expect("a".isNewerChangeTag(than: "9"))
69+
#expect(!"9".isNewerChangeTag(than: "a"))
70+
#expect("z".isNewerChangeTag(than: "a"))
71+
72+
#expect("10".isNewerChangeTag(than: "z"))
73+
#expect(!"z".isNewerChangeTag(than: "10"))
74+
#expect("100".isNewerChangeTag(than: "zz"))
75+
#expect(!"zz".isNewerChangeTag(than: "100"))
76+
}
4177
}
4278
#endif

Tests/SQLiteDataTests/CloudKitTests/NextRecordZoneChangeBatchTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@
308308
recordType: "remindersLists",
309309
parent: nil,
310310
share: nil,
311-
recordChangeTag: 3,
311+
recordChangeTag: "2",
312312
id: 1,
313313
id🗓️: 0,
314314
title: "Personal 3",

Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
}
5151
let nonEncryptedKeys = Set(allKeys())
5252
.subtracting(encryptedValues.allKeys())
53-
.subtracting(["_recordChangeTag"])
5453
var baseChildren = [
5554
("recordID", recordID as Any),
5655
("recordType", recordType as Any),

0 commit comments

Comments
 (0)