From bf01f0efd73f957ed99f3102b1326ad8d4a7e170 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:32:16 -0600 Subject: [PATCH 1/9] Fix 'limitExceeded' errors related to FK constraint failures. --- .../CloudKitDemo/CountersListFeature.swift | 14 ++++ .../CloudKit/Internal/MockCloudDatabase.swift | 5 ++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 70 +++++++++++++------ .../ForeignKeyConstraintTests.swift | 44 ++++++++++++ .../MockCloudDatabaseTests.swift | 35 ++++++++++ 5 files changed, 146 insertions(+), 22 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 98b904e2..64c53869 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -33,6 +33,20 @@ struct CountersListView: View { } } } + ToolbarItem { + Button("💥") { + withErrorReporting { + try database.write { db in + for index in 1...1000 { + try Counter.insert { + Counter.Draft(count: index) + } + .execute(db) + } + } + } + } + } } } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index c8138bf0..3d69b221 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -64,6 +64,11 @@ guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + guard ids.count < 200 + else { + throw CKError(.limitExceeded) + } + var results: [CKRecord.ID: Result] = [:] for id in ids { results[id] = Result { try record(for: id) } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index c9488697..a02c48c8 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1419,11 +1419,11 @@ ) async { let deletedRecordIDsByRecordType = OrderedDictionary( grouping: deletions.sorted { lhs, rhs in - guard - let lhsIndex = tablesByOrder[lhs.recordType], - let rhsIndex = tablesByOrder[rhs.recordType] - else { return true } - return lhsIndex > rhsIndex + topologicallyAscending( + lhsTableName: lhs.recordType, + rhsTableName: rhs.recordType, + rootFirst: false + ) }, by: \.recordType ) @@ -1490,18 +1490,32 @@ .execute(db) } } - let results = try await syncEngine.database.records(for: Array(unsyncedRecordIDs)) + let batchSize = 150 + let batchCount = unsyncedRecordIDs.count / batchSize + let orderedUnsyncedRecordIDs = unsyncedRecordIDs.sorted { + topologicallyAscending( + lhsTableName: $0.tableName, + rhsTableName: $1.tableName, + rootFirst: true + ) + } var unsyncedRecords: [CKRecord] = [] - for (recordID, result) in results { - switch result { - case .success(let record): - unsyncedRecords.append(record) - case .failure(let error as CKError) where error.code == .unknownItem: - try await userDatabase.write { db in - try UnsyncedRecordID.find(recordID).delete().execute(db) + for batch in 0...batchCount { + let recordIDs = orderedUnsyncedRecordIDs + .dropFirst(batch * batchSize) + .prefix(batchSize) + let results = try await syncEngine.database.records(for: Array(recordIDs)) + for (recordID, result) in results { + switch result { + case .success(let record): + unsyncedRecords.append(record) + case .failure(let error as CKError) where error.code == .unknownItem: + try await userDatabase.write { db in + try UnsyncedRecordID.find(recordID).delete().execute(db) + } + case .failure: + continue } - case .failure: - continue } } return unsyncedRecords @@ -1509,13 +1523,11 @@ ?? [CKRecord]() let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in - guard - let lhsRecordType = lhs.recordID.tableName, - let lhsIndex = tablesByOrder[lhsRecordType], - let rhsRecordType = rhs.recordID.tableName, - let rhsIndex = tablesByOrder[rhsRecordType] - else { return true } - return lhsIndex < rhsIndex + topologicallyAscending( + lhsTableName: lhs.recordID.tableName, + rhsTableName: rhs.recordID.tableName, + rootFirst: true + ) } enum ShareOrReference { @@ -1563,6 +1575,20 @@ } } + private func topologicallyAscending( + lhsTableName: String?, + rhsTableName: String?, + rootFirst: Bool + ) -> Bool { + guard + let lhsTableName, + let rhsTableName, + let lhsIndex = tablesByOrder[lhsTableName], + let rhsIndex = tablesByOrder[rhsTableName] + else { return false } + return rootFirst ? lhsIndex < rhsIndex : lhsIndex > rhsIndex + } + package func handleSentRecordZoneChanges( savedRecords: [CKRecord] = [], failedRecordSaves: [(record: CKRecord, error: CKError)] = [], diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index bf6d011c..e3b22dd3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -869,6 +869,50 @@ """ } } + + // When downloading + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func batchAssociations() async throws { + + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + let remindersListModification = try syncEngine.modifyRecords( + scope: .private, + saving: [remindersListRecord] + ) + + let reminderCount = 500 + let reminderRecords = (1...reminderCount).map { index in + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: index) + ) + reminderRecord.setValue(index, forKey: "id", at: now) + reminderRecord.setValue("Reminder #\(index)", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + return reminderRecord + } + + try await syncEngine.modifyRecords( + scope: .private, + saving: reminderRecords + ) + .notify() + await remindersListModification.notify() + + try await userDatabase.read { db in + try #expect(RemindersList.fetchCount(db) == 1) + try #expect(Reminder.fetchCount(db) == reminderCount) + } + } } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index 9ba9ad8e..c2056628 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -649,6 +649,41 @@ ) } } + + @Test func limitExceeded() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let reminderRecords = (1...400).map { index in + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: index) + ) + reminderRecord.setValue(index, forKey: "id", at: now) + reminderRecord.setValue("Reminder #\(index)", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + return reminderRecord + } + + _ = try syncEngine.private.database.modifyRecords( + saving: reminderRecords + [remindersListRecord] + ) + + let error = await #expect(throws: CKError.self) { + _ = try await syncEngine.private.database.records( + for: [remindersListRecord.recordID] + reminderRecords.map(\.recordID) + ) + } + #expect(error?.code == .limitExceeded) + } } } #endif From 8dc739e2ea5e9cd53d076cd3c453e19145be5cc5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:32:47 -0600 Subject: [PATCH 2/9] revert --- Examples/CloudKitDemo/CountersListFeature.swift | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Examples/CloudKitDemo/CountersListFeature.swift b/Examples/CloudKitDemo/CountersListFeature.swift index 64c53869..98b904e2 100644 --- a/Examples/CloudKitDemo/CountersListFeature.swift +++ b/Examples/CloudKitDemo/CountersListFeature.swift @@ -33,20 +33,6 @@ struct CountersListView: View { } } } - ToolbarItem { - Button("💥") { - withErrorReporting { - try database.write { db in - for index in 1...1000 { - try Counter.insert { - Counter.Draft(count: index) - } - .execute(db) - } - } - } - } - } } } From 96360ff417449cf495f7676a772bed6fa951f3bb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:35:23 -0600 Subject: [PATCH 3/9] test explanation --- .../CloudKitTests/ForeignKeyConstraintTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index e3b22dd3..592d7b9e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -870,7 +870,13 @@ } } - // When downloading + /* + * Create a parent record in CloudKit database but do not sync to client. + * Create many child records in CloudKit database and **do** sync to client. + * Sync parent record to client. + * => Cached unsaved child records should be batched so as to not run into 'limitExceeded' + errors + */ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func batchAssociations() async throws { From e06070228548cb5005b0aa82e12031c67517c18c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:40:19 -0600 Subject: [PATCH 4/9] emulate error for modifyRecords too --- .../CloudKit/Internal/MockCloudDatabase.swift | 9 +++-- .../MockCloudDatabaseTests.swift | 39 +++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 3d69b221..21c3c8a0 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -65,9 +65,7 @@ else { throw ckError(forAccountStatus: accountStatus) } guard ids.count < 200 - else { - throw CKError(.limitExceeded) - } + else { throw CKError(.limitExceeded) } var results: [CKRecord.ID: Result] = [:] for id in ids { @@ -89,6 +87,11 @@ guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + guard (recordsToSave.count + recordIDsToDelete.count) < 200 + else { + throw CKError(.limitExceeded) + } + return storage.withValue { storage in let previousStorage = storage var saveResults: [CKRecord.ID: Result] = [:] diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift index c2056628..ecd50cc2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseTests.swift @@ -650,7 +650,7 @@ } } - @Test func limitExceeded() async throws { + @Test func limitExceeded_modifyRecords() async throws { let remindersListRecord = CKRecord( recordType: RemindersList.tableName, recordID: RemindersList.recordID(for: 1) @@ -673,9 +673,42 @@ return reminderRecord } - _ = try syncEngine.private.database.modifyRecords( - saving: reminderRecords + [remindersListRecord] + let error = #expect(throws: CKError.self) { + _ = try syncEngine.private.database.modifyRecords( + saving: reminderRecords + [remindersListRecord] + ) + } + #expect(error?.code == .limitExceeded) + } + + @Test func records_limitExceeded() async throws { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("Personal", forKey: "title", at: now) + + let reminderRecords = (1...400).map { index in + let reminderRecord = CKRecord( + recordType: Reminder.tableName, + recordID: Reminder.recordID(for: index) + ) + reminderRecord.setValue(index, forKey: "id", at: now) + reminderRecord.setValue("Reminder #\(index)", forKey: "title", at: now) + reminderRecord.setValue(1, forKey: "remindersListID", at: now) + reminderRecord.parent = CKRecord.Reference( + record: remindersListRecord, + action: .none + ) + return reminderRecord + } + + _ = try syncEngine.private.database.modifyRecords(saving: [remindersListRecord]) + _ = try syncEngine.private.database.modifyRecords(saving: Array(reminderRecords[0...100])) + _ = try syncEngine.private.database.modifyRecords(saving: Array(reminderRecords[101...200])) + _ = try syncEngine.private.database.modifyRecords(saving: Array(reminderRecords[201...300])) + _ = try syncEngine.private.database.modifyRecords(saving: Array(reminderRecords[301...399])) let error = await #expect(throws: CKError.self) { _ = try await syncEngine.private.database.records( From c2ac6f289f5fbbd1b5433b5c533a1086a346de04 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:42:38 -0600 Subject: [PATCH 5/9] fix test --- .../ForeignKeyConstraintTests.swift | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index 592d7b9e..5bd8f224 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -891,8 +891,7 @@ saving: [remindersListRecord] ) - let reminderCount = 500 - let reminderRecords = (1...reminderCount).map { index in + let reminderRecords = (1...500).map { index in let reminderRecord = CKRecord( recordType: Reminder.tableName, recordID: Reminder.recordID(for: index) @@ -909,14 +908,29 @@ try await syncEngine.modifyRecords( scope: .private, - saving: reminderRecords - ) - .notify() + saving: Array(reminderRecords[0...100]) + ).notify() + try await syncEngine.modifyRecords( + scope: .private, + saving: Array(reminderRecords[101...200]) + ).notify() + try await syncEngine.modifyRecords( + scope: .private, + saving: Array(reminderRecords[201...300]) + ).notify() + try await syncEngine.modifyRecords( + scope: .private, + saving: Array(reminderRecords[301...400]) + ).notify() + try await syncEngine.modifyRecords( + scope: .private, + saving: Array(reminderRecords[401...499]) + ).notify() await remindersListModification.notify() try await userDatabase.read { db in try #expect(RemindersList.fetchCount(db) == 1) - try #expect(Reminder.fetchCount(db) == reminderCount) + try #expect(Reminder.fetchCount(db) == 500) } } } From 40aa5f29da2b7731528268a1ab0b3d49fe391903 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 10:50:28 -0600 Subject: [PATCH 6/9] rename --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index a02c48c8..d75c0d65 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1501,10 +1501,10 @@ } var unsyncedRecords: [CKRecord] = [] for batch in 0...batchCount { - let recordIDs = orderedUnsyncedRecordIDs + let recordIDsBatch = orderedUnsyncedRecordIDs .dropFirst(batch * batchSize) .prefix(batchSize) - let results = try await syncEngine.database.records(for: Array(recordIDs)) + let results = try await syncEngine.database.records(for: Array(recordIDsBatch)) for (recordID, result) in results { switch result { case .success(let record): From 0e3e2c1895bc62082d4b959637eb8e725025d912 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 11:53:43 -0600 Subject: [PATCH 7/9] added a test for tablesByOrder --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 36 +++++++++++-------- .../TopologicalTableSortingTests.swift | 17 +++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index d75c0d65..c2f5a473 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -24,7 +24,7 @@ package let tables: [any SynchronizableTable] package let privateTables: [any SynchronizableTable] let tablesByName: [String: any SynchronizableTable] - private let tablesByOrder: [String: Int] + package let tablesByOrder: [String: Int] let foreignKeysByTableName: [String: [ForeignKey]] package let syncEngines = LockIsolated(SyncEngines()) package let defaultZone: CKRecordZone @@ -217,7 +217,7 @@ tables: [any SynchronizableTable], privateTables: [any SynchronizableTable] = [] ) throws { - let allTables = Set((tables + privateTables).map(HashableSynchronizedTable.init)) + let allTables = OrderedSet((tables + privateTables).map(HashableSynchronizedTable.init)) .map(\.type) self.tables = allTables self.privateTables = privateTables @@ -1491,7 +1491,6 @@ } } let batchSize = 150 - let batchCount = unsyncedRecordIDs.count / batchSize let orderedUnsyncedRecordIDs = unsyncedRecordIDs.sorted { topologicallyAscending( lhsTableName: $0.tableName, @@ -1500,9 +1499,9 @@ ) } var unsyncedRecords: [CKRecord] = [] - for batch in 0...batchCount { + for start in stride(from: 0, to: orderedUnsyncedRecordIDs.count, by: batchSize) { let recordIDsBatch = orderedUnsyncedRecordIDs - .dropFirst(batch * batchSize) + .dropFirst(start) .prefix(batchSize) let results = try await syncEngine.database.records(for: Array(recordIDsBatch)) for (recordID, result) in results { @@ -1580,13 +1579,20 @@ rhsTableName: String?, rootFirst: Bool ) -> Bool { - guard - let lhsTableName, - let rhsTableName, - let lhsIndex = tablesByOrder[lhsTableName], - let rhsIndex = tablesByOrder[rhsTableName] - else { return false } - return rootFirst ? lhsIndex < rhsIndex : lhsIndex > rhsIndex + switch (lhsTableName, rhsTableName) { + case (nil, nil), (nil, _): + return false + case (_, nil): + return true + case let (.some(lhs), .some(rhs)): + let lhsIndex = tablesByOrder[lhs] ?? (rootFirst ? .max : .min) + let rhsIndex = tablesByOrder[rhs] ?? (rootFirst ? .max : .min) + guard lhsIndex != rhsIndex + else { + return lhs < rhs + } + return rootFirst ? lhsIndex < rhsIndex : lhsIndex > rhsIndex + } } package func handleSentRecordZoneChanges( @@ -2318,10 +2324,12 @@ tablesByName: [String: any SynchronizableTable] ) throws -> [String: Int] { let tableDependencies = try userDatabase.read { db in - var dependencies: [HashableSynchronizedTable: [any SynchronizableTable]] = [:] + var dependencies: OrderedDictionary = [:] for table in tables { func open(_: some SynchronizableTable) throws -> [String] { - try PragmaForeignKeyList.select(\.table) + try PragmaForeignKeyList + .order(by: \.table) + .select(\.table) .fetchAll(db) } let toTables = try open(table) diff --git a/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift new file mode 100644 index 00000000..61a0671b --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift @@ -0,0 +1,17 @@ +#if canImport(CloudKit) + import CloudKit + import SQLiteData + import Testing + + extension BaseCloudKitTests { + @MainActor + final class TopologicalTableSortingTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func tablesByOrder() async throws { + #expect( + syncEngine.tablesByOrder == ["remindersListPrivates": 11, "childWithOnDeleteSetNulls": 6, "reminders": 1, "modelAs": 8, "remindersLists": 0, "reminderTags": 4, "modelBs": 9, "parents": 5, "childWithOnDeleteSetDefaults": 7, "modelCs": 10, "remindersListAssets": 2, "tags": 3] + ) + } + } + } +#endif From 281ec18ff27f9c6786af5a1bfd9cbf7ca8c03b77 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 11:54:15 -0600 Subject: [PATCH 8/9] clean up --- .../TopologicalTableSortingTests.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift index 61a0671b..a829086f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TopologicalTableSortingTests.swift @@ -9,7 +9,20 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func tablesByOrder() async throws { #expect( - syncEngine.tablesByOrder == ["remindersListPrivates": 11, "childWithOnDeleteSetNulls": 6, "reminders": 1, "modelAs": 8, "remindersLists": 0, "reminderTags": 4, "modelBs": 9, "parents": 5, "childWithOnDeleteSetDefaults": 7, "modelCs": 10, "remindersListAssets": 2, "tags": 3] + syncEngine.tablesByOrder == [ + "remindersLists": 0, + "reminders": 1, + "remindersListAssets": 2, + "tags": 3, + "reminderTags": 4, + "parents": 5, + "childWithOnDeleteSetNulls": 6, + "childWithOnDeleteSetDefaults": 7, + "modelAs": 8, + "modelBs": 9, + "modelCs": 10, + "remindersListPrivates": 11, + ] ) } } From c7c420ecbaf1d646116c9226520f20814ac1b4de Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 6 Jan 2026 12:36:35 -0600 Subject: [PATCH 9/9] add test --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ++-- .../CloudKitTests/FetchRecordZoneChangesTests.swift | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index c2f5a473..5d094197 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1523,8 +1523,8 @@ let modifications = (modifications + unsyncedRecords).sorted { lhs, rhs in topologicallyAscending( - lhsTableName: lhs.recordID.tableName, - rhsTableName: rhs.recordID.tableName, + lhsTableName: lhs.recordType, + rhsTableName: rhs.recordType, rootFirst: true ) } diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 505930e4..13e85616 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -739,6 +739,12 @@ } #expect(error?.message == SyncEngine.invalidRecordNameError) } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncInvalidRecordID() async throws { + let record = CKRecord(recordType: "foo", recordID: CKRecord.ID(recordName: "bar")) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + } } } #endif