From 3c295d488796feac17aa313f2228cf95d5d6fbae Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:07:50 +0100 Subject: [PATCH 01/23] Introduce Post table type --- .../CloudKitTests/CloudKitTests.swift | 41 ++++ .../CloudKitTests/RecordTypeTests.swift | 41 ++++ .../CloudKitTests/TriggerTests.swift | 211 +++++++++++++----- .../Internal/BaseCloudKitTests.swift | 1 + Tests/SQLiteDataTests/Internal/Schema.swift | 17 ++ 5 files changed, 255 insertions(+), 56 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index b788024d..28f8da3b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -367,6 +367,47 @@ ] ), [12]: RecordType( + tableName: "posts", + schema: """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "body", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isPublished", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [13]: RecordType( tableName: "scopedModels", schema: """ CREATE TABLE "scopedModels" ( diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 2891b3e2..842ab989 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -365,6 +365,47 @@ ] ), [12]: RecordType( + tableName: "posts", + schema: """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """, + tableInfo: [ + [0]: TableInfo( + defaultValue: nil, + isPrimaryKey: false, + name: "body", + isNotNull: false, + type: "TEXT" + ), + [1]: TableInfo( + defaultValue: nil, + isPrimaryKey: true, + name: "id", + isNotNull: true, + type: "INT" + ), + [2]: TableInfo( + defaultValue: "0", + isPrimaryKey: false, + name: "isPublished", + isNotNull: true, + type: "INTEGER" + ), + [3]: TableInfo( + defaultValue: "\'\'", + isPrimaryKey: false, + name: "title", + isNotNull: true, + type: "TEXT" + ) + ] + ), + [13]: RecordType( tableName: "scopedModels", schema: """ CREATE TABLE "scopedModels" ( diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 16a5aefc..2528af0a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift @@ -193,6 +193,35 @@ END """, [12]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_posts_from_sync_engine" + AFTER DELETE ON "posts" + FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN + DELETE FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('posts'))); + END + """, + [13]: """ + CREATE TRIGGER "sqlitedata_icloud_after_delete_on_posts_from_user" + AFTER DELETE ON "posts" + FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") IS (NULL)) AND (("sqlitedata_icloud_metadata"."recordType") IS (NULL))) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName") IS ("rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('posts'))); + END + """, + [14]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_sync_engine" AFTER DELETE ON "reminderTags" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -200,7 +229,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [13]: """ + [15]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminderTags_from_user" AFTER DELETE ON "reminderTags" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -221,7 +250,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [14]: """ + [16]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_sync_engine" AFTER DELETE ON "remindersListAssets" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -229,7 +258,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [15]: """ + [17]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListAssets_from_user" AFTER DELETE ON "remindersListAssets" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -250,7 +279,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [16]: """ + [18]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_sync_engine" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -258,7 +287,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [17]: """ + [19]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersListPrivates_from_user" AFTER DELETE ON "remindersListPrivates" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -279,7 +308,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [18]: """ + [20]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_sync_engine" AFTER DELETE ON "remindersLists" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -287,7 +316,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [19]: """ + [21]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_remindersLists_from_user" AFTER DELETE ON "remindersLists" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -308,7 +337,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [20]: """ + [22]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_sync_engine" AFTER DELETE ON "reminders" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -316,7 +345,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [21]: """ + [23]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_reminders_from_user" AFTER DELETE ON "reminders" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -337,7 +366,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [22]: """ + [24]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_scopedModels_from_sync_engine" AFTER DELETE ON "scopedModels" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -345,7 +374,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); END """, - [23]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_scopedModels_from_user" AFTER DELETE ON "scopedModels" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -366,7 +395,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); END """, - [24]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_sqlitedata_icloud_metadata" AFTER UPDATE OF "_isDeleted" ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN ((NOT ("old"."_isDeleted")) AND ("new"."_isDeleted")) AND (NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN @@ -386,7 +415,7 @@ )), "new"."share"); END """, - [25]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" AFTER DELETE ON "tags" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -394,7 +423,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [26]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" AFTER DELETE ON "tags" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -415,7 +444,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [27]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -446,7 +475,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [28]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -477,7 +506,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [29]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" AFTER INSERT ON "modelAs" FOR EACH ROW BEGIN @@ -500,7 +529,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [30]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" AFTER INSERT ON "modelBs" FOR EACH ROW BEGIN @@ -531,7 +560,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [31]: """ + [33]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" AFTER INSERT ON "modelCs" FOR EACH ROW BEGIN @@ -562,7 +591,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [32]: """ + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -585,7 +614,30 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [33]: """ + [35]: """ + CREATE TRIGGER "sqlitedata_icloud_after_insert_on_posts" + AFTER INSERT ON "posts" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") IS (NULL)) AND (("sqlitedata_icloud_metadata"."recordType") IS (NULL))) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName") IS ("rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'posts', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO UPDATE SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"(), "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL + WHERE "sqlitedata_icloud_metadata"."_isDeleted"; + END + """, + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -608,7 +660,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [34]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -639,7 +691,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [35]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -670,7 +722,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [36]: """ + [39]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -701,7 +753,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [37]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -724,7 +776,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [38]: """ + [41]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_scopedModels" AFTER INSERT ON "scopedModels" FOR EACH ROW BEGIN @@ -747,7 +799,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [39]: """ + [42]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_sqlitedata_icloud_metadata" AFTER INSERT ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -756,7 +808,7 @@ SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "new"."zoneName", "new"."ownerName", NULL); END """, - [40]: """ + [43]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -779,7 +831,7 @@ WHERE "sqlitedata_icloud_metadata"."_isDeleted"; END """, - [41]: """ + [44]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetDefaults" AFTER UPDATE OF "id" ON "childWithOnDeleteSetDefaults" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -800,7 +852,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [42]: """ + [45]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_childWithOnDeleteSetNulls" AFTER UPDATE OF "id" ON "childWithOnDeleteSetNulls" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -821,7 +873,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [43]: """ + [46]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelAs" AFTER UPDATE OF "id" ON "modelAs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -842,7 +894,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [44]: """ + [47]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelBs" AFTER UPDATE OF "id" ON "modelBs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -863,7 +915,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [45]: """ + [48]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_modelCs" AFTER UPDATE OF "id" ON "modelCs" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -884,7 +936,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [46]: """ + [49]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_parents" AFTER UPDATE OF "id" ON "parents" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -905,7 +957,28 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [47]: """ + [50]: """ + CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_posts" + AFTER UPDATE OF "id" ON "posts" + FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") IS (NULL)) AND (("sqlitedata_icloud_metadata"."recordType") IS (NULL))) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName") IS ("rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + UPDATE "sqlitedata_icloud_metadata" + SET "_isDeleted" = 1 + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('posts'))); + END + """, + [51]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminderTags" AFTER UPDATE OF "id" ON "reminderTags" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -926,7 +999,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [48]: """ + [52]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_reminders" AFTER UPDATE OF "id" ON "reminders" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -947,7 +1020,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [49]: """ + [53]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListAssets" AFTER UPDATE OF "remindersListID" ON "remindersListAssets" FOR EACH ROW WHEN ("old"."remindersListID") <> ("new"."remindersListID") BEGIN @@ -968,7 +1041,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [50]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersListPrivates" AFTER UPDATE OF "remindersListID" ON "remindersListPrivates" FOR EACH ROW WHEN ("old"."remindersListID") <> ("new"."remindersListID") BEGIN @@ -989,7 +1062,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [51]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_remindersLists" AFTER UPDATE OF "id" ON "remindersLists" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -1010,7 +1083,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [52]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_scopedModels" AFTER UPDATE OF "id" ON "scopedModels" FOR EACH ROW WHEN ("old"."id") <> ("new"."id") BEGIN @@ -1031,7 +1104,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); END """, - [53]: """ + [57]: """ CREATE TRIGGER "sqlitedata_icloud_after_primary_key_change_on_tags" AFTER UPDATE OF "title" ON "tags" FOR EACH ROW WHEN ("old"."title") <> ("new"."title") BEGIN @@ -1052,7 +1125,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [54]: """ + [58]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -1090,7 +1163,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [55]: """ + [59]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -1128,7 +1201,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [56]: """ + [60]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -1154,7 +1227,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [57]: """ + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -1192,7 +1265,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [58]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -1230,7 +1303,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [59]: """ + [63]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -1256,7 +1329,33 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [60]: """ + [64]: """ + CREATE TRIGGER "sqlitedata_icloud_after_update_on_posts" + AFTER UPDATE ON "posts" + FOR EACH ROW BEGIN + WITH "rootShares" AS ( + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") IS (NULL)) AND (("sqlitedata_icloud_metadata"."recordType") IS (NULL))) + UNION ALL + SELECT "sqlitedata_icloud_metadata"."parentRecordName" AS "parentRecordName", "sqlitedata_icloud_metadata"."share" AS "share" + FROM "sqlitedata_icloud_metadata" + JOIN "rootShares" ON ("sqlitedata_icloud_metadata"."recordName") IS ("rootShares"."parentRecordName") + ) + SELECT RAISE(ABORT, 'co.pointfree.SQLiteData.CloudKit.write-permission-error') + FROM "rootShares" + WHERE (((NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) AND (("rootShares"."parentRecordName") IS (NULL))) AND (NOT ("sqlitedata_icloud_hasPermission"("rootShares"."share")))); + INSERT INTO "sqlitedata_icloud_metadata" + ("recordPrimaryKey", "recordType", "zoneName", "ownerName", "parentRecordPrimaryKey", "parentRecordType") + SELECT "new"."id", 'posts', coalesce("sqlitedata_icloud_currentZoneName"(), 'zone'), coalesce("sqlitedata_icloud_currentOwnerName"(), '__defaultOwner__'), NULL, NULL + ON CONFLICT DO UPDATE SET "_isDeleted" = 0, "userModificationTime" = "sqlitedata_icloud_currentTime"(), "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL + WHERE "sqlitedata_icloud_metadata"."_isDeleted"; + UPDATE "sqlitedata_icloud_metadata" + SET "zoneName" = coalesce("sqlitedata_icloud_currentZoneName"(), "sqlitedata_icloud_metadata"."zoneName"), "ownerName" = coalesce("sqlitedata_icloud_currentOwnerName"(), "sqlitedata_icloud_metadata"."ownerName"), "parentRecordPrimaryKey" = NULL, "parentRecordType" = NULL, "userModificationTime" = "sqlitedata_icloud_currentTime"() + WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('posts'))); + END + """, + [65]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -1282,7 +1381,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [61]: """ + [66]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -1320,7 +1419,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [62]: """ + [67]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -1358,7 +1457,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [63]: """ + [68]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -1396,7 +1495,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [64]: """ + [69]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -1422,7 +1521,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [65]: """ + [70]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_scopedModels" AFTER UPDATE ON "scopedModels" FOR EACH ROW BEGIN @@ -1448,7 +1547,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('scopedModels'))); END """, - [66]: """ + [71]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (NOT ("new"."_isDeleted")) AND (NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN @@ -1470,7 +1569,7 @@ ) END); END """, - [67]: """ + [72]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -1496,7 +1595,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [68]: """ + [73]: """ 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 diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 8c1cd190..e3a4931a 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -108,6 +108,7 @@ ModelB.self, ModelC.self, ScopedModel.self, + Post.self, privateTables: RemindersListPrivate.self, startImmediately: startImmediately ) diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index f6aff6cf..e5e179b7 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -70,6 +70,12 @@ import SQLiteData var title = "" var modelBID: ModelB.ID } +@Table struct Post: Equatable, Identifiable { + let id: Int + var title: String + var body: String? + var isPublished = false +} @Table struct UnsyncedModel: Equatable, Identifiable { let id: Int } @@ -228,6 +234,17 @@ func database( """ ) .execute(db) + try #sql( + """ + CREATE TABLE "posts" ( + "id" INT PRIMARY KEY NOT NULL, + "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', + "body" TEXT, + "isPublished" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ) STRICT + """ + ) + .execute(db) try #sql( """ CREATE TABLE "unsyncedModels" ( From 3682b2b9375d686ba36e46d24b92066708d9e816 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:03:57 +0100 Subject: [PATCH 02/23] Add differentFieldsChange_conflictOnSend_clientNewer test --- .../CloudKitTests/MergeConflictTests.swift | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index a37a8987..3fd659a2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -14,6 +14,118 @@ @MainActor @Suite(.taskLocal(CKRecord._$printTimestamps, true)) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + + // MARK: - Different Fields Change + + @Test func differentFieldsChange_conflictOnSend_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits isPublished @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 60, + title: "Hello", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + + // MARK: - Old tests + // TODO: Remove old tests once new analogues are verified + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { try await userDatabase.userWrite { db in From 23080ff3d41fd7538077a020ee674d818c89066d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 11:20:05 +0100 Subject: [PATCH 03/23] Add differentFieldsChange_conflictOnSend_serverNewer test --- .../CloudKitTests/MergeConflictTests.swift | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 3fd659a2..a2fd895d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -123,6 +123,114 @@ } } + @Test func differentFieldsChange_conflictOnSend_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits isPublished @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) + } + } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 30, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + } + // MARK: - Old tests // TODO: Remove old tests once new analogues are verified From 96c3a268011912042bbf6df90c2e556daf67ec2d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 10:55:04 +0100 Subject: [PATCH 04/23] Remove replaced tests --- .../CloudKitTests/MergeConflictTests.swift | 330 ------------------ 1 file changed, 330 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index a2fd895d..a90505e3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -234,336 +234,6 @@ // MARK: - Old tests // TODO: Remove old tests once new analogues are verified - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func merge_clientRecordUpdatedBeforeServerRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let modificationCallback = try withDependencies { - $0.currentTime.now += 60 - } operation: { - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: now) - return try syncEngine.modifyRecords(scope: .private, saving: [record]) - } - - try await withDependencies { - $0.currentTime.now += 30 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.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:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Buy milk", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await withDependencies { - $0.currentTime.now += 30 - } operation: { - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - } - await withDependencies { - $0.currentTime.now += 60 - } operation: { - await modificationCallback.notify() - } - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 1, - isCompletedπŸ—“οΈ: 30, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Buy milk", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordUpdatedBeforeClientRecord() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.currentTime.now += 60 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.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:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Buy milk", - titleπŸ—“οΈ: 30, - πŸ—“οΈ: 30 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 1, - isCompletedπŸ—“οΈ: 60, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Buy milk", - titleπŸ—“οΈ: 30, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverAndClientEditDifferentFields() async throws { try await userDatabase.userWrite { db in From bb48750ddd68a44236b32e499465203c33f181f9 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 11:19:22 +0100 Subject: [PATCH 05/23] Add differentFieldsChange_conflictOnFetch_clientNewer test --- .../CloudKitTests/MergeConflictTests.swift | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index a90505e3..0bfb46ad 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -231,6 +231,133 @@ } } + @Test func differentFieldsChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 3: Client edits isPublished @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) + } + } + + // Step 4: Fetch arrives (merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Send (merged record) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 60, + title: "Hello", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + // MARK: - Old tests // TODO: Remove old tests once new analogues are verified From 9cc1ae4125aab85396f7684eb89cea46c9b6c503 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:17:06 +0100 Subject: [PATCH 06/23] Add differentFieldsChange_conflictOnFetch_serverNewer test --- .../CloudKitTests/MergeConflictTests.swift | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 0bfb46ad..89f4b50a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -358,6 +358,133 @@ } } + @Test func differentFieldsChange_conflictOnFetch_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits isPublished @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.isPublished = true }.execute(db) + } + } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 4: Fetch arrives (merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Send (merged record) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 60, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + // MARK: - Old tests // TODO: Remove old tests once new analogues are verified From b39f2d79536ca260ec1549e47d3695c4363aca94 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:33:22 +0100 Subject: [PATCH 07/23] Add convergence assertions for local state --- .../CloudKitTests/MergeConflictTests.swift | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 89f4b50a..09ea5878 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -98,6 +98,28 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { """ MockCloudDatabase( @@ -204,6 +226,28 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { """ @@ -333,6 +377,28 @@ // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { """ MockCloudDatabase( @@ -460,6 +526,28 @@ // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { """ MockCloudDatabase( From 8d95765a2cde5801078b8f7b0b703c5f89464290 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 15:50:47 +0100 Subject: [PATCH 08/23] Remove replaced test --- .../CloudKitTests/MergeConflictTests.swift | 72 ------------------- 1 file changed, 72 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 09ea5878..cec423a6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -574,78 +574,6 @@ } // MARK: - Old tests - // TODO: Remove old tests once new analogues are verified - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverAndClientEditDifferentFields() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.currentTime.now += 60 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.isCompleted = true }.execute(db) - } - } - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 1, - isCompletedπŸ—“οΈ: 60, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Buy milk", - titleπŸ—“οΈ: 30, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { From 2192caf9384f0fc8c48c17fafa4dcbb4f57a861e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:23:54 +0100 Subject: [PATCH 09/23] Add sameFieldChange_conflictOnSend_retryBeforeFetch_* tests --- .../CloudKitTests/MergeConflictTests.swift | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index cec423a6..7e87428c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -573,6 +573,225 @@ } } + // MARK: - Same Field Change + + @Test func sameFieldChange_conflictOnSend_retryBeforeFetch_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from client", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChange_conflictOnSend_retryBeforeFetch_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } + } + // MARK: - Old tests @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 7d73e9a474dee69bad2be2db14f50f03b3dfaa55 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:27:15 +0100 Subject: [PATCH 10/23] Add sameFieldChange_conflictOnSend_fetchBeforeRetry_* tests --- .../CloudKitTests/MergeConflictTests.swift | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 7e87428c..c7d74ca5 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -792,6 +792,223 @@ } } + @Test func sameFieldChange_conflictOnSend_fetchBeforeRetry_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Fetch arrives + await fetchedRecordZoneChangesCallback.notify() + + // Step 6: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from client", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChange_conflictOnSend_fetchBeforeRetry_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Fetch arrives + await fetchedRecordZoneChangesCallback.notify() + + // Step 6: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } + } + // MARK: - Old tests @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 8afad22d46c8a8fdf041d1dc6ca7569dfc2a231b Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:36:21 +0100 Subject: [PATCH 11/23] Add sameFieldChange_conflictOnFetch_clientNewer test --- .../CloudKitTests/MergeConflictTests.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index c7d74ca5..97e73dbc 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1009,6 +1009,131 @@ } } + @Test func sameFieldChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 30, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Send (merged result) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from client", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + // MARK: - Old tests @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From d56f15fe0f5fe40fc21f1a768a87644ee9b69672 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:45:10 +0100 Subject: [PATCH 12/23] Add sameFieldChange_conflictOnFetch_serverNewer test --- .../CloudKitTests/MergeConflictTests.swift | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 97e73dbc..8b03acf1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1134,6 +1134,92 @@ } } + @Test func sameFieldChange_conflictOnFetch_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 3: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from server", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Send (merged result) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") + } + } + } + // MARK: - Old tests @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 7ae899de683dfd7fd4868e806b5b67cbfa62a670 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:55:59 +0100 Subject: [PATCH 13/23] Remove replaced tests --- .../CloudKitTests/MergeConflictTests.swift | 232 ------------------ 1 file changed, 232 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 8b03acf1..76da2531 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1222,238 +1222,6 @@ // MARK: - Old tests - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAfterClientButProcessedBeforeClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await withDependencies { - $0.currentTime.now += 30 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) - } - try await withDependencies { - $0.currentTime.now += 30 - } operation: { - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: now) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - } - } - - assertQuery(Reminder.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Reminder( β”‚ - β”‚ id: 1, β”‚ - β”‚ dueDate: nil, β”‚ - β”‚ isCompleted: false, β”‚ - β”‚ priority: nil, β”‚ - β”‚ title: "Get milk", β”‚ - β”‚ remindersListID: 1 β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Get milk", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedAndProcessedBeforeClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.currentTime.now += 60 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) - } - } - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Get milk", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordEditedBeforeClientButProcessedAfterClient() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "") - Reminder(id: 1, title: "", remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 30) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - try await withDependencies { - $0.currentTime.now += 60 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.title = "Get milk" }.execute(db) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await modificationCallback.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDateπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priorityπŸ—“οΈ: 0, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "Get milk", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func mergeWithNullableFields() async throws { try await userDatabase.userWrite { db in From 949d691edacfd63c1e0391459211803c0753f524 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 18:18:07 +0100 Subject: [PATCH 14/23] Add sameFieldChangeAndRemoval_* tests --- .../CloudKitTests/MergeConflictTests.swift | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 76da2531..28c5159d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1220,6 +1220,229 @@ } } + // MARK: - Same Field Change & Removal + + @Test func sameFieldChangeAndRemoval_conflictOnSend_clientNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Original body", + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Server changes body @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Server body", forKey: "body", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Server body", + bodyπŸ—“οΈ: 30, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 30 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery(Post.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 60, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + + @Test func sameFieldChangeAndRemoval_conflictOnSend_serverNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Original body", + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + + // Step 2: Client nulls body @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) + } + } + + // Step 3: Server changes body @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Server body", forKey: "body", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + body: "Server body", + bodyπŸ—“οΈ: 60, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.body == "Server body") + } + } + } + // MARK: - Old tests @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) From 5932b21924a9a1e59bbe72e7dea293cfc8f5cefc Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 18:40:43 +0100 Subject: [PATCH 15/23] Revisit test for updating nullable fields --- .../CloudKitTests/MergeConflictTests.swift | 198 +++++++++--------- 1 file changed, 99 insertions(+), 99 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 28c5159d..5b663653 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -573,6 +573,105 @@ } } + @Test func differentNullableFieldsChange_conflictOnFetch_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + Reminder(id: 1, remindersListID: 1) + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server changes dueDate @ t=30 + let record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) + record.setValue(Date(timeIntervalSince1970: 30), forKey: "dueDate", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client changes priority @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Reminder.find(1).update { $0.priority = #bind(3) }.execute(db) + } + } + + // Step 4: Fetch arrives (conflict, merged locally) + await fetchedRecordZoneChangesCallback.notify() + + // Step 5: Send (merged result) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + assertQuery(Reminder.all, database: userDatabase.database) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Reminder( β”‚ + β”‚ id: 1, β”‚ + β”‚ dueDate: Date(1970-01-01T00:00:30.000Z), β”‚ + β”‚ isCompleted: false, β”‚ + β”‚ priority: 3, β”‚ + β”‚ title: "", β”‚ + β”‚ remindersListID: 1 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + assertQuery( + SyncMetadata.select(\.userModificationTime), + database: syncEngine.metadatabase + ) { + """ + β”Œβ”€β”€β”€β”€β” + β”‚ 0 β”‚ + β”‚ 60 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), + recordType: "reminders", + parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), + share: nil, + dueDate: Date(1970-01-01T00:00:30.000Z), + dueDateπŸ—“οΈ: 30, + id: 1, + idπŸ—“οΈ: 0, + isCompleted: 0, + isCompletedπŸ—“οΈ: 0, + priority: 3, + priorityπŸ—“οΈ: 60, + remindersListID: 1, + remindersListIDπŸ—“οΈ: 0, + title: "", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 60 + ), + [1]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + idπŸ—“οΈ: 0, + title: "Personal", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 0 + ) + ] + ) + """ + } + } + // MARK: - Same Field Change @Test func sameFieldChange_conflictOnSend_retryBeforeFetch_clientNewer() async throws { @@ -1442,105 +1541,6 @@ } } } - - // MARK: - Old tests - - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func mergeWithNullableFields() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - Reminder(id: 1, remindersListID: 1) - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await withDependencies { - $0.currentTime.now += 1 - } operation: { - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue( - Date(timeIntervalSince1970: Double(30)), - forKey: "dueDate", - at: now - ) - let modificationsFinished = try syncEngine.modifyRecords( - scope: .private, - saving: [reminderRecord] - ) - - try await withDependencies { - $0.currentTime.now += 1 - } operation: { - try await userDatabase.userWrite { db in - try Reminder.find(1).update { $0.priority = #bind(3) }.execute(db) - } - await modificationsFinished.notify() - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - } - - assertInlineSnapshot(of: container, as: .customDump) { - """ - MockCloudContainer( - privateCloudDatabase: MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__), - recordType: "reminders", - parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)), - share: nil, - dueDate: Date(1970-01-01T00:00:30.000Z), - dueDateπŸ—“οΈ: 1, - id: 1, - idπŸ—“οΈ: 0, - isCompleted: 0, - isCompletedπŸ—“οΈ: 0, - priority: 3, - priorityπŸ—“οΈ: 2, - remindersListID: 1, - remindersListIDπŸ—“οΈ: 0, - title: "", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 2 - ), - [1]: CKRecord( - recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), - recordType: "remindersLists", - parent: nil, - share: nil, - id: 1, - idπŸ—“οΈ: 0, - title: "Personal", - titleπŸ—“οΈ: 0, - πŸ—“οΈ: 0 - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - - try await userDatabase.read { db in - let reminder = try #require(try Reminder.find(1).fetchOne(db)) - expectNoDifference( - reminder, - Reminder( - id: 1, - dueDate: Date(timeIntervalSince1970: 30), - priority: 3, - remindersListID: 1 - ) - ) - } - } - } } } #endif From 802515897450c7305de1fb0c761d59c0c1e02a81 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 19:10:53 +0100 Subject: [PATCH 16/23] Combine local database assertions into a single join query --- .../CloudKitTests/MergeConflictTests.swift | 269 ++++++++---------- 1 file changed, 112 insertions(+), 157 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 5b663653..fb58299a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -9,10 +9,11 @@ import SQLiteDataTestSupport import SnapshotTestingCustomDump import Testing + import TestLocals extension BaseCloudKitTests { @MainActor - @Suite(.taskLocal(CKRecord._$printTimestamps, true)) + @Suite(.taskLocal($attachMetadatabase, true), .taskLocal(CKRecord._$printTimestamps, true)) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { // MARK: - Different Fields Change @@ -98,26 +99,21 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: true β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: true β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -226,26 +222,21 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: true β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: true β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { @@ -377,26 +368,21 @@ // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: true β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: true β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -526,26 +512,21 @@ // Step 5: Send (merged record) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: true β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: true β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -606,29 +587,23 @@ // Step 5: Send (merged result) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertQuery(Reminder.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Reminder( β”‚ - β”‚ id: 1, β”‚ - β”‚ dueDate: Date(1970-01-01T00:00:30.000Z), β”‚ - β”‚ isCompleted: false, β”‚ - β”‚ priority: 3, β”‚ - β”‚ title: "", β”‚ - β”‚ remindersListID: 1 β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Reminder.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 0 β”‚ - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Reminder( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ dueDate: Date(1970-01-01T00:00:30.000Z), β”‚ β”‚ + β”‚ isCompleted: false, β”‚ β”‚ + β”‚ priority: 3, β”‚ β”‚ + β”‚ title: "", β”‚ β”‚ + β”‚ remindersListID: 1 β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -755,26 +730,21 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello from client", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: false β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello from client", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -972,26 +942,21 @@ // Step 6: Retry send try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello from client", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: false β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello from client", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1186,26 +1151,21 @@ // Step 5: Send (merged result) try await syncEngine.processPendingRecordZoneChanges(scope: .private) - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello from client", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: false β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello from client", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1404,26 +1364,21 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - assertQuery(Post.all, database: userDatabase.database) { - """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Post( β”‚ - β”‚ id: 1, β”‚ - β”‚ title: "Hello", β”‚ - β”‚ body: nil, β”‚ - β”‚ isPublished: false β”‚ - β”‚ ) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - """ - } assertQuery( - SyncMetadata.select(\.userModificationTime), - database: syncEngine.metadatabase + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β” - β”‚ 60 β”‚ - β””β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { From 0182aeeb8091ae04057733df55cefeb016c4053f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:17:44 +0100 Subject: [PATCH 17/23] Add sameFieldChange_conflictOnSend_equalTimestamps test --- .../CloudKitTests/MergeConflictTests.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index fb58299a..a446b0bf 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1073,6 +1073,81 @@ } } + @Test func sameFieldChange_conflictOnSend_equalTimestamps() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server edits title @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from server", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from client" }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database + ) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello from client", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello from client", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } + @Test func sameFieldChange_conflictOnFetch_clientNewer() async throws { // Step 1: Seed and initial sync try await userDatabase.userWrite { db in From 8cbeb017f3a0ddbf09ee53e31e356bf9a6aeb3cd Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:27:17 +0100 Subject: [PATCH 18/23] Add sameFieldRemoval_conflictOnSend_clientNewer test --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../CloudKitTests/MergeConflictTests.swift | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index a6b216da..fac22be3 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -154,7 +154,7 @@ @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecordKeyValueSetting { - fileprivate subscript(at key: String) -> Int64 { + package subscript(at key: String) -> Int64 { get { self["\(CKRecord.userModificationTimeKey)_\(key)"] as? Int64 ?? -1 } diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index a446b0bf..532252b4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1571,6 +1571,63 @@ } } } + + @Test func sameFieldRemoval_conflictOnSend_clientNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server nulls body @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.removeValue(forKey: "body", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database + ) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + """ + } + withKnownIssue("Per-field timestamp should reflect the newer removal") { + let recordID = Post.recordID(for: 1) + let record = try syncEngine.private.database.record(for: recordID) + #expect(record.encryptedValues[at: "body"] == 60) + } + } } } #endif From 4dbebc0944a0537608dc8970f00ea5db706d8bf3 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:32:35 +0100 Subject: [PATCH 19/23] Add sameFieldRemoval_conflictOnSend_serverNewer test --- .../CloudKitTests/MergeConflictTests.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 532252b4..e6909a56 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1628,6 +1628,81 @@ #expect(record.encryptedValues[at: "body"] == 60) } } + + @Test func sameFieldRemoval_conflictOnSend_serverNewer() async throws { + // Step 1: Seed with body and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello", body: "Original body") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server nulls body @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.removeValue(forKey: "body", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client nulls body @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.body = #bind(nil as String?) }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { ($0, $1.userModificationTime) }, + database: userDatabase.database + ) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” + β”‚ Post( β”‚ 60 β”‚ + β”‚ id: 1, β”‚ β”‚ + β”‚ title: "Hello", β”‚ β”‚ + β”‚ body: nil, β”‚ β”‚ + β”‚ isPublished: false β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + """ + } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 60, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 0, + isPublishedπŸ—“οΈ: 0, + title: "Hello", + titleπŸ—“οΈ: 0, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } } } #endif From 6976886343e2a16f5d8f31c40c739e1d2b7c01c2 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:46:03 +0100 Subject: [PATCH 20/23] Improve synced row query asserts --- .../CloudKitTests/MergeConflictTests.swift | 321 ++++++++++++------ 1 file changed, 211 insertions(+), 110 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index e6909a56..a8c3c229 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -102,18 +102,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: true β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -225,18 +233,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: true β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { @@ -371,18 +387,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: true β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -515,18 +539,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: true β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: true β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -590,20 +622,28 @@ assertQuery( Reminder.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Reminder( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ dueDate: Date(1970-01-01T00:00:30.000Z), β”‚ β”‚ - β”‚ isCompleted: false, β”‚ β”‚ - β”‚ priority: 3, β”‚ β”‚ - β”‚ title: "", β”‚ β”‚ - β”‚ remindersListID: 1 β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Reminder( β”‚ + β”‚ id: 1, β”‚ + β”‚ dueDate: Date(1970-01-01T00:00:30.000Z), β”‚ + β”‚ isCompleted: false, β”‚ + β”‚ priority: 3, β”‚ + β”‚ title: "", β”‚ + β”‚ remindersListID: 1 β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -733,18 +773,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello from client", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -945,18 +993,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello from client", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1109,18 +1165,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello from client", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1229,18 +1293,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello from client", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from client", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1442,18 +1514,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1608,18 +1688,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } withKnownIssue("Per-field timestamp should reflect the newer removal") { @@ -1665,18 +1753,26 @@ assertQuery( Post.find(1) .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } - .select { ($0, $1.userModificationTime) }, + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, database: userDatabase.database ) { """ - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” - β”‚ Post( β”‚ 60 β”‚ - β”‚ id: 1, β”‚ β”‚ - β”‚ title: "Hello", β”‚ β”‚ - β”‚ body: nil, β”‚ β”‚ - β”‚ isPublished: false β”‚ β”‚ - β”‚ ) β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -1705,4 +1801,9 @@ } } } + + @Selection struct SyncedRow where T.QueryOutput == T { + let row: T + let userModificationTime: Int64 + } #endif From ff4ba2573d26b866ed5fe56fbed4913893a01842 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Nov 2026 17:07:37 +0100 Subject: [PATCH 21/23] Add sameFieldChange_conflictOnSend_equalValues_* tests --- .../CloudKitTests/MergeConflictTests.swift | 134 +++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index a8c3c229..b9688a2c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1129,6 +1129,136 @@ } } + @Test func sameFieldChange_conflictOnSend_equalValues_clientNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Server edits title @ t=30 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from both", forKey: "title", at: 30) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 3: Client edits title to the same value @ t=60 + try await withDependencies { + $0.currentTime.now = 60 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from both" }.execute(db) + } + } + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from both", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + withKnownIssue( + "Per-field timestamp should reflect the client's newer edit even though the value matches the server's" + ) { + let recordID = Post.recordID(for: 1) + let record = try syncEngine.private.database.record(for: recordID) + #expect(record.encryptedValues[at: "title"] == 60) + } + } + + @Test func sameFieldChange_conflictOnSend_equalValues_serverNewer() async throws { + // Step 1: Seed and initial sync + try await userDatabase.userWrite { db in + try db.seed { Post(id: 1, title: "Hello") } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 2: Client edits title @ t=30 + try await withDependencies { + $0.currentTime.now = 30 + } operation: { + try await userDatabase.userWrite { db in + try Post.find(1).update { $0.title = "Hello from both" }.execute(db) + } + } + + // Step 3: Server edits title to the same value @ t=60 + let record = try syncEngine.private.database.record(for: Post.recordID(for: 1)) + record.setValue("Hello from both", forKey: "title", at: 60) + let fetchedRecordZoneChangesCallback = try syncEngine.modifyRecords( + scope: .private, + saving: [record] + ) + + // Step 4: Send (rejected, merged locally) + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 5: Retry send + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + // Step 6: Fetch arrives (no-op, conflict already resolved) + await fetchedRecordZoneChangesCallback.notify() + + assertQuery( + Post.find(1) + .join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) } + .select { + SyncedRow.Columns( + row: $0, + userModificationTime: $1.userModificationTime + ) + }, + database: userDatabase.database + ) { + """ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ SyncedRow( β”‚ + β”‚ row: Post( β”‚ + β”‚ id: 1, β”‚ + β”‚ title: "Hello from both", β”‚ + β”‚ body: nil, β”‚ + β”‚ isPublished: false β”‚ + β”‚ ), β”‚ + β”‚ userModificationTime: 60 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + } + let recordID = Post.recordID(for: 1) + let serverRecord = try syncEngine.private.database.record(for: recordID) + #expect(serverRecord.encryptedValues[at: "title"] == 60) + } + @Test func sameFieldChange_conflictOnSend_equalTimestamps() async throws { // Step 1: Seed and initial sync try await userDatabase.userWrite { db in @@ -1710,7 +1840,9 @@ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } - withKnownIssue("Per-field timestamp should reflect the newer removal") { + withKnownIssue( + "Per-field timestamp should reflect the client's newer removal even though the server also removed the value" + ) { let recordID = Post.recordID(for: 1) let record = try syncEngine.private.database.record(for: recordID) #expect(record.encryptedValues[at: "body"] == 60) From 5f45336d6f81603744f24ec7eb72d621b9a8adac Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 11 Nov 2026 11:37:58 +0100 Subject: [PATCH 22/23] Adjust expectations for fix from #574 --- .../CloudKitTests/MergeConflictTests.swift | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index b9688a2c..50983fb2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -255,30 +255,28 @@ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } - withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { - assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { - """ - MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), - recordType: "posts", - parent: nil, - share: nil, - bodyπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isPublished: 1, - isPublishedπŸ—“οΈ: 30, - title: "Hello", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ) - ] - ) - """ - } + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 30, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ } } @@ -901,11 +899,9 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { - try await userDatabase.read { db in - let post = try #require(try Post.find(1).fetchOne(db)) - #expect(post.title == "Hello from server") - } + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") } } @@ -1121,11 +1117,9 @@ // Step 6: Retry send try await syncEngine.processPendingRecordZoneChanges(scope: .private) - await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { - try await userDatabase.read { db in - let post = try #require(try Post.find(1).fetchOne(db)) - #expect(post.title == "Hello from server") - } + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.title == "Hello from server") } } @@ -1774,11 +1768,9 @@ // Step 6: Fetch arrives (no-op, conflict already resolved) await fetchedRecordZoneChangesCallback.notify() - await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") { - try await userDatabase.read { db in - let post = try #require(try Post.find(1).fetchOne(db)) - #expect(post.body == "Server body") - } + try await userDatabase.read { db in + let post = try #require(try Post.find(1).fetchOne(db)) + #expect(post.body == "Server body") } } From e37bd97a06141b459f04f8a9ad7b30c30e16be60 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 12 Nov 2026 12:41:17 +0100 Subject: [PATCH 23/23] Mark restamp in differentFieldsChange_conflictOnFetch_serverNewer as known issue --- .../CloudKitTests/MergeConflictTests.swift | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 50983fb2..9f466d02 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -559,28 +559,32 @@ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } - assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { - """ - MockCloudDatabase( - databaseScope: .private, - storage: [ - [0]: CKRecord( - recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), - recordType: "posts", - parent: nil, - share: nil, - bodyπŸ—“οΈ: 0, - id: 1, - idπŸ—“οΈ: 0, - isPublished: 1, - isPublishedπŸ—“οΈ: 60, - title: "Hello", - titleπŸ—“οΈ: 60, - πŸ—“οΈ: 60 - ) - ] - ) - """ + withKnownIssue("Client edit to isPublished should keep its original timestamp instead of being restamped at merge time") { + assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { + """ + MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:posts/zone/__defaultOwner__), + recordType: "posts", + parent: nil, + share: nil, + bodyπŸ—“οΈ: 0, + id: 1, + idπŸ—“οΈ: 0, + isPublished: 1, + isPublishedπŸ—“οΈ: 30, + title: "Hello", + titleπŸ—“οΈ: 60, + πŸ—“οΈ: 60 + ) + ] + ) + """ + } + } matching: { issue in + issue.comments.contains { $0.rawValue.contains(/\+\s*isPublishedπŸ—“οΈ: 60,/) } } }