From cf4253e900ade3e07d50f0d41a5712b72239c14f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:07:50 +0100 Subject: [PATCH 01/20] Introduce Post table type --- .../CloudKitTests/CloudKitTests.swift | 41 ++++ .../CloudKitTests/RecordTypeTests.swift | 41 ++++ .../CloudKitTests/TriggerTests.swift | 199 +++++++++++++----- .../Internal/BaseCloudKitTests.swift | 1 + Tests/SQLiteDataTests/Internal/Schema.swift | 17 ++ 5 files changed, 248 insertions(+), 51 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index d7332fc9..8a21c1c9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -365,6 +365,47 @@ type: "TEXT" ) ] + ), + [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" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index 8e624b72..3cfb0b5e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -363,6 +363,47 @@ type: "TEXT" ) ] + ), + [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" + ) + ] ) ] """# diff --git a/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift b/Tests/SQLiteDataTests/CloudKitTests/TriggerTests.swift index 4a04e1b2..7a2a8215 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_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 @@ -357,7 +386,7 @@ )), "new"."share"); END """, - [23]: """ + [25]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_sync_engine" AFTER DELETE ON "tags" FOR EACH ROW WHEN "sqlitedata_icloud_syncEngineIsSynchronizingChanges"() BEGIN @@ -365,7 +394,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [24]: """ + [26]: """ CREATE TRIGGER "sqlitedata_icloud_after_delete_on_tags_from_user" AFTER DELETE ON "tags" FOR EACH ROW WHEN NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"()) BEGIN @@ -386,7 +415,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [25]: """ + [27]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetDefaults" AFTER INSERT ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -412,7 +441,7 @@ ON CONFLICT DO NOTHING; END """, - [26]: """ + [28]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_childWithOnDeleteSetNulls" AFTER INSERT ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -438,7 +467,7 @@ ON CONFLICT DO NOTHING; END """, - [27]: """ + [29]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelAs" AFTER INSERT ON "modelAs" FOR EACH ROW BEGIN @@ -460,7 +489,7 @@ ON CONFLICT DO NOTHING; END """, - [28]: """ + [30]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelBs" AFTER INSERT ON "modelBs" FOR EACH ROW BEGIN @@ -486,7 +515,7 @@ ON CONFLICT DO NOTHING; END """, - [29]: """ + [31]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_modelCs" AFTER INSERT ON "modelCs" FOR EACH ROW BEGIN @@ -512,7 +541,7 @@ ON CONFLICT DO NOTHING; END """, - [30]: """ + [32]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_parents" AFTER INSERT ON "parents" FOR EACH ROW BEGIN @@ -534,7 +563,29 @@ ON CONFLICT DO NOTHING; END """, - [31]: """ + [33]: """ + 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 NOTHING; + END + """, + [34]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminderTags" AFTER INSERT ON "reminderTags" FOR EACH ROW BEGIN @@ -556,7 +607,7 @@ ON CONFLICT DO NOTHING; END """, - [32]: """ + [35]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN @@ -582,7 +633,7 @@ ON CONFLICT DO NOTHING; END """, - [33]: """ + [36]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListAssets" AFTER INSERT ON "remindersListAssets" FOR EACH ROW BEGIN @@ -608,7 +659,7 @@ ON CONFLICT DO NOTHING; END """, - [34]: """ + [37]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersListPrivates" AFTER INSERT ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -634,7 +685,7 @@ ON CONFLICT DO NOTHING; END """, - [35]: """ + [38]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_remindersLists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN @@ -656,7 +707,7 @@ ON CONFLICT DO NOTHING; END """, - [36]: """ + [39]: """ 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 @@ -665,7 +716,7 @@ SELECT "sqlitedata_icloud_didUpdate"("new"."recordName", "new"."zoneName", "new"."ownerName", "new"."zoneName", "new"."ownerName", NULL); END """, - [37]: """ + [40]: """ CREATE TRIGGER "sqlitedata_icloud_after_insert_on_tags" AFTER INSERT ON "tags" FOR EACH ROW BEGIN @@ -687,7 +738,7 @@ ON CONFLICT DO NOTHING; END """, - [38]: """ + [41]: """ 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 @@ -708,7 +759,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [39]: """ + [42]: """ 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 @@ -729,7 +780,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [40]: """ + [43]: """ 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 @@ -750,7 +801,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [41]: """ + [44]: """ 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 @@ -771,7 +822,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [42]: """ + [45]: """ 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 @@ -792,7 +843,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [43]: """ + [46]: """ 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 @@ -813,7 +864,28 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [44]: """ + [47]: """ + 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 + """, + [48]: """ 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 @@ -834,7 +906,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [45]: """ + [49]: """ 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 @@ -855,7 +927,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [46]: """ + [50]: """ 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 @@ -876,7 +948,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [47]: """ + [51]: """ 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 @@ -897,7 +969,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [48]: """ + [52]: """ 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 @@ -918,7 +990,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [49]: """ + [53]: """ 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 @@ -939,7 +1011,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("old"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [50]: """ + [54]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetDefaults" AFTER UPDATE ON "childWithOnDeleteSetDefaults" FOR EACH ROW BEGIN @@ -972,7 +1044,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetDefaults'))); END """, - [51]: """ + [55]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_childWithOnDeleteSetNulls" AFTER UPDATE ON "childWithOnDeleteSetNulls" FOR EACH ROW BEGIN @@ -1005,7 +1077,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('childWithOnDeleteSetNulls'))); END """, - [52]: """ + [56]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelAs" AFTER UPDATE ON "modelAs" FOR EACH ROW BEGIN @@ -1030,7 +1102,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelAs'))); END """, - [53]: """ + [57]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelBs" AFTER UPDATE ON "modelBs" FOR EACH ROW BEGIN @@ -1063,7 +1135,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelBs'))); END """, - [54]: """ + [58]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_modelCs" AFTER UPDATE ON "modelCs" FOR EACH ROW BEGIN @@ -1096,7 +1168,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('modelCs'))); END """, - [55]: """ + [59]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_parents" AFTER UPDATE ON "parents" FOR EACH ROW BEGIN @@ -1121,7 +1193,32 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('parents'))); END """, - [56]: """ + [60]: """ + 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 NOTHING; + 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 + """, + [61]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminderTags" AFTER UPDATE ON "reminderTags" FOR EACH ROW BEGIN @@ -1146,7 +1243,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminderTags'))); END """, - [57]: """ + [62]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_reminders" AFTER UPDATE ON "reminders" FOR EACH ROW BEGIN @@ -1179,7 +1276,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('reminders'))); END """, - [58]: """ + [63]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListAssets" AFTER UPDATE ON "remindersListAssets" FOR EACH ROW BEGIN @@ -1212,7 +1309,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListAssets'))); END """, - [59]: """ + [64]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersListPrivates" AFTER UPDATE ON "remindersListPrivates" FOR EACH ROW BEGIN @@ -1245,7 +1342,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."remindersListID")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersListPrivates'))); END """, - [60]: """ + [65]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_remindersLists" AFTER UPDATE ON "remindersLists" FOR EACH ROW BEGIN @@ -1270,7 +1367,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."id")) AND (("sqlitedata_icloud_metadata"."recordType") = ('remindersLists'))); END """, - [61]: """ + [66]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_sqlitedata_icloud_metadata" AFTER UPDATE ON "sqlitedata_icloud_metadata" FOR EACH ROW WHEN (("old"."_isDeleted") = ("new"."_isDeleted")) AND (NOT ("sqlitedata_icloud_syncEngineIsSynchronizingChanges"())) BEGIN @@ -1292,7 +1389,7 @@ ) END); END """, - [62]: """ + [67]: """ CREATE TRIGGER "sqlitedata_icloud_after_update_on_tags" AFTER UPDATE ON "tags" FOR EACH ROW BEGIN @@ -1317,7 +1414,7 @@ WHERE ((("sqlitedata_icloud_metadata"."recordPrimaryKey") = ("new"."title")) AND (("sqlitedata_icloud_metadata"."recordType") = ('tags'))); END """, - [63]: """ + [68]: """ 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 d54dc32b..c7b294d8 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -79,6 +79,7 @@ ModelA.self, ModelB.self, ModelC.self, + Post.self, privateTables: RemindersListPrivate.self, startImmediately: _StartImmediatelyTrait.startImmediately ) diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index a9e965b9..a63a11c7 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 } @@ -220,6 +226,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 124edb788e4684a8d1216d61747770262e5e78e1 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:03:57 +0100 Subject: [PATCH 02/20] Add differentFieldsChange_conflictOnSend_clientNewer test --- .../CloudKitTests/MergeConflictTests.swift | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index 34530951..92a76198 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -11,8 +11,119 @@ extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable - { + @Suite(.printTimestamps) 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 a452c1de9273d4969cbcb55ad07db702b4405722 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 11:20:05 +0100 Subject: [PATCH 03/20] 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 92a76198..cb836b18 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -121,6 +121,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() + + // NB: t_isPublished is 60 (not 30), because all changed fields are sent with the user + // modification time, which is set to max(t_client, t_server). + 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 2eddb62e66f304646ce79592d7e502a6a4a54b4e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 10:55:04 +0100 Subject: [PATCH 04/20] Remove replaced tests --- .../CloudKitTests/MergeConflictTests.swift | 320 ------------------ 1 file changed, 320 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index cb836b18..7b93992e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -232,326 +232,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 record = try syncEngine.private.database.record(for: Reminder.recordID(for: 1)) - record.setValue("Buy milk", forKey: "title", at: 60) - let modificationCallback = try { - try syncEngine.modifyRecords(scope: .private, saving: [record]) - }() - - 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 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πŸ—“οΈ: 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 29ff91bf4c888ca1cbe4d9ccfaa4d63c7f12cc63 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 11:19:22 +0100 Subject: [PATCH 05/20] 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 7b93992e..b086fa8a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -229,6 +229,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 17bcc829fd60a05dc11f94bd56c156c7545a52e8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:17:06 +0100 Subject: [PATCH 06/20] 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 b086fa8a..34a859a3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -356,6 +356,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 88ef0349a457f8c9964cef9540ab3a3295a03cb1 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 12:33:22 +0100 Subject: [PATCH 07/20] 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 34a859a3..73fc5396 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -96,6 +96,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( @@ -202,6 +224,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 β”‚ + β””β”€β”€β”€β”€β”˜ + """ + } // NB: t_isPublished is 60 (not 30), because all changed fields are sent with the user // modification time, which is set to max(t_client, t_server). assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) { @@ -331,6 +375,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( @@ -458,6 +524,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 a248d2d2b1c81e15d2d5f0af13604ab2783ced00 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 15:50:47 +0100 Subject: [PATCH 08/20] 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 73fc5396..8bba05d7 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -572,78 +572,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 6376312cf3dc40fb83cff90fd818513038ee35cd Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:23:54 +0100 Subject: [PATCH 09/20] 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 8bba05d7..6ca8530b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -571,6 +571,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 0427775062f99becf3426ad722fce1940fbd3344 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:27:15 +0100 Subject: [PATCH 10/20] 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 6ca8530b..d4e59299 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -790,6 +790,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 160ae4018e200e2830ad1a3fe3501a4ccbbdcd1d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:36:21 +0100 Subject: [PATCH 11/20] 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 d4e59299..0b181ea1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1007,6 +1007,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 8366153fb97ab33dd7ed764633ab0777e3f946da Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:45:10 +0100 Subject: [PATCH 12/20] 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 0b181ea1..74aa554b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1132,6 +1132,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 baea0a27f44a16b0602e85079f0c25c3a61e0788 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 16:55:59 +0100 Subject: [PATCH 13/20] 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 74aa554b..7b02d198 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1220,238 +1220,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 79cc37daeab1503f3145d64f5cb48378df64bca7 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 18:18:07 +0100 Subject: [PATCH 14/20] 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 7b02d198..06601764 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1218,6 +1218,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 d7f04c15c5633539fd9860960884d15636700bb2 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 18:40:43 +0100 Subject: [PATCH 15/20] Update 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 06601764..e9b4bf22 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -571,6 +571,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 { @@ -1440,105 +1539,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 a64ec1634639c698d90ceb73d95a7f03fb6667d1 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 4 Mar 2026 19:10:53 +0100 Subject: [PATCH 16/20] 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 e9b4bf22..63fa7e88 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -11,7 +11,8 @@ extension BaseCloudKitTests { @MainActor - @Suite(.printTimestamps) final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { + @Suite(.attachMetadatabase, .printTimestamps) + final class MergeConflictTests: BaseCloudKitTests, @unchecked Sendable { // MARK: - Different Fields Change @@ -96,26 +97,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) { @@ -224,26 +220,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 β”‚ β”‚ + β”‚ ) β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ """ } // NB: t_isPublished is 60 (not 30), because all changed fields are sent with the user @@ -375,26 +366,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) { @@ -524,26 +510,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) { @@ -604,29 +585,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) { @@ -753,26 +728,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) { @@ -970,26 +940,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) { @@ -1184,26 +1149,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) { @@ -1402,26 +1362,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 fff64735f5a600f7214034ef004e180668b25973 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:17:44 +0100 Subject: [PATCH 17/20] 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 63fa7e88..7c9f7f12 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1071,6 +1071,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 be62e8e03049ad5a32bfc3df8a41ba22236fee63 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:27:17 +0100 Subject: [PATCH 18/20] 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 c03123a1..0cae9633 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -130,7 +130,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 7c9f7f12..38391147 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1569,6 +1569,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 4267ff0614a9fc7b399e5eaf5f7317286678951b Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:32:35 +0100 Subject: [PATCH 19/20] 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 38391147..e7bc9a0b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -1626,6 +1626,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 42a568260fe682aa8d269b91692fece1b3fcef10 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 5 Mar 2026 09:46:03 +0100 Subject: [PATCH 20/20] 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 e7bc9a0b..dd7297bf 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -100,18 +100,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) { @@ -223,18 +231,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 β”‚ + β”‚ ) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ """ } // NB: t_isPublished is 60 (not 30), because all changed fields are sent with the user @@ -369,18 +385,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) { @@ -513,18 +537,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) { @@ -588,20 +620,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) { @@ -731,18 +771,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) { @@ -943,18 +991,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) { @@ -1107,18 +1163,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) { @@ -1227,18 +1291,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) { @@ -1440,18 +1512,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) { @@ -1606,18 +1686,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") { @@ -1663,18 +1751,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) { @@ -1703,4 +1799,9 @@ } } } + + @Selection struct SyncedRow where T.QueryOutput == T { + let row: T + let userModificationTime: Int64 + } #endif