From b2714103919718d95b4e63ed54c51f69d3122316 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 4 Nov 2026 13:03:51 -0600 Subject: [PATCH 1/9] Support modificationDate. --- .../SQLiteData/CloudKit/CloudKit+StructuredQueries.swift | 9 +++++++++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++-- .../CloudKitTests/MergeConflictTests.swift | 4 +--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index d2f0726e..1f890c06 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -28,6 +28,7 @@ queryOutput.encodeSystemFields(with: archiver) if isTesting { archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") } return archiver.encodedData.queryBinding } @@ -55,6 +56,9 @@ queryOutput._recordChangeTag = coder .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue + queryOutput._modificationDate = + coder + .decodeObject(of: NSDate.self, forKey: "_recordChangeTag") as Date? } self.init(queryOutput: queryOutput) } @@ -379,6 +383,11 @@ get { self[#function] } set { self[#function] = newValue } } + + package var _modificationDate: Date? { + get { modificationDate ?? self[#function] } + set { self[#function] = newValue } + } } extension DataProtocol { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index e1dc5d1a..f9faa082 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2059,8 +2059,8 @@ .execute(db) } - if let lastKnownDate = metadata?.lastKnownServerRecord?.modificationDate { - if let recordDate = record.modificationDate, lastKnownDate < recordDate { + if let lastKnownDate = metadata?.lastKnownServerRecord?._modificationDate { + if let recordDate = record._modificationDate, lastKnownDate < recordDate { try updateLastKnownServerRecord() } } else { diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index b9840148..3192e104 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -69,9 +69,7 @@ let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) record.setValue("Buy milk", forKey: "title", at: 60) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() + let modificationCallback = try syncEngine.modifyRecords(scope: .private, saving: [record]) try await withDependencies { $0.currentTime.now += 30 From 6f2a47a8765fd68ff641d1833998ff00d61092b7 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 4 Nov 2026 11:51:26 -0800 Subject: [PATCH 2/9] wip --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKit/Internal/MockCloudDatabase.swift | 2 ++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/MergeConflictTests.swift | 24 ++++++++++++++----- .../Internal/CloudKit+CustomDump.swift | 2 +- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 1f890c06..de01ad7e 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -58,7 +58,7 @@ .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue queryOutput._modificationDate = coder - .decodeObject(of: NSDate.self, forKey: "_recordChangeTag") as Date? + .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? } self.init(queryOutput: queryOutput) } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 6b1f4cb6..039e5459 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -219,6 +219,8 @@ guard let databaseCopy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } + @Dependency(\.currentTime.now) var now + databaseCopy._modificationDate = Date(timeIntervalSinceReferenceDate: Double(now)) databaseCopy._recordChangeTag = state.nextRecordChangeTag() for key in databaseCopy.allKeys() { diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f9faa082..c7bbcf93 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2060,7 +2060,7 @@ } if let lastKnownDate = metadata?.lastKnownServerRecord?._modificationDate { - if let recordDate = record._modificationDate, lastKnownDate < recordDate { + if let recordDate = record._modificationDate, lastKnownDate <= recordDate { try updateLastKnownServerRecord() } } else { diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 3192e104..a37a8987 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -67,9 +67,13 @@ """ } - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 60) - let modificationCallback = try syncEngine.modifyRecords(scope: .private, saving: [record]) + let modificationCallback = try withDependencies { + $0.currentTime.now += 60 + } operation: { + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue("Buy milk", forKey: "title", at: now) + return try syncEngine.modifyRecords(scope: .private, saving: [record]) + } try await withDependencies { $0.currentTime.now += 30 @@ -77,8 +81,8 @@ try await userDatabase.userWrite { db in try Reminder.find(1).update { $0.isCompleted = true }.execute(db) } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { """ @@ -124,8 +128,16 @@ """ } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() + try await withDependencies { + $0.currentTime.now += 30 + } operation: { + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + await withDependencies { + $0.currentTime.now += 60 + } operation: { + await modificationCallback.notify() + } assertInlineSnapshot(of: container, as: .customDump) { """ diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 16d1903b..63fc80a3 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -56,7 +56,7 @@ } let nonEncryptedKeys = Set(allKeys()) .subtracting(encryptedValues.allKeys()) - .subtracting(["_recordChangeTag"]) + .subtracting(["_modificationDate", "_recordChangeTag"]) var baseChildren = [ ("recordID", recordID as Any), ("recordType", recordType as Any), From 2faf48b12e7cc4de8ce635058b5720f0bc18568f Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 4 Nov 2026 14:38:23 -0600 Subject: [PATCH 3/9] fixes --- .../SQLiteData/CloudKit/CloudKit+StructuredQueries.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index de01ad7e..b65a6221 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -74,6 +74,7 @@ queryOutput.encode(with: archiver) if isTesting { archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") } return archiver.encodedData.queryBinding } @@ -101,6 +102,9 @@ queryOutput._recordChangeTag = coder .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue + queryOutput._modificationDate = + coder + .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? } self.init(queryOutput: queryOutput) } @@ -386,7 +390,9 @@ package var _modificationDate: Date? { get { modificationDate ?? self[#function] } - set { self[#function] = newValue } + set { + self[#function] = newValue + } } } From 85884b8a14936d42a551281bf1845efda2919c6b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 4 Nov 2026 14:40:49 -0600 Subject: [PATCH 4/9] clean up --- .../SQLiteData/CloudKit/CloudKit+StructuredQueries.swift | 4 +--- .../SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index b65a6221..a1b18dd3 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -390,9 +390,7 @@ package var _modificationDate: Date? { get { modificationDate ?? self[#function] } - set { - self[#function] = newValue - } + set { self[#function] = newValue } } } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 039e5459..9ad2260c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -10,6 +10,7 @@ package let databaseScope: CKDatabase.Scope let _container = IsolatedWeakVar() let dataManager = Dependency(\.dataManager) + let now = Dependency(\.currentTime.now) let clock: AnyClock package struct State { @@ -219,8 +220,9 @@ guard let databaseCopy = recordToSave.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } - @Dependency(\.currentTime.now) var now - databaseCopy._modificationDate = Date(timeIntervalSinceReferenceDate: Double(now)) + databaseCopy._modificationDate = Date( + timeIntervalSinceReferenceDate: Double(now.wrappedValue) + ) databaseCopy._recordChangeTag = state.nextRecordChangeTag() for key in databaseCopy.allKeys() { From ad038a60c4390687091a218aa058b83afa3f88f8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 4 Nov 2026 15:22:54 -0600 Subject: [PATCH 5/9] Update modificationDate on references too. --- .../SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 9ad2260c..14835d03 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -106,7 +106,9 @@ for key in record.allKeys() { guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] else { continue } - let url = dataManager.wrappedValue.temporaryDirectory.appending(path: UUID().uuidString) + let url = dataManager.wrappedValue.temporaryDirectory.appending( + path: UUID().uuidString + ) try dataManager.wrappedValue.save(assetData, to: url) record[key] = CKAsset(fileURL: url) } @@ -248,6 +250,9 @@ as? CKRecord { parentRecord._recordChangeTag = state.nextRecordChangeTag() + parentRecord._modificationDate = Date( + timeIntervalSince1970: Double(now.wrappedValue) + ) state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentRecord } } From 362249503af228e652bfd28468ee9628142f812d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 4 Nov 2026 15:24:13 -0600 Subject: [PATCH 6/9] wip --- Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 14835d03..4f741595 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -106,9 +106,7 @@ for key in record.allKeys() { guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] else { continue } - let url = dataManager.wrappedValue.temporaryDirectory.appending( - path: UUID().uuidString - ) + let url = dataManager.wrappedValue.temporaryDirectory.appending(path: UUID().uuidString) try dataManager.wrappedValue.save(assetData, to: url) record[key] = CKAsset(fileURL: url) } From 0830c63a4aea4bff351dceb90ae3bd5d1ed4530a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 10:54:56 -0600 Subject: [PATCH 7/9] Fix date epoch typo. --- Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 4f741595..dbd6a4c1 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -249,7 +249,7 @@ { parentRecord._recordChangeTag = state.nextRecordChangeTag() parentRecord._modificationDate = Date( - timeIntervalSince1970: Double(now.wrappedValue) + timeIntervalSinceReferenceDate: Double(now.wrappedValue) ) state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentRecord } From 5f1f9abbd489ada149a55a664b8c016d8edb3f5b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 15:49:16 -0600 Subject: [PATCH 8/9] Decode share title too. --- .../CloudKit/CloudKit+StructuredQueries.swift | 409 ------------------ 1 file changed, 409 deletions(-) delete mode 100644 Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift deleted file mode 100644 index afc3ad18..00000000 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ /dev/null @@ -1,409 +0,0 @@ -#if canImport(CloudKit) - public import CloudKit - import CryptoKit - import Dependencies - import IssueReporting - public import StructuredQueriesCore - - extension CKRecord { - public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation - } - - extension CKShare { - public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation - } - - extension Optional where Wrapped: CKRecord { - public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation? - public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? - } - - public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: Record - - public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encodeSystemFields(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") - if queryOutput is CKShare { - archiver.encode(queryOutput[CKShare.SystemFieldKey.title], forKey: CKShare.SystemFieldKey.title) - } - } - return archiver.encodedData.queryBinding - } - - public init(queryOutput: Record) { - self.queryOutput = queryOutput - } - - public init?(queryBinding: QueryBinding) { - guard case .blob(let bytes) = queryBinding else { return nil } - try? self.init(data: Data(bytes)) - } - - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - try self.init(data: try Data(decoder: &decoder)) - } - - private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { - throw DecodingError() - } - if isTesting { - queryOutput._recordChangeTag = - coder - .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue - queryOutput._modificationDate = - coder - .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? - if queryOutput is CKShare { - queryOutput[CKShare.SystemFieldKey.title] = coder - .decodeObject(of: NSString.self, forKey: CKShare.SystemFieldKey.title) - } - } - self.init(queryOutput: queryOutput) - } - - private struct DecodingError: Error {} - } - - public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: Record - - public var queryBinding: QueryBinding { - let archiver = NSKeyedArchiver(requiringSecureCoding: true) - queryOutput.encode(with: archiver) - if isTesting { - archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") - archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") - } - return archiver.encodedData.queryBinding - } - - public init(queryOutput: Record) { - self.queryOutput = queryOutput - } - - public init?(queryBinding: QueryBinding) { - guard case .blob(let bytes) = queryBinding else { return nil } - try? self.init(data: Data(bytes)) - } - - public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - try self.init(data: try Data(decoder: &decoder)) - } - - private init(data: Data) throws { - let coder = try NSKeyedUnarchiver(forReadingFrom: data) - coder.requiresSecureCoding = true - guard let queryOutput = Record(coder: coder) else { - throw DecodingError() - } - if isTesting { - queryOutput._recordChangeTag = - coder - .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue - queryOutput._modificationDate = - coder - .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? - } - self.init(queryOutput: queryOutput) - } - - private struct DecodingError: Error {} - } - - extension CKDatabase.Scope { - public struct RawValueRepresentation: QueryBindable, QueryRepresentable { - public let queryOutput: CKDatabase.Scope - public var queryBinding: QueryBinding { - queryOutput.rawValue.queryBinding - } - public init(queryOutput: CKDatabase.Scope) { - self.queryOutput = queryOutput - } - public init?(queryBinding: QueryBinding) { - guard case .int(let rawValue) = queryBinding else { return nil } - try? self.init(rawValue: Int(rawValue)) - } - public init(decoder: inout some QueryDecoder) throws { - try self.init(rawValue: Int(decoder: &decoder)) - } - private init(rawValue: Int) throws { - guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { - throw DecodingError() - } - self.init(queryOutput: queryOutput) - } - private struct DecodingError: Error {} - } - } - - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) - extension CKRecordKeyValueSetting { - fileprivate subscript(at key: String) -> Int64 { - get { - self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1 - } - set { - self["\(CKRecord.userModificationTimeKey)_\(key)"] = max(self[at: key], newValue) - } - } - fileprivate subscript(hash key: String) -> Data? { - get { self["\(key)_hash"] as? Data } - set { self["\(key)_hash"] = newValue } - } - } - - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) - extension CKRecord { - func hasSet(key: String) -> Bool { - self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil - } - - @discardableResult - package func setValue( - _ newValue: some CKRecordValueProtocol & Equatable, - forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 - ) -> Bool { - guard - encryptedValues[at: key] <= userModificationTime, - encryptedValues[key] != newValue - else { return false } - encryptedValues[key] = newValue - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true - } - - @discardableResult - package func setAsset( - _ newValue: CKAsset, - forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 - ) -> Bool { - @Dependency(\.dataManager) var dataManager - guard - let fileURL = newValue.fileURL, - let hash = dataManager.sha256(of: fileURL) - else { return false } - guard - encryptedValues[at: key] <= userModificationTime, - encryptedValues[hash: key] != hash - else { return false } - - self[key] = newValue - encryptedValues[hash: key] = hash - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true - } - - @discardableResult - package func setBytes( - _ newValue: [UInt8], - forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 - ) -> Bool { - guard encryptedValues[at: key] <= userModificationTime - else { return false } - - @Dependency(\.dataManager) var dataManager - let hash = newValue.sha256 - let fileURL = dataManager.temporaryDirectory.appending( - component: - hash - .compactMap { String(format: "%02hhx", $0) } - .joined() - ) - let asset = CKAsset(fileURL: fileURL) - return withErrorReporting(.sqliteDataCloudKitFailure) { - try dataManager.save(Data(newValue), to: fileURL) - self[key] = asset - encryptedValues[at: key] = userModificationTime - encryptedValues[hash: key] = hash - self.userModificationTime = userModificationTime - return true - } - ?? false - } - - @discardableResult - package func removeValue( - forKey key: CKRecord.FieldKey, - at userModificationTime: Int64 - ) -> Bool { - guard encryptedValues[at: key] <= userModificationTime - else { - return false - } - if encryptedValues[key] != nil { - encryptedValues[key] = nil - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true - } else if self[key] != nil { - self[key] = nil - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - return true - } else if !hasSet(key: key) { - encryptedValues[at: key] = userModificationTime - self.userModificationTime = userModificationTime - } - return false - } - - func update(with row: T, userModificationTime: Int64) { - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let keyPath = column.keyPath as! KeyPath - let column = column as! any WritableTableColumnExpression - let value = Value(queryOutput: row[keyPath: keyPath]) - switch value.queryBinding { - case .blob(let value): - setBytes(value, forKey: column.name, at: userModificationTime) - case .bool(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .double(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .date(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .int(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .null: - removeValue(forKey: column.name, at: userModificationTime) - case .text(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .uint(let value): - setValue(value, forKey: column.name, at: userModificationTime) - case .uuid(let value): - setValue( - value.uuidString.lowercased(), - forKey: column.name, - at: userModificationTime - ) - case .invalid(let error): - reportIssue(error) - } - } - open(column) - } - } - - func update( - with other: CKRecord, - row: T, - columnNames: inout [String], - parentForeignKey: ForeignKey? - ) { - typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable - - self.userModificationTime = other.userModificationTime - for column in T.TableColumns.writableColumns { - func open(_ column: some WritableTableColumnExpression) { - let key = column.name - let keyPath = column.keyPath as! KeyPath - let didSet: Bool - if let value = other[key] as? CKAsset { - didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key]) - } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { - didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) - } else if other.encryptedValues[key] == nil { - didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) - } else { - didSet = false - } - /// The row value has been modified more recently than the last known record. - var isRowValueModified: Bool { - switch Value(queryOutput: row[keyPath: keyPath]).queryBinding { - case .blob(let value): - return other.encryptedValues[hash: key] != value.sha256 - case .bool(let value): - return other.encryptedValues[key] != value - case .double(let value): - return other.encryptedValues[key] != value - case .date(let value): - return other.encryptedValues[key] != value - case .int(let value): - return other.encryptedValues[key] != value - case .null: - return other.encryptedValues[key] != nil - case .text(let value): - return other.encryptedValues[key] != value - case .uint(let value): - return other.encryptedValues[key] != value - case .uuid(let value): - return other.encryptedValues[key] != value.uuidString.lowercased() - case .invalid(let error): - reportIssue(error) - return false - } - } - if didSet || isRowValueModified { - columnNames.removeAll(where: { $0 == key }) - if didSet, let parentForeignKey, key == parentForeignKey.from { - self.parent = other.parent - } - } - } - open(column) - } - } - - package var userModificationTime: Int64 { - get { encryptedValues[Self.userModificationTimeKey] as? Int64 ?? -1 } - set { - encryptedValues[Self.userModificationTimeKey] = Swift.max(userModificationTime, newValue) - } - } - - package static let userModificationTimeKey = - "\(String.sqliteDataCloudKitSchemaName)_userModificationTime" - } - - extension __CKRecordObjCValue { - var queryFragment: QueryFragment { - if let value = self as? Int64 { - return value.queryFragment - } else if let value = self as? Double { - return value.queryFragment - } else if let value = self as? String { - return value.queryFragment - } else if let value = self as? Data { - return value.queryFragment - } else if let value = self as? Date { - return value.queryFragment - } else { - return "\(.invalid(Unbindable()))" - } - } - } - - private struct Unbindable: Error {} - - extension CKRecord { - package var _recordChangeTag: Int? { - get { self[#function] } - set { self[#function] = newValue } - } - - package var _modificationDate: Date? { - get { modificationDate ?? self[#function] } - set { self[#function] = newValue } - } - } - - extension DataProtocol { - fileprivate var sha256: Data { - Data(SHA256.hash(data: self)) - } - } -#endif From c63dced4d9359a4e8b03fa920182fcc5f90fae13 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Thu, 5 Nov 2026 15:53:15 -0600 Subject: [PATCH 9/9] oops --- .../CloudKit/CloudKit+StructuredQueries.swift | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift new file mode 100644 index 00000000..a6b216da --- /dev/null +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -0,0 +1,416 @@ +#if canImport(CloudKit) + public import CloudKit + import CryptoKit + import Dependencies + import IssueReporting + public import StructuredQueriesCore + + extension CKRecord { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation + } + + extension CKShare { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation + } + + extension Optional where Wrapped: CKRecord { + public typealias _AllFieldsRepresentation = SQLiteData._AllFieldsRepresentation? + public typealias SystemFieldsRepresentation = _SystemFieldsRepresentation? + } + + public struct _SystemFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record + + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encodeSystemFields(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") + if queryOutput is CKShare { + archiver.encode(queryOutput[CKShare.SystemFieldKey.title], forKey: CKShare.SystemFieldKey.title) + } + } + return archiver.encodedData.queryBinding + } + + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } + + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue + queryOutput._modificationDate = + coder + .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? + if queryOutput is CKShare { + queryOutput[CKShare.SystemFieldKey.title] = coder + .decodeObject(of: NSString.self, forKey: CKShare.SystemFieldKey.title) + } + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } + + public struct _AllFieldsRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: Record + + public var queryBinding: QueryBinding { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + queryOutput.encode(with: archiver) + if isTesting { + archiver.encode(queryOutput._recordChangeTag, forKey: "_recordChangeTag") + archiver.encode(queryOutput._modificationDate, forKey: "_modificationDate") + if queryOutput is CKShare { + archiver.encode(queryOutput[CKShare.SystemFieldKey.title], forKey: CKShare.SystemFieldKey.title) + } + } + return archiver.encodedData.queryBinding + } + + public init(queryOutput: Record) { + self.queryOutput = queryOutput + } + + public init?(queryBinding: QueryBinding) { + guard case .blob(let bytes) = queryBinding else { return nil } + try? self.init(data: Data(bytes)) + } + + public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + try self.init(data: try Data(decoder: &decoder)) + } + + private init(data: Data) throws { + let coder = try NSKeyedUnarchiver(forReadingFrom: data) + coder.requiresSecureCoding = true + guard let queryOutput = Record(coder: coder) else { + throw DecodingError() + } + if isTesting { + queryOutput._recordChangeTag = + coder + .decodeObject(of: NSNumber.self, forKey: "_recordChangeTag")?.intValue + queryOutput._modificationDate = + coder + .decodeObject(of: NSDate.self, forKey: "_modificationDate") as Date? + if queryOutput is CKShare { + queryOutput[CKShare.SystemFieldKey.title] = coder + .decodeObject(of: NSString.self, forKey: CKShare.SystemFieldKey.title) + } + } + self.init(queryOutput: queryOutput) + } + + private struct DecodingError: Error {} + } + + extension CKDatabase.Scope { + public struct RawValueRepresentation: QueryBindable, QueryRepresentable { + public let queryOutput: CKDatabase.Scope + public var queryBinding: QueryBinding { + queryOutput.rawValue.queryBinding + } + public init(queryOutput: CKDatabase.Scope) { + self.queryOutput = queryOutput + } + public init?(queryBinding: QueryBinding) { + guard case .int(let rawValue) = queryBinding else { return nil } + try? self.init(rawValue: Int(rawValue)) + } + public init(decoder: inout some QueryDecoder) throws { + try self.init(rawValue: Int(decoder: &decoder)) + } + private init(rawValue: Int) throws { + guard let queryOutput = CKDatabase.Scope(rawValue: rawValue) else { + throw DecodingError() + } + self.init(queryOutput: queryOutput) + } + private struct DecodingError: Error {} + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecordKeyValueSetting { + fileprivate subscript(at key: String) -> Int64 { + get { + self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1 + } + set { + self["\(CKRecord.userModificationTimeKey)_\(key)"] = max(self[at: key], newValue) + } + } + fileprivate subscript(hash key: String) -> Data? { + get { self["\(key)_hash"] as? Data } + set { self["\(key)_hash"] = newValue } + } + } + + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + extension CKRecord { + func hasSet(key: String) -> Bool { + self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil + } + + @discardableResult + package func setValue( + _ newValue: some CKRecordValueProtocol & Equatable, + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[key] != newValue + else { return false } + encryptedValues[key] = newValue + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + + @discardableResult + package func setAsset( + _ newValue: CKAsset, + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + @Dependency(\.dataManager) var dataManager + guard + let fileURL = newValue.fileURL, + let hash = dataManager.sha256(of: fileURL) + else { return false } + guard + encryptedValues[at: key] <= userModificationTime, + encryptedValues[hash: key] != hash + else { return false } + + self[key] = newValue + encryptedValues[hash: key] = hash + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } + + @discardableResult + package func setBytes( + _ newValue: [UInt8], + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + guard encryptedValues[at: key] <= userModificationTime + else { return false } + + @Dependency(\.dataManager) var dataManager + let hash = newValue.sha256 + let fileURL = dataManager.temporaryDirectory.appending( + component: + hash + .compactMap { String(format: "%02hhx", $0) } + .joined() + ) + let asset = CKAsset(fileURL: fileURL) + return withErrorReporting(.sqliteDataCloudKitFailure) { + try dataManager.save(Data(newValue), to: fileURL) + self[key] = asset + encryptedValues[at: key] = userModificationTime + encryptedValues[hash: key] = hash + self.userModificationTime = userModificationTime + return true + } + ?? false + } + + @discardableResult + package func removeValue( + forKey key: CKRecord.FieldKey, + at userModificationTime: Int64 + ) -> Bool { + guard encryptedValues[at: key] <= userModificationTime + else { + return false + } + if encryptedValues[key] != nil { + encryptedValues[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } else if self[key] != nil { + self[key] = nil + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + return true + } else if !hasSet(key: key) { + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime + } + return false + } + + func update(with row: T, userModificationTime: Int64) { + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let keyPath = column.keyPath as! KeyPath + let column = column as! any WritableTableColumnExpression + let value = Value(queryOutput: row[keyPath: keyPath]) + switch value.queryBinding { + case .blob(let value): + setBytes(value, forKey: column.name, at: userModificationTime) + case .bool(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .double(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .date(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .int(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .null: + removeValue(forKey: column.name, at: userModificationTime) + case .text(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .uint(let value): + setValue(value, forKey: column.name, at: userModificationTime) + case .uuid(let value): + setValue( + value.uuidString.lowercased(), + forKey: column.name, + at: userModificationTime + ) + case .invalid(let error): + reportIssue(error) + } + } + open(column) + } + } + + func update( + with other: CKRecord, + row: T, + columnNames: inout [String], + parentForeignKey: ForeignKey? + ) { + typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable + + self.userModificationTime = other.userModificationTime + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let key = column.name + let keyPath = column.keyPath as! KeyPath + let didSet: Bool + if let value = other[key] as? CKAsset { + didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key]) + } else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol { + didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key]) + } else if other.encryptedValues[key] == nil { + didSet = removeValue(forKey: key, at: other.encryptedValues[at: key]) + } else { + didSet = false + } + /// The row value has been modified more recently than the last known record. + var isRowValueModified: Bool { + switch Value(queryOutput: row[keyPath: keyPath]).queryBinding { + case .blob(let value): + return other.encryptedValues[hash: key] != value.sha256 + case .bool(let value): + return other.encryptedValues[key] != value + case .double(let value): + return other.encryptedValues[key] != value + case .date(let value): + return other.encryptedValues[key] != value + case .int(let value): + return other.encryptedValues[key] != value + case .null: + return other.encryptedValues[key] != nil + case .text(let value): + return other.encryptedValues[key] != value + case .uint(let value): + return other.encryptedValues[key] != value + case .uuid(let value): + return other.encryptedValues[key] != value.uuidString.lowercased() + case .invalid(let error): + reportIssue(error) + return false + } + } + if didSet || isRowValueModified { + columnNames.removeAll(where: { $0 == key }) + if didSet, let parentForeignKey, key == parentForeignKey.from { + self.parent = other.parent + } + } + } + open(column) + } + } + + package var userModificationTime: Int64 { + get { encryptedValues[Self.userModificationTimeKey] as? Int64 ?? -1 } + set { + encryptedValues[Self.userModificationTimeKey] = Swift.max(userModificationTime, newValue) + } + } + + package static let userModificationTimeKey = + "\(String.sqliteDataCloudKitSchemaName)_userModificationTime" + } + + extension __CKRecordObjCValue { + var queryFragment: QueryFragment { + if let value = self as? Int64 { + return value.queryFragment + } else if let value = self as? Double { + return value.queryFragment + } else if let value = self as? String { + return value.queryFragment + } else if let value = self as? Data { + return value.queryFragment + } else if let value = self as? Date { + return value.queryFragment + } else { + return "\(.invalid(Unbindable()))" + } + } + } + + private struct Unbindable: Error {} + + extension CKRecord { + package var _recordChangeTag: Int? { + get { self[#function] } + set { self[#function] = newValue } + } + + package var _modificationDate: Date? { + get { modificationDate ?? self[#function] } + set { self[#function] = newValue } + } + } + + extension DataProtocol { + fileprivate var sha256: Data { + Data(SHA256.hash(data: self)) + } + } +#endif