From b91b5512ccee51dda86d4bbb30b995231105dd7b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:39:07 -0600 Subject: [PATCH 1/7] Support for ancestor records in MockCloudDatabase. --- .../Internal/MockCloudContainer.swift | 6 +- .../CloudKit/Internal/MockCloudDatabase.swift | 84 ++++++++++++++----- .../CloudKit/Internal/MockSyncEngine.swift | 2 +- .../ForeignKeyConstraintTests.swift | 4 +- .../MockCloudDatabaseTests.swift | 32 +++++++ .../Internal/CloudKit+CustomDump.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- 7 files changed, 105 insertions(+), 27 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index aaf8568b..80399cf9 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -48,9 +48,9 @@ : sharedCloudDatabase let rootRecord: CKRecord? = database.state.withValue { - $0.storage[share.recordID.zoneID]?.records.values.first { record in - record.share?.recordID == share.recordID - } + $0.storage[share.recordID.zoneID]?.records.values.first { entry in + entry.current.share?.recordID == share.recordID + }?.current } return ShareMetadata( diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index ae093dd5..34ac85c2 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -27,7 +27,12 @@ package struct Zone { package var zone: CKRecordZone - package var records: [CKRecord.ID: CKRecord] = [:] + package var records: [CKRecord.ID: RecordEntry] = [:] + } + + package struct RecordEntry { + package var current: CKRecord + package var history: [Int: CKRecord] } package init(databaseScope: CKDatabase.Scope) { @@ -49,7 +54,7 @@ let record = try state.withValue { state in guard let zone = state.storage[recordID.zoneID] else { throw CKError(.zoneNotFound) } - guard let record = zone.records[recordID] + guard let record = zone.records[recordID]?.current else { throw CKError(.unknownItem) } guard let record = record.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } @@ -143,7 +148,7 @@ let existingRecord = state.storage[recordToSave.recordID.zoneID]?.records[ recordToSave.recordID - ] + ]?.current func saveRecordToDatabase() { let hasReferenceViolation = @@ -161,12 +166,12 @@ func root(of record: CKRecord) -> CKRecord { guard let parent = record.parent else { return record } - return (state.storage[parent.recordID.zoneID]?.records[parent.recordID]).map( - root - ) ?? record + return (state.storage[parent.recordID.zoneID]?.records[parent.recordID]?.current) + .map(root) ?? record } func share(for rootRecord: CKRecord) -> CKShare? { - for (_, record) in state.storage[rootRecord.recordID.zoneID]?.records ?? [:] { + for (_, entry) in state.storage[rootRecord.recordID.zoneID]?.records ?? [:] { + let record = entry.current guard record.recordID == rootRecord.share?.recordID else { continue } return record as? CKShare @@ -199,7 +204,28 @@ } // TODO: This should merge copy's values to more accurately reflect reality - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = copy + if let existingEntry = state.storage[recordToSave.recordID.zoneID]?.records[ + recordToSave.recordID + ], + let existingRecord = Optional(existingEntry.current), + let existingRecordChangeTag = existingRecord._recordChangeTag, + let existingRecordCopy = existingRecord.copy() as? CKRecord + { + var updatedEntry = existingEntry + updatedEntry.history[existingRecordChangeTag] = existingRecordCopy + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = + updatedEntry + } + if var existingEntry = state.storage[recordToSave.recordID.zoneID]?.records[ + recordToSave.recordID + ] { + existingEntry.current = copy + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = + existingEntry + } else { + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = + RecordEntry(current: copy, history: [:]) + } saveResults[recordToSave.recordID] = .success(copy) // NB: "Touch" parent records when saving a child: @@ -208,11 +234,24 @@ !recordsToSave.contains(where: { $0.recordID == parent.recordID }), // And if the parent is in the database. let parentRecord = state.storage[parent.recordID.zoneID]?.records[parent.recordID]? - .copy() - as? CKRecord + .current + .copy() as? CKRecord { parentRecord._recordChangeTag = state.nextRecordChangeTag() - state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentRecord + if var parentEntry = + state.storage[parent.recordID.zoneID]?.records[parent.recordID] + { + if let parentChangeTag = parentEntry.current._recordChangeTag, + let parentRecordCopy = parentEntry.current.copy() as? CKRecord + { + parentEntry.history[parentChangeTag] = parentRecordCopy + } + parentEntry.current = parentRecord + state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentEntry + } else { + state.storage[parent.recordID.zoneID]?.records[parent.recordID] = + RecordEntry(current: parentRecord, history: [:]) + } } } @@ -225,12 +264,17 @@ precondition(existingRecord._recordChangeTag != nil) saveRecordToDatabase() } else { + let ancestorRecord = + state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID]? + .history[recordToSaveChangeTag] + ?? (existingRecord.copy() as? CKRecord ?? existingRecord) saveResults[recordToSave.recordID] = .failure( CKError( .serverRecordChanged, userInfo: [ CKRecordChangedErrorServerRecordKey: existingRecord.copy() as Any, CKRecordChangedErrorClientRecordKey: recordToSave.copy(), + CKRecordChangedErrorAncestorRecordKey: ancestorRecord as Any, ] ) ) @@ -272,7 +316,7 @@ } let hasReferenceViolation = !Set( state.storage[recordIDToDelete.zoneID]?.records.values - .compactMap { $0.parent?.recordID == recordIDToDelete ? $0.recordID : nil } + .compactMap { $0.current.parent?.recordID == recordIDToDelete ? $0.current.recordID : nil } ?? [] ) .subtracting(recordIDsToDelete) @@ -283,7 +327,8 @@ deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) continue } - let recordToDelete = state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] + let recordToDelete = state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete]? + .current state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] = nil deleteResults[recordIDToDelete] = .success(()) if let recordType = recordToDelete?.recordType { @@ -299,16 +344,17 @@ func deleteRecords(referencing recordID: CKRecord.ID) { for recordToDelete in (state.storage[recordIDToDelete.zoneID]?.records ?? [:]).values { + let record = recordToDelete.current guard - recordToDelete.share?.recordID == recordID - || recordToDelete.parent?.recordID == recordID + record.share?.recordID == recordID + || record.parent?.recordID == recordID else { continue } - state.storage[recordIDToDelete.zoneID]?.records[recordToDelete.recordID] = nil - deleteResults[recordToDelete.recordID] = .success(()) - state.deletedRecords.append((recordIDToDelete, recordToDelete.recordType)) - deleteRecords(referencing: recordToDelete.recordID) + state.storage[recordIDToDelete.zoneID]?.records[record.recordID] = nil + deleteResults[record.recordID] = .success(()) + state.deletedRecords.append((recordIDToDelete, record.recordType)) + deleteRecords(referencing: record.recordID) } } deleteRecords(referencing: shareToDelete.recordID) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index 9a2cc088..c6a205d9 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -47,7 +47,7 @@ zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in - accum += ((state.storage[zoneID]?.records.values).map { Array($0) } ?? []) + accum += (state.storage[zoneID]?.records.values.map(\.current) ?? []) .filter { precondition( $0._recordChangeTag != nil, diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 9596a66e..7fda4d09 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -674,7 +674,7 @@ assertInlineSnapshot( of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ Reminder.recordID(for: 1) - ], + ]?.current, as: .customDump ) { """ @@ -769,7 +769,7 @@ assertInlineSnapshot( of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ Reminder.recordID(for: 1) - ], + ]?.current, as: .customDump ) { """ diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index ecd50cc2..2543d672 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -650,6 +650,38 @@ } } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordChangedIncludesAncestorRecord() async throws { + let recordID = CKRecord.ID(recordName: "ancestor-test") + let record = CKRecord(recordType: "A", recordID: recordID) + record["value"] = 1 + + let (saveResults, _) = try syncEngine.private.database.modifyRecords(saving: [record]) + #expect(saveResults.values.count(where: { (try? $0.get()) != nil }) == 1) + + let clientRecord = try syncEngine.private.database.record(for: recordID) + + let serverRecord = try syncEngine.private.database.record(for: recordID) + serverRecord["value"] = 2 + _ = try syncEngine.private.database.modifyRecords(saving: [serverRecord]) + + clientRecord["value"] = 3 + let (conflictResults, _) = try syncEngine.private.database.modifyRecords( + saving: [clientRecord] + ) + let error = #expect(throws: CKError.self) { + try conflictResults[recordID]?.get() + } + #expect(error?.code == .serverRecordChanged) + + let ancestor = error?.userInfo[CKRecordChangedErrorAncestorRecordKey] as? CKRecord + let server = error?.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord + let client = error?.userInfo[CKRecordChangedErrorClientRecordKey] as? CKRecord + #expect(ancestor?["value"] as? Int == 1) + #expect(server?["value"] as? Int == 2) + #expect(client?["value"] as? Int == 3) + } + @Test func limitExceeded_modifyRecords() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 338db9df..a67f3b00 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -245,7 +245,7 @@ "storage": state .value .storage - .flatMap { _, value in value.records.values } + .flatMap { _, value in value.records.values.map(\.current) } .sorted { ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) }, diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 4716be7b..641ba03a 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -77,7 +77,7 @@ extension SyncEngine { let recordsToDeleteByID = Dictionary( grouping: syncEngine.database.state.withValue { state in recordIDsToDelete.compactMap { - recordID in state.storage[recordID.zoneID]?.records[recordID] + recordID in state.storage[recordID.zoneID]?.records[recordID]?.current } }, by: \.recordID From c0cd32c75afc02140be38a66832c920484ce42cb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:45:09 -0600 Subject: [PATCH 2/7] wip --- .../CloudKit/Internal/MockCloudDatabase.swift | 55 +++++++------------ 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 34ac85c2..5c153e04 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -18,6 +18,23 @@ lastRecordChangeTag += 1 return lastRecordChangeTag } + + mutating func saveRecord(_ record: CKRecord) { + guard let existingEntry = storage[record.recordID.zoneID]?.records[record.recordID] + else { + storage[record.recordID.zoneID]?.records[record.recordID] = + RecordEntry(current: record, history: [:]) + return + } + var updatedEntry = existingEntry + if let existingRecordChangeTag = existingEntry.current._recordChangeTag, + let existingRecordCopy = existingEntry.current.copy() as? CKRecord + { + updatedEntry.history[existingRecordChangeTag] = existingRecordCopy + } + updatedEntry.current = record + storage[record.recordID.zoneID]?.records[record.recordID] = updatedEntry + } } struct AssetID: Hashable { @@ -204,28 +221,7 @@ } // TODO: This should merge copy's values to more accurately reflect reality - if let existingEntry = state.storage[recordToSave.recordID.zoneID]?.records[ - recordToSave.recordID - ], - let existingRecord = Optional(existingEntry.current), - let existingRecordChangeTag = existingRecord._recordChangeTag, - let existingRecordCopy = existingRecord.copy() as? CKRecord - { - var updatedEntry = existingEntry - updatedEntry.history[existingRecordChangeTag] = existingRecordCopy - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = - updatedEntry - } - if var existingEntry = state.storage[recordToSave.recordID.zoneID]?.records[ - recordToSave.recordID - ] { - existingEntry.current = copy - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = - existingEntry - } else { - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID] = - RecordEntry(current: copy, history: [:]) - } + state.saveRecord(copy) saveResults[recordToSave.recordID] = .success(copy) // NB: "Touch" parent records when saving a child: @@ -238,20 +234,7 @@ .copy() as? CKRecord { parentRecord._recordChangeTag = state.nextRecordChangeTag() - if var parentEntry = - state.storage[parent.recordID.zoneID]?.records[parent.recordID] - { - if let parentChangeTag = parentEntry.current._recordChangeTag, - let parentRecordCopy = parentEntry.current.copy() as? CKRecord - { - parentEntry.history[parentChangeTag] = parentRecordCopy - } - parentEntry.current = parentRecord - state.storage[parent.recordID.zoneID]?.records[parent.recordID] = parentEntry - } else { - state.storage[parent.recordID.zoneID]?.records[parent.recordID] = - RecordEntry(current: parentRecord, history: [:]) - } + state.saveRecord(parentRecord) } } From bdf22edc23ecbe1af7853b8a00c07a11f9ec5332 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:50:59 -0600 Subject: [PATCH 3/7] wip --- .../Internal/MockCloudContainer.swift | 6 +- .../CloudKit/Internal/MockCloudDatabase.swift | 58 +++++++++---------- .../CloudKit/Internal/MockSyncEngine.swift | 3 +- .../ForeignKeyConstraintTests.swift | 8 +-- .../Internal/CloudKit+CustomDump.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 2 +- 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift index 80399cf9..55ad5105 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudContainer.swift @@ -48,9 +48,9 @@ : sharedCloudDatabase let rootRecord: CKRecord? = database.state.withValue { - $0.storage[share.recordID.zoneID]?.records.values.first { entry in - entry.current.share?.recordID == share.recordID - }?.current + $0.storage[share.recordID.zoneID]?.entries.values.first { entry in + entry.record.share?.recordID == share.recordID + }?.record } return ShareMetadata( diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 5c153e04..dcb78a39 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -20,20 +20,20 @@ } mutating func saveRecord(_ record: CKRecord) { - guard let existingEntry = storage[record.recordID.zoneID]?.records[record.recordID] + guard let existingEntry = storage[record.recordID.zoneID]?.entries[record.recordID] else { - storage[record.recordID.zoneID]?.records[record.recordID] = - RecordEntry(current: record, history: [:]) + storage[record.recordID.zoneID]?.entries[record.recordID] = + RecordEntry(record: record, history: [:]) return } var updatedEntry = existingEntry - if let existingRecordChangeTag = existingEntry.current._recordChangeTag, - let existingRecordCopy = existingEntry.current.copy() as? CKRecord + if let existingRecordChangeTag = existingEntry.record._recordChangeTag, + let existingRecordCopy = existingEntry.record.copy() as? CKRecord { updatedEntry.history[existingRecordChangeTag] = existingRecordCopy } - updatedEntry.current = record - storage[record.recordID.zoneID]?.records[record.recordID] = updatedEntry + updatedEntry.record = record + storage[record.recordID.zoneID]?.entries[record.recordID] = updatedEntry } } @@ -44,11 +44,11 @@ package struct Zone { package var zone: CKRecordZone - package var records: [CKRecord.ID: RecordEntry] = [:] + package var entries: [CKRecord.ID: RecordEntry] = [:] } package struct RecordEntry { - package var current: CKRecord + package var record: CKRecord package var history: [Int: CKRecord] } @@ -71,7 +71,7 @@ let record = try state.withValue { state in guard let zone = state.storage[recordID.zoneID] else { throw CKError(.zoneNotFound) } - guard let record = zone.records[recordID]?.current + guard let record = zone.entries[recordID]?.record else { throw CKError(.unknownItem) } guard let record = record.copy() as? CKRecord else { fatalError("Could not copy CKRecord.") } @@ -140,7 +140,7 @@ $0.share?.recordID == share.recordID }) let shareWasPreviouslySaved = - state.storage[share.recordID.zoneID]?.records[share.recordID] != nil + state.storage[share.recordID.zoneID]?.entries[share.recordID] != nil guard shareWasPreviouslySaved || isSavingRootRecord else { saveResults[recordToSave.recordID] = .failure(CKError(.invalidArguments)) @@ -163,14 +163,14 @@ continue } - let existingRecord = state.storage[recordToSave.recordID.zoneID]?.records[ + let existingRecord = state.storage[recordToSave.recordID.zoneID]?.entries[ recordToSave.recordID - ]?.current + ]?.record func saveRecordToDatabase() { let hasReferenceViolation = recordToSave.parent.map { parent in - state.storage[parent.recordID.zoneID]?.records[parent.recordID] == nil + state.storage[parent.recordID.zoneID]?.entries[parent.recordID] == nil && !recordsToSave.contains { $0.recordID == parent.recordID } } ?? false @@ -183,12 +183,12 @@ func root(of record: CKRecord) -> CKRecord { guard let parent = record.parent else { return record } - return (state.storage[parent.recordID.zoneID]?.records[parent.recordID]?.current) + return (state.storage[parent.recordID.zoneID]?.entries[parent.recordID]?.record) .map(root) ?? record } func share(for rootRecord: CKRecord) -> CKShare? { - for (_, entry) in state.storage[rootRecord.recordID.zoneID]?.records ?? [:] { - let record = entry.current + for (_, entry) in state.storage[rootRecord.recordID.zoneID]?.entries ?? [:] { + let record = entry.record guard record.recordID == rootRecord.share?.recordID else { continue } return record as? CKShare @@ -229,8 +229,8 @@ // If the parent isn't also being saved in this batch. !recordsToSave.contains(where: { $0.recordID == parent.recordID }), // And if the parent is in the database. - let parentRecord = state.storage[parent.recordID.zoneID]?.records[parent.recordID]? - .current + let parentRecord = state.storage[parent.recordID.zoneID]?.entries[parent.recordID]? + .record .copy() as? CKRecord { parentRecord._recordChangeTag = state.nextRecordChangeTag() @@ -248,7 +248,7 @@ saveRecordToDatabase() } else { let ancestorRecord = - state.storage[recordToSave.recordID.zoneID]?.records[recordToSave.recordID]? + state.storage[recordToSave.recordID.zoneID]?.entries[recordToSave.recordID]? .history[recordToSaveChangeTag] ?? (existingRecord.copy() as? CKRecord ?? existingRecord) saveResults[recordToSave.recordID] = .failure( @@ -298,8 +298,8 @@ continue } let hasReferenceViolation = !Set( - state.storage[recordIDToDelete.zoneID]?.records.values - .compactMap { $0.current.parent?.recordID == recordIDToDelete ? $0.current.recordID : nil } + state.storage[recordIDToDelete.zoneID]?.entries.values + .compactMap { $0.record.parent?.recordID == recordIDToDelete ? $0.record.recordID : nil } ?? [] ) .subtracting(recordIDsToDelete) @@ -310,9 +310,9 @@ deleteResults[recordIDToDelete] = .failure(CKError(.referenceViolation)) continue } - let recordToDelete = state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete]? - .current - state.storage[recordIDToDelete.zoneID]?.records[recordIDToDelete] = nil + let recordToDelete = state.storage[recordIDToDelete.zoneID]?.entries[recordIDToDelete]? + .record + state.storage[recordIDToDelete.zoneID]?.entries[recordIDToDelete] = nil deleteResults[recordIDToDelete] = .success(()) if let recordType = recordToDelete?.recordType { state.deletedRecords.append((recordIDToDelete, recordType)) @@ -325,16 +325,16 @@ shareToDelete.recordID.zoneID.ownerName == CKCurrentUserDefaultName { func deleteRecords(referencing recordID: CKRecord.ID) { - for recordToDelete in (state.storage[recordIDToDelete.zoneID]?.records ?? [:]).values + for entryToDelete in (state.storage[recordIDToDelete.zoneID]?.entries ?? [:]).values { - let record = recordToDelete.current + let record = entryToDelete.record guard record.share?.recordID == recordID || record.parent?.recordID == recordID else { continue } - state.storage[recordIDToDelete.zoneID]?.records[record.recordID] = nil + state.storage[recordIDToDelete.zoneID]?.entries[record.recordID] = nil deleteResults[record.recordID] = .success(()) state.deletedRecords.append((recordIDToDelete, record.recordType)) deleteRecords(referencing: record.recordID) @@ -377,7 +377,7 @@ deleteResults[deleteSuccessRecordID] = .failure(CKError(.batchRequestFailed)) } // All storage changes are reverted in zone. - state.storage[zoneID]?.records = previousStorage[zoneID]?.records ?? [:] + state.storage[zoneID]?.entries = previousStorage[zoneID]?.entries ?? [:] } return (saveResults: saveResults, deleteResults: deleteResults) } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index c6a205d9..c75f3f3a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -47,7 +47,7 @@ zoneIDs.reduce(into: [CKRecord]()) { accum, zoneID in - accum += (state.storage[zoneID]?.records.values.map(\.current) ?? []) + accum += (state.storage[zoneID]?.entries.values.map(\.record) ?? []) .filter { precondition( $0._recordChangeTag != nil, @@ -201,7 +201,6 @@ extension SyncEngine { package struct SendRecordsCallback { fileprivate let operation: @Sendable () async -> Void - @discardableResult package func receive() async { await operation() } diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 7fda4d09..f0e67dd1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -672,9 +672,9 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ + of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.entries[ Reminder.recordID(for: 1) - ]?.current, + ]?.record, as: .customDump ) { """ @@ -767,9 +767,9 @@ """ } assertInlineSnapshot( - of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.records[ + of: syncEngine.private.database.state.storage[syncEngine.defaultZone.zoneID]?.entries[ Reminder.recordID(for: 1) - ]?.current, + ]?.record, as: .customDump ) { """ diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index a67f3b00..eee8dfd6 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -245,7 +245,7 @@ "storage": state .value .storage - .flatMap { _, value in value.records.values.map(\.current) } + .flatMap { _, value in value.entries.values.map(\.record) } .sorted { ($0.recordType, $0.recordID.recordName) < ($1.recordType, $1.recordID.recordName) }, diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 641ba03a..f40231ba 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -77,7 +77,7 @@ extension SyncEngine { let recordsToDeleteByID = Dictionary( grouping: syncEngine.database.state.withValue { state in recordIDsToDelete.compactMap { - recordID in state.storage[recordID.zoneID]?.records[recordID]?.current + recordID in state.storage[recordID.zoneID]?.entries[recordID]?.record } }, by: \.recordID From cc31c31c4c632a9e97c0f108e6687d17cc4c8056 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:55:28 -0600 Subject: [PATCH 4/7] wip --- .../SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index dcb78a39..80d2e479 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -20,20 +20,19 @@ } mutating func saveRecord(_ record: CKRecord) { - guard let existingEntry = storage[record.recordID.zoneID]?.entries[record.recordID] + guard var existingEntry = storage[record.recordID.zoneID]?.entries[record.recordID] else { storage[record.recordID.zoneID]?.entries[record.recordID] = RecordEntry(record: record, history: [:]) return } - var updatedEntry = existingEntry if let existingRecordChangeTag = existingEntry.record._recordChangeTag, let existingRecordCopy = existingEntry.record.copy() as? CKRecord { - updatedEntry.history[existingRecordChangeTag] = existingRecordCopy + existingEntry.history[existingRecordChangeTag] = existingRecordCopy } - updatedEntry.record = record - storage[record.recordID.zoneID]?.entries[record.recordID] = updatedEntry + existingEntry.record = record + storage[record.recordID.zoneID]?.entries[record.recordID] = existingEntry } } From 8edaa5093d94063813adfbc1ff0af24b18715f44 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:58:51 -0600 Subject: [PATCH 5/7] clean up --- .../SQLiteData/CloudKit/Internal/MockCloudDatabase.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 80d2e479..293b4326 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -26,10 +26,8 @@ RecordEntry(record: record, history: [:]) return } - if let existingRecordChangeTag = existingEntry.record._recordChangeTag, - let existingRecordCopy = existingEntry.record.copy() as? CKRecord - { - existingEntry.history[existingRecordChangeTag] = existingRecordCopy + if let existingRecordChangeTag = existingEntry.record._recordChangeTag { + existingEntry.history[existingRecordChangeTag] = existingEntry.record.copy() as? CKRecord } existingEntry.record = record storage[record.recordID.zoneID]?.entries[record.recordID] = existingEntry From 0c49df43189f8607e7cec241312058af715bb77b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 21:59:32 -0600 Subject: [PATCH 6/7] clean up --- .../SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 2543d672..ae828883 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -652,7 +652,7 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordChangedIncludesAncestorRecord() async throws { - let recordID = CKRecord.ID(recordName: "ancestor-test") + let recordID = CKRecord.ID(recordName: "1") let record = CKRecord(recordType: "A", recordID: recordID) record["value"] = 1 From c4987f1a3a673e21381e18f3bb6adc87c2be7f16 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 22:05:36 -0600 Subject: [PATCH 7/7] add another test --- .../MockCloudDatabaseTests.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index ae828883..885ce137 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -682,6 +682,43 @@ #expect(client?["value"] as? Int == 3) } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordChangedUsesChangeTagAncestor() async throws { + let recordID = CKRecord.ID(recordName: "1") + let record = CKRecord(recordType: "A", recordID: recordID) + record["value"] = 1 + + _ = try syncEngine.private.database.modifyRecords(saving: [record]) + + let clientRecord = try syncEngine.private.database.record(for: recordID) + let clientChangeTag = clientRecord._recordChangeTag + + let serverRecordV2 = try syncEngine.private.database.record(for: recordID) + serverRecordV2["value"] = 2 + _ = try syncEngine.private.database.modifyRecords(saving: [serverRecordV2]) + + let serverRecordV3 = try syncEngine.private.database.record(for: recordID) + serverRecordV3["value"] = 3 + _ = try syncEngine.private.database.modifyRecords(saving: [serverRecordV3]) + + clientRecord["value"] = 99 + let (conflictResults, _) = try syncEngine.private.database.modifyRecords( + saving: [clientRecord] + ) + let error = #expect(throws: CKError.self) { + try conflictResults[recordID]?.get() + } + #expect(error?.code == .serverRecordChanged) + + let ancestor = error?.userInfo[CKRecordChangedErrorAncestorRecordKey] as? CKRecord + let server = error?.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord + let client = error?.userInfo[CKRecordChangedErrorClientRecordKey] as? CKRecord + #expect(ancestor?._recordChangeTag == clientChangeTag) + #expect(ancestor?["value"] as? Int == 1) + #expect(server?["value"] as? Int == 3) + #expect(client?["value"] as? Int == 99) + } + @Test func limitExceeded_modifyRecords() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName,