From bdd291d023f756efbed30d0d8447d74993791356 Mon Sep 17 00:00:00 2001 From: James Sutula Date: Thu, 12 Mar 2026 23:51:02 -0700 Subject: [PATCH 1/3] Fix CloudKit data loss on delete-then-reinsert Fixes #418 --- .../CloudKit/Internal/MockSyncEngine.swift | 22 +- .../CloudKit/Internal/Triggers.swift | 34 ++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 + .../ChangeSupersessionTests.swift | 333 ++++++++++++++++++ .../MockSyncEngineStateTests.swift | 98 ++++++ .../CloudKitTests/TriggerTests.swift | 69 +++- 6 files changed, 553 insertions(+), 17 deletions(-) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift create mode 100644 Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index e8719d26..fd42047c 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -173,8 +173,15 @@ } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.append(contentsOf: pendingRecordZoneChanges) + self._pendingRecordZoneChanges.withValue { set in + for change in pendingRecordZoneChanges { + if let id = change.id, + let supersededIndex = set.firstIndex(where: { $0.id == id && $0 != change }) + { + set.remove(at: supersededIndex) + } + set.append(change) + } } } @@ -185,8 +192,15 @@ } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.append(contentsOf: pendingDatabaseChanges) + self._pendingDatabaseChanges.withValue { set in + for change in pendingDatabaseChanges { + if let zoneID = change.zoneID, + let supersededIndex = set.firstIndex(where: { $0.zoneID == zoneID && $0 != change }) + { + set.remove(at: supersededIndex) + } + set.append(change) + } } } diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index 93411a74..9e06ebe4 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -82,6 +82,16 @@ defaultZone: defaultZone, privateTables: privateTables ) + SyncMetadata + .where { + $0.recordPrimaryKey.eq(#sql("\(new.primaryKey)")) + && $0.recordType.eq(tableName) + && $0._isDeleted + } + .update { + $0._isDeleted = false + $0.userModificationTime = $currentTime() + } } ) } @@ -242,6 +252,7 @@ afterZoneUpdateTrigger(), afterUpdateTrigger(for: syncEngine), afterSoftDeleteTrigger(for: syncEngine), + afterUndeleteTrigger(for: syncEngine), ] } @@ -348,6 +359,29 @@ } ) } + + fileprivate static func afterUndeleteTrigger( + for syncEngine: SyncEngine + ) -> TemporaryTrigger { + createTemporaryTrigger( + "\(String.sqliteDataCloudKitSchemaName)_after_undelete_on_sqlitedata_icloud_metadata", + ifNotExists: true, + after: .update(of: \._isDeleted) { _, new in + Values( + syncEngine.$didUpdate( + recordName: new.recordName, + zoneName: new.zoneName, + ownerName: new.ownerName, + oldZoneName: new.zoneName, + oldOwnerName: new.ownerName, + descendantRecordNames: #bind(nil) + ) + ) + } when: { old, new in + old._isDeleted && !new._isDeleted && !SyncEngine.$isSynchronizing + } + ) + } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 871c7999..f2dbdcf7 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2092,6 +2092,20 @@ } } + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) + extension CKSyncEngine.PendingDatabaseChange { + var zoneID: CKRecordZone.ID? { + switch self { + case .saveZone(let zone): + return zone.zoneID + case .deleteZone(let zoneID): + return zoneID + @unknown default: + return nil + } + } + } + extension CKRecord.ID { var tableName: String? { guard diff --git a/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift new file mode 100644 index 00000000..67054c7a --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift @@ -0,0 +1,333 @@ +#if canImport(CloudKit) + import CloudKit + import CustomDump + import InlineSnapshotTesting + import OrderedCollections + import SQLiteData + import SnapshotTestingCustomDump + import Testing + + extension BaseCloudKitTests { + @MainActor + final class ChangeSupersessionTests: BaseCloudKitTests, @unchecked Sendable { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteAndReinsertInSingleWrite() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Renamed") }.execute(db) + } + + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Renamed" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteAndReinsertInSeparateWrites() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + try await userDatabase.userWrite { db in + try RemindersList.insert { RemindersList(id: 1, title: "Restored") }.execute(db) + } + + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Restored" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteWithoutReinsert_stillDeletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.deleteRecord(RemindersList.recordID(for: 1))]) + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteReinsertThenDeleteAgain_deletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Original") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Middle") }.execute(db) + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func balancedDeleteReinsertCycles_savesWithFinalValues() async throws { + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Original") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Middle") }.execute(db) + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Final") }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Final" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func updateThenDelete_deletes() async throws { + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Original") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) + } + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func updateThenDeleteThenReinsert_savesWithReinsertedValues() async throws { + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Original") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) + } + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) + } + + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Reinserted" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + + // A second delete+reinsert cycle should propagate the cycle-2 field values to CloudKit, + // not the stale cycle-1 values. Regression test for stale userModificationTime timestamps. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func secondDeleteAndReinsertPropagatesCycle2Values() async throws { + // Seed and sync initial record. + try await userDatabase.userWrite { db in + try db.seed { RemindersList(id: 1, title: "Original") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Cycle 1: delete + reinsert. + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Cycle1") }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Cycle 2: delete + reinsert with new value. + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Cycle2") }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Cycle2" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift new file mode 100644 index 00000000..5c7b9c0b --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift @@ -0,0 +1,98 @@ +#if canImport(CloudKit) + import CloudKit + import SQLiteData + import Testing + + @Suite struct MockSyncEngineStateTests { + let recordIDa = CKRecord.ID(recordName: "A") + let recordIDb = CKRecord.ID(recordName: "B") + let zoneIDa = CKRecordZone.ID(zoneName: "A", ownerName: CKCurrentUserDefaultName) + let zoneIDb = CKRecordZone.ID(zoneName: "B", ownerName: CKCurrentUserDefaultName) + + @Suite struct PendingRecordZoneChanges { + let state = MockSyncEngineState() + let idA = CKRecord.ID(recordName: "A") + let idB = CKRecord.ID(recordName: "B") + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sameType_isDeduplicatedAtOriginalPosition() { + state.add(pendingRecordZoneChanges: [.saveRecord(idA), .saveRecord(idB)]) + state.add(pendingRecordZoneChanges: [.saveRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.saveRecord(idA), .saveRecord(idB)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveThenDelete_deleteSupersedes() { + state.add(pendingRecordZoneChanges: [.saveRecord(idA)]) + state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.deleteRecord(idA)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteThenSave_saveSupersedes() { + state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) + state.add(pendingRecordZoneChanges: [.saveRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.saveRecord(idA)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteThenSaveThenDelete_lastDeleteWins() { + state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) + state.add(pendingRecordZoneChanges: [.saveRecord(idA)]) + state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.deleteRecord(idA)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func crossTypeSupersession_doesNotAffectOtherRecords() { + state.add(pendingRecordZoneChanges: [.saveRecord(idA), .saveRecord(idB)]) + state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.saveRecord(idB), .deleteRecord(idA)]) + } + } + + @Suite struct PendingDatabaseChanges { + let state = MockSyncEngineState() + let zoneA = CKRecordZone(zoneName: "A") + let zoneB = CKRecordZone(zoneName: "B") + var zoneAID: CKRecordZone.ID { zoneA.zoneID } + var zoneBID: CKRecordZone.ID { zoneB.zoneID } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func sameType_isDeduplicatedAtOriginalPosition() { + state.add(pendingDatabaseChanges: [.saveZone(zoneA), .saveZone(zoneB)]) + state.add(pendingDatabaseChanges: [.saveZone(zoneA)]) + #expect(state.pendingDatabaseChanges == [.saveZone(zoneA), .saveZone(zoneB)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func saveThenDelete_deleteSupersedes() { + state.add(pendingDatabaseChanges: [.saveZone(zoneA)]) + state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) + #expect(state.pendingDatabaseChanges == [.deleteZone(zoneAID)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteThenSave_saveSupersedes() { + state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) + state.add(pendingDatabaseChanges: [.saveZone(zoneA)]) + #expect(state.pendingDatabaseChanges == [.saveZone(zoneA)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func deleteThenSaveThenDelete_lastDeleteWins() { + state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) + state.add(pendingDatabaseChanges: [.saveZone(zoneA)]) + state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) + #expect(state.pendingDatabaseChanges == [.deleteZone(zoneAID)]) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func crossTypeSupersession_doesNotAffectOtherZones() { + state.add(pendingDatabaseChanges: [.saveZone(zoneA), .saveZone(zoneB)]) + state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) + #expect(state.pendingDatabaseChanges == [.saveZone(zoneB), .deleteZone(zoneAID)]) + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 4a04e1b2..c848b895 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -410,6 +410,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."parentID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))))), '__defaultOwner__'), "new"."parentID", 'parents' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [26]: """ @@ -436,6 +439,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."parentID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))))), '__defaultOwner__'), "new"."parentID", 'parents' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [27]: """ @@ -458,6 +464,9 @@ ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'modelAs', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [28]: """ @@ -484,6 +493,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."modelAID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))))), '__defaultOwner__'), "new"."modelAID", 'modelAs' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [29]: """ @@ -510,6 +522,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."modelBID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))))), '__defaultOwner__'), "new"."modelBID", 'modelBs' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [30]: """ @@ -532,6 +547,9 @@ ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'parents', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [31]: """ @@ -554,6 +572,9 @@ ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'reminderTags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [32]: """ @@ -580,6 +601,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [33]: """ @@ -606,6 +630,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [34]: """ @@ -632,6 +659,9 @@ FROM "sqlitedata_icloud_metadata" WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))))), '__defaultOwner__'), "new"."remindersListID", 'remindersLists' ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [35]: """ @@ -654,6 +684,9 @@ ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."id", 'remindersLists', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [36]: """ @@ -685,6 +718,9 @@ ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") SELECT "new"."title", 'tags', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL ON CONFLICT DO NOTHING; + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE (((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))) AND ("sqlitedata_icloud_metadata"."_isDeleted")); END """, [38]: """ @@ -940,6 +976,13 @@ END """, [50]: """ + CREATE TRIGGER "sqlitedata_icloud_after_undelete_on_sqlitedata_icloud_metadata" + AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" + FOR EACH ROW WHEN (("old"."_isDeleted") AND (NOT ("new"."_isDeleted"))) AND (NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN + SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "new"."zoneName", "new"."ownerName", NULL); + END + """, + [51]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -972,7 +1015,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [51]: """ + [52]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -1005,7 +1048,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [52]: """ + [53]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -1030,7 +1073,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [53]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -1063,7 +1106,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [54]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -1096,7 +1139,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [55]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -1121,7 +1164,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [56]: """ + [57]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -1146,7 +1189,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [57]: """ + [58]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -1179,7 +1222,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [58]: """ + [59]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -1212,7 +1255,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [59]: """ + [60]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -1245,7 +1288,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [60]: """ + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -1270,7 +1313,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [61]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("old"."_isDeleted") = ("new"."_isDeleted")) AND (NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN @@ -1292,7 +1335,7 @@ ) END); END """, - [62]: """ + [63]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -1317,7 +1360,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [63]: """ + [64]: """ CREATE TRIGGER "sqlitedata_icloud_after_zone_update_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "zoneName", "ownerName" ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("new"."zoneName") <> ("old"."zoneName")) OR (("new"."ownerName") <> ("old"."ownerName")) BEGIN From a3049c7a0b13db9aa542eb33dca5ad8144fc55e5 Mon Sep 17 00:00:00 2001 From: James Sutula Date: Fri, 13 Mar 2026 12:03:06 -0700 Subject: [PATCH 2/3] Structurally enforce ID-based deduplication in MockSyncEngineState --- .../CloudKit/Internal/MockSyncEngine.swift | 62 ++++++++++++------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 ----- .../MockSyncEngineStateTests.swift | 4 +- .../Internal/CloudKit+CustomDump.swift | 5 +- .../Internal/CloudKitTestHelpers.swift | 4 +- 5 files changed, 47 insertions(+), 42 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index fd42047c..99e6df77 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -136,12 +136,12 @@ package final class MockSyncEngineState: CKSyncEngineStateProtocol { package let changeTag = LockIsolated(0) package let _pendingRecordZoneChanges = LockIsolated< - OrderedSet - >([] + OrderedDictionary + >([:] ) package let _pendingDatabaseChanges = LockIsolated< - OrderedSet - >([]) + OrderedDictionary + >([:]) private let fileID: StaticString private let filePath: StaticString private let line: UInt @@ -160,11 +160,11 @@ } package var pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] { - _pendingRecordZoneChanges.withValue { Array($0) } + _pendingRecordZoneChanges.withValue { Array($0.values) } } package var pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange] { - _pendingDatabaseChanges.withValue { Array($0) } + _pendingDatabaseChanges.withValue { Array($0.values) } } package func removePendingChanges() { @@ -173,40 +173,58 @@ } package func add(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { set in + self._pendingRecordZoneChanges.withValue { dict in for change in pendingRecordZoneChanges { - if let id = change.id, - let supersededIndex = set.firstIndex(where: { $0.id == id && $0 != change }) - { - set.remove(at: supersededIndex) + switch change { + case .saveRecord(let id), .deleteRecord(let id): + dict.updateValue(change, forKey: id) + @unknown default: + fatalError("Unsupported pendingRecordZoneChange: \(change)") } - set.append(change) } } } package func remove(pendingRecordZoneChanges: [CKSyncEngine.PendingRecordZoneChange]) { - self._pendingRecordZoneChanges.withValue { - $0.subtract(pendingRecordZoneChanges) + self._pendingRecordZoneChanges.withValue { dict in + for change in pendingRecordZoneChanges { + switch change { + case .saveRecord(let id), .deleteRecord(let id): + if dict[id] == change { dict.removeValue(forKey: id) } + @unknown default: + fatalError("Unsupported pendingRecordZoneChange: \(change)") + } + } } } package func add(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { set in + self._pendingDatabaseChanges.withValue { dict in for change in pendingDatabaseChanges { - if let zoneID = change.zoneID, - let supersededIndex = set.firstIndex(where: { $0.zoneID == zoneID && $0 != change }) - { - set.remove(at: supersededIndex) + switch change { + case .saveZone(let zone): + dict.updateValue(change, forKey: zone.zoneID) + case .deleteZone(let zoneID): + dict.updateValue(change, forKey: zoneID) + @unknown default: + fatalError("Unsupported pendingDatabaseChange: \(change)") } - set.append(change) } } } package func remove(pendingDatabaseChanges: [CKSyncEngine.PendingDatabaseChange]) { - self._pendingDatabaseChanges.withValue { - $0.subtract(pendingDatabaseChanges) + self._pendingDatabaseChanges.withValue { dict in + for change in pendingDatabaseChanges { + switch change { + case .saveZone(let zone): + if dict[zone.zoneID] == change { dict.removeValue(forKey: zone.zoneID) } + case .deleteZone(let zoneID): + if dict[zoneID] == change { dict.removeValue(forKey: zoneID) } + @unknown default: + fatalError("Unsupported pendingDatabaseChange: \(change)") + } + } } } } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f2dbdcf7..871c7999 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2092,20 +2092,6 @@ } } - @available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) - extension CKSyncEngine.PendingDatabaseChange { - var zoneID: CKRecordZone.ID? { - switch self { - case .saveZone(let zone): - return zone.zoneID - case .deleteZone(let zoneID): - return zoneID - @unknown default: - return nil - } - } - } - extension CKRecord.ID { var tableName: String? { guard diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift index 5c7b9c0b..311d7551 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockSyncEngineStateTests.swift @@ -47,7 +47,7 @@ @Test func crossTypeSupersession_doesNotAffectOtherRecords() { state.add(pendingRecordZoneChanges: [.saveRecord(idA), .saveRecord(idB)]) state.add(pendingRecordZoneChanges: [.deleteRecord(idA)]) - #expect(state.pendingRecordZoneChanges == [.saveRecord(idB), .deleteRecord(idA)]) + #expect(state.pendingRecordZoneChanges == [.deleteRecord(idA), .saveRecord(idB)]) } } @@ -91,7 +91,7 @@ @Test func crossTypeSupersession_doesNotAffectOtherZones() { state.add(pendingDatabaseChanges: [.saveZone(zoneA), .saveZone(zoneB)]) state.add(pendingDatabaseChanges: [.deleteZone(zoneAID)]) - #expect(state.pendingDatabaseChanges == [.saveZone(zoneB), .deleteZone(zoneAID)]) + #expect(state.pendingDatabaseChanges == [.deleteZone(zoneAID), .saveZone(zoneB)]) } } } diff --git a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift index 338db9df..29d65b20 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKit+CustomDump.swift @@ -1,6 +1,7 @@ #if canImport(CloudKit) import CustomDump import CloudKit + import OrderedCollections import SQLiteData extension CKDatabase.Scope: @retroactive CustomDumpStringConvertible { @@ -169,13 +170,13 @@ children: [ ( "pendingRecordZoneChanges", - _pendingRecordZoneChanges.withValue(\.self) + _pendingRecordZoneChanges.withValue { Array($0.values) } .sorted(by: comparePendingRecordZoneChange) as Any ), ( "pendingDatabaseChanges", - _pendingDatabaseChanges.withValue(\.self) + _pendingDatabaseChanges.withValue { Array($0.values) } .sorted(by: comparePendingDatabaseChange) as Any ), ], diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 4716be7b..26bd1190 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -164,7 +164,7 @@ extension MockSyncEngineState { _pendingRecordZoneChanges.withValue { expectNoDifference( Set(changes), - Set($0), + Set($0.values), fileID: fileID, filePath: filePath, line: line, @@ -184,7 +184,7 @@ extension MockSyncEngineState { _pendingDatabaseChanges.withValue { expectNoDifference( Set(changes), - Set($0), + Set($0.values), fileID: fileID, filePath: filePath, line: line, From afc96c12284ed2e2fa7e9cd47d499592260d6ab9 Mon Sep 17 00:00:00 2001 From: James Sutula Date: Fri, 13 Mar 2026 19:59:10 -0700 Subject: [PATCH 3/3] Strengthen ChangeSupersessionTests --- .../ChangeSupersessionTests.swift | 183 ++++++++++-------- 1 file changed, 102 insertions(+), 81 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift index 67054c7a..3409fbae 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ChangeSupersessionTests.swift @@ -11,21 +11,14 @@ @MainActor final class ChangeSupersessionTests: BaseCloudKitTests, @unchecked Sendable { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteAndReinsertInSingleWrite() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - + @Test func insertThenDelete_deletes() async throws { try await userDatabase.userWrite { db in + try RemindersList.insert { RemindersList(id: 1, title: "Personal") }.execute(db) try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Renamed") }.execute(db) } let pending = syncEngine.private.state.pendingRecordZoneChanges - #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + #expect(pending == [.deleteRecord(RemindersList.recordID(for: 1))]) try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -34,16 +27,7 @@ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Renamed" - ) - ] + storage: [] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -55,23 +39,19 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteAndReinsertInSeparateWrites() async throws { + @Test func updateThenDelete_deletes() async throws { try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } + try db.seed { RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) try RemindersList.find(1).delete().execute(db) } - try await userDatabase.userWrite { db in - try RemindersList.insert { RemindersList(id: 1, title: "Restored") }.execute(db) - } let pending = syncEngine.private.state.pendingRecordZoneChanges - #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + #expect(pending == [.deleteRecord(RemindersList.recordID(for: 1))]) try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -80,16 +60,7 @@ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - title: "Restored" - ) - ] + storage: [] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -101,20 +72,21 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteWithoutReinsert_stillDeletes() async throws { + @Test func deleteAndReinsertInSingleWrite_saves() async throws { try await userDatabase.userWrite { db in try db.seed { - RemindersList(id: 1, title: "Personal") + RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) } let pending = syncEngine.private.state.pendingRecordZoneChanges - #expect(pending == [.deleteRecord(RemindersList.recordID(for: 1))]) + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -123,7 +95,16 @@ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [] + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Reinserted" + ) + ] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -135,17 +116,23 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func deleteReinsertThenDeleteAgain_deletes() async throws { + @Test func deleteAndReinsertInSeparateWrites_saves() async throws { try await userDatabase.userWrite { db in - try db.seed { RemindersList(id: 1, title: "Original") } + try db.seed { + RemindersList(id: 1, title: "Original") + } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Middle") }.execute(db) - try RemindersList.find(1).delete().execute(db) } + try await userDatabase.userWrite { db in + try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) + } + + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) try await syncEngine.processPendingRecordZoneChanges(scope: .private) @@ -154,7 +141,16 @@ MockCloudContainer( privateCloudDatabase: MockCloudDatabase( databaseScope: .private, - storage: [] + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "Reinserted" + ) + ] ), sharedCloudDatabase: MockCloudDatabase( databaseScope: .shared, @@ -166,19 +162,21 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func balancedDeleteReinsertCycles_savesWithFinalValues() async throws { + @Test func updateThenDeleteThenReinsert_saves() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) try await userDatabase.userWrite { db in + try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Middle") }.execute(db) - try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Final") }.execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) } + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { @@ -193,7 +191,7 @@ parent: nil, share: nil, id: 1, - title: "Final" + title: "Reinserted" ) ] ), @@ -207,19 +205,21 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateThenDelete_deletes() async throws { + @Test func deleteReinsertThenDeleteAgain_deletes() async throws { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) - } try await userDatabase.userWrite { db in try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) + try RemindersList.find(1).delete().execute(db) } + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.deleteRecord(RemindersList.recordID(for: 1))]) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { @@ -239,22 +239,28 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func updateThenDeleteThenReinsert_savesWithReinsertedValues() async throws { + @Test(.printTimestamps) func twoDeleteReinsertCyclesInSameWrite_propagatesLatestValueAndTimestamp() + async throws + { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - try await userDatabase.userWrite { db in - try RemindersList.find(1).update { $0.title = "Updated" }.execute(db) - } - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Reinserted") }.execute(db) + try await withDependencies { + $0.currentTime.now = 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Middle") }.execute(db) + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Final") }.execute(db) + } + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { """ MockCloudContainer( @@ -267,7 +273,10 @@ parent: nil, share: nil, id: 1, - title: "Reinserted" + id🗓️: 0, + title: "Final", + title🗓️: 1, + 🗓️: 1 ) ] ), @@ -280,29 +289,38 @@ } } - // A second delete+reinsert cycle should propagate the cycle-2 field values to CloudKit, - // not the stale cycle-1 values. Regression test for stale userModificationTime timestamps. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func secondDeleteAndReinsertPropagatesCycle2Values() async throws { - // Seed and sync initial record. + @Test(.printTimestamps) func twoDeleteReinsertCyclesInSeparateBatches_propagatesLatestValueAndTimestamp() + async throws + { try await userDatabase.userWrite { db in try db.seed { RemindersList(id: 1, title: "Original") } } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - // Cycle 1: delete + reinsert. - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Cycle1") }.execute(db) + try await withDependencies { + $0.currentTime.now = 1 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Cycle1") }.execute(db) + } + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - // Cycle 2: delete + reinsert with new value. - try await userDatabase.userWrite { db in - try RemindersList.find(1).delete().execute(db) - try RemindersList.insert { RemindersList(id: 1, title: "Cycle2") }.execute(db) + try await withDependencies { + $0.currentTime.now = 2 + } operation: { + try await userDatabase.userWrite { db in + try RemindersList.find(1).delete().execute(db) + try RemindersList.insert { RemindersList(id: 1, title: "Cycle2") }.execute(db) + } + let pending = syncEngine.private.state.pendingRecordZoneChanges + #expect(pending == [.saveRecord(RemindersList.recordID(for: 1))]) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) assertInlineSnapshot(of: container, as: .customDump) { """ @@ -316,7 +334,10 @@ parent: nil, share: nil, id: 1, - title: "Cycle2" + id🗓️: 0, + title: "Cycle2", + title🗓️: 2, + 🗓️: 2 ) ] ),