From 37e20be7b01b0eaf1063639db91b747eceb6e13d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 20 Jan 2026 17:02:59 -0600 Subject: [PATCH 1/7] Fix 'NULL DEFAULT _' migrations. --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 6 +- .../CloudKitTests/SchemaChangeTests.swift | 79 ++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 878e832f..f77c44fc 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2003,7 +2003,11 @@ ) async throws -> QueryFragment { let nonPrimaryKeyChangedColumns = changedColumnNames - .filter { $0 != T.primaryKey.name } + .filter { + $0 != T.primaryKey.name + && (record.allKeys().contains($0) + || record.encryptedValues.allKeys().contains($0)) + } guard !nonPrimaryKeyChangedColumns.isEmpty else { diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index 735d3cb4..c8b9cf12 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -218,7 +218,7 @@ try #sql( """ ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0 + ADD COLUMN "position" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 42 """ ) .execute(db) @@ -236,6 +236,63 @@ ], privateTables: syncEngine.privateTables ) + + try await userDatabase.read { db in + try #expect( + RemindersListWithPosition.fetchAll(db) == [ + RemindersListWithPosition(id: 1, title: "Personal", position: 42) + ] + ) + } + } + + /* + * Old schema creates record and synchronizes to iCloud. + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + => Sync starts without emitting an error and default value is persisted in local database. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_OldRecordsSyncToNewSchema() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + _ = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "Personal", color: 42) + ] + ) + } } /* @@ -668,14 +725,30 @@ ) } } + + /* + * Sync engine saves records with old schema. + * Sync engine is relaunched with new schema, adding a 'NOT NULL DEFAULT' column. + => Defaults are set in local records. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNotNullColumn() async throws { + } } } +@Table("remindersLists") +private struct RemindersListWithPosition: Equatable, Identifiable { + let id: Int + var title = "" + var position = 0 +} + @Table("remindersLists") - private struct RemindersListWithPosition: Equatable, Identifiable { + private struct RemindersListWithColor: Equatable, Identifiable { let id: Int var title = "" - var position = 0 + var color: Int? } @Table("reminders") From ca9610365ebc87b6bb0644903a8478b438b10b8b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 09:14:20 -0600 Subject: [PATCH 2/7] clean up --- .../CloudKitTests/SchemaChangeTests.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index c8b9cf12..c0576fa7 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -725,15 +725,6 @@ ) } } - - /* - * Sync engine saves records with old schema. - * Sync engine is relaunched with new schema, adding a 'NOT NULL DEFAULT' column. - => Defaults are set in local records. - */ - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addNotNullColumn() async throws { - } } } From eebb27be3b9b17744e7495ca34cd299928dac111 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 15:44:57 -0600 Subject: [PATCH 3/7] Add test for when old device makes change and syncs to new device. --- .../CloudKitTests/SchemaChangeTests.swift | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index c0576fa7..a820baa6 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -295,6 +295,90 @@ } } + /* + * Old schema creates record and synchronizes to iCloud. + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + => Sync starts without emitting an error and default value is persisted in local database. + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_OldDeviceSyncsChangesToNew() async throws { + let remindersList = RemindersList(id: 1, title: "Personal") + try await userDatabase.userWrite { db in + try db.seed { + remindersList + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + syncEngine.stop() + + try await userDatabase.userWrite { db in + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListRecord = try relaunchedSyncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("My stuff", forKey: "title", at: now) + try await relaunchedSyncEngine + .modifyRecords(scope: .private, saving: [remindersListRecord]) + .notify() + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "My stuff", color: 42) + ] + ) + } + assertInlineSnapshot(of: relaunchedSyncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } + } + /* * Test run from perspective of old device with old schema. * Old schema saves record in cloud database. From f9a517f8ac096afa4a58e8b81e367be7679fdf71 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 17:29:52 -0600 Subject: [PATCH 4/7] fixes --- .../CloudKit/CloudKit+StructuredQueries.swift | 10 ++ Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 +- .../CloudKitTests/CloudKitTests.swift | 36 ++-- .../FetchRecordZoneChangesTests.swift | 4 +- .../ForeignKeyConstraintTests.swift | 6 +- .../CloudKitTests/MergeConflictTests.swift | 160 ++++++++++-------- .../CloudKitTests/RecordTypeTests.swift | 40 ++--- .../CloudKitTests/SchemaChangeTests.swift | 93 ++++++++-- .../CloudKitTests/SharingTests.swift | 4 +- .../CloudKitTests/UserlandTests.swift | 2 +- Tests/SQLiteDataTests/Internal/Schema.swift | 22 +-- 11 files changed, 257 insertions(+), 134 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 2c64af7b..f1698b78 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -128,6 +128,13 @@ } } +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) +extension CKRecord { + func hasSet(key: String) -> Bool { + self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil + } +} + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecordKeyValueSetting { fileprivate subscript(at key: String) -> Int64 { @@ -233,6 +240,9 @@ encryptedValues[at: key] = userModificationTime self.userModificationTime = userModificationTime return true + } else if !hasSet(key: key) { + encryptedValues[at: key] = userModificationTime + self.userModificationTime = userModificationTime } return false } diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index f77c44fc..84b41ea4 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2005,8 +2005,7 @@ changedColumnNames .filter { $0 != T.primaryKey.name - && (record.allKeys().contains($0) - || record.encryptedValues.allKeys().contains($0)) + && record.hasSet(key: $0) } guard !nonPrimaryKeyChangedColumns.isEmpty @@ -2440,6 +2439,13 @@ columnNames: some Collection ) -> QueryFragment { let allColumnNames = T.TableColumns.writableColumns.map(\.name) + .filter { + record.hasSet(key: $0) + } + guard !allColumnNames.isEmpty + else { + return "" + } let hasNonPrimaryKeyColumns = columnNames.contains { $0 != T.primaryKey.name } var query: QueryFragment = "INSERT INTO \(T.self) (" query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) @@ -2452,6 +2458,10 @@ return (try? asset.fileURL.map { try dataManager.load($0) })? .queryFragment ?? "NULL" } else { + // no key present + // key present, nil value + // key present, non-nil value + return record.encryptedValues[columnName]?.queryFragment ?? "NULL" } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift index 5c0a9347..eb9f2aa0 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/CloudKitTests.swift @@ -24,7 +24,7 @@ tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, @@ -34,7 +34,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "\'\'", @@ -101,7 +101,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -124,7 +124,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", @@ -177,7 +177,7 @@ tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -188,7 +188,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -210,7 +210,7 @@ tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """, tableInfo: [ @@ -219,7 +219,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -227,7 +227,7 @@ tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, @@ -237,7 +237,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -252,7 +252,7 @@ tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -263,7 +263,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -278,7 +278,7 @@ tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -296,7 +296,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -304,7 +304,7 @@ tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -315,7 +315,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -337,7 +337,7 @@ tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -348,7 +348,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 207728c7..d0a82cb3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -404,8 +404,8 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func receiveRecord_SingleFieldPrimaryKey() async throws { - let tagRecord = CKRecord(recordType: "tags", recordID: Tag.recordID(for: "weekend")) - tagRecord.encryptedValues["title"] = "weekend" + let tagRecord = CKRecord(recordType: Tag.tableName, recordID: Tag.recordID(for: "weekend")) + tagRecord.setValue("weekend", forKey: "title", at: 0) try await syncEngine.modifyRecords(scope: .private, saving: [tagRecord]).notify() try await userDatabase.read { db in diff --git a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift index cc2883d6..9596a66e 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ForeignKeyConstraintTests.swift @@ -119,10 +119,13 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func remoteCreatesRecordABC_localReceivesAC_remoteDeletesBC() async throws { let modelARecord = CKRecord(recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1)) + modelARecord.setValue(1, forKey: "id", at: now) let modelBRecord = CKRecord(recordType: ModelB.tableName, recordID: ModelB.recordID(for: 1)) + modelBRecord.setValue(1, forKey: "id", at: now) modelBRecord.setValue(1, forKey: "modelAID", at: now) modelBRecord.parent = CKRecord.Reference(record: modelARecord, action: .none) let modelCRecord = CKRecord(recordType: ModelC.tableName, recordID: ModelC.recordID(for: 1)) + modelCRecord.setValue(1, forKey: "id", at: now) modelCRecord.setValue(1, forKey: "modelBID", at: now) modelCRecord.parent = CKRecord.Reference(record: modelBRecord, action: .none) @@ -205,7 +208,8 @@ recordID: CKRecord.ID(1:modelAs/zone/__defaultOwner__), recordType: "modelAs", parent: nil, - share: nil + share: nil, + id: 1 ) ] ), diff --git a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift index e3a5964a..c2e36742 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift @@ -33,10 +33,12 @@ 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: "", @@ -90,10 +92,12 @@ 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", @@ -135,10 +139,12 @@ 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", @@ -187,10 +193,12 @@ 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: "", @@ -244,10 +252,12 @@ 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", @@ -289,10 +299,12 @@ 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", @@ -358,10 +370,12 @@ 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", @@ -445,10 +459,12 @@ 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", @@ -514,10 +530,12 @@ 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", @@ -584,10 +602,12 @@ 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", @@ -626,86 +646,90 @@ } try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let reminderRecord = try syncEngine.private.database.record( - for: Reminder.recordID(for: 1) - ) - reminderRecord.setValue( - Date(timeIntervalSince1970: Double(now + 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 = 3 }.execute(db) + 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 = 3 }.execute(db) + } + await modificationsFinished.notify() + try await syncEngine.processPendingRecordZoneChanges(scope: .private) } - 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🗓️: 0, - id: 1, - id🗓️: 0, - isCompleted: 0, - isCompleted🗓️: 0, - priority: 3, - priority🗓️: 1, - remindersListID: 1, - remindersListID🗓️: 0, - title: "", - title🗓️: 0, - 🗓️: 1 - ), - [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: [] + 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)) - #expect( - reminder - == Reminder( + 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 ) - ) + ) + } } } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift index a7190930..8e624b72 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/RecordTypeTests.swift @@ -22,7 +22,7 @@ tableName: "remindersLists", schema: """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """, @@ -32,7 +32,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "\'\'", @@ -99,7 +99,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -122,7 +122,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", @@ -175,7 +175,7 @@ tableName: "reminderTags", schema: """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -186,7 +186,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -208,7 +208,7 @@ tableName: "parents", schema: """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """, tableInfo: [ @@ -217,7 +217,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -225,7 +225,7 @@ tableName: "childWithOnDeleteSetNulls", schema: """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """, @@ -235,7 +235,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -250,7 +250,7 @@ tableName: "childWithOnDeleteSetDefaults", schema: """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -261,7 +261,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -276,7 +276,7 @@ tableName: "modelAs", schema: """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -294,7 +294,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ) ] ), @@ -302,7 +302,7 @@ tableName: "modelBs", schema: """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -313,7 +313,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: "0", @@ -335,7 +335,7 @@ tableName: "modelCs", schema: """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -346,7 +346,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [1]: TableInfo( defaultValue: nil, @@ -445,7 +445,7 @@ tableName: "reminders", schema: """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -468,7 +468,7 @@ isPrimaryKey: true, name: "id", isNotNull: true, - type: "INTEGER" + type: "INT" ), [2]: TableInfo( defaultValue: "0", diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index a820baa6..54357da9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -296,21 +296,91 @@ } /* - * Old schema creates record and synchronizes to iCloud. * Schema is migrated to add a "NULL DEFAULT _" column. * New sync engine is launched. - => Sync starts without emitting an error and default value is persisted in local database. + * Old record with no 'color' value synchronized + => Local database row created uses default for 'color'. */ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func addNullableColumn_OldDeviceSyncsChangesToNew() async throws { - let remindersList = RemindersList(id: 1, title: "Personal") + @Test func addNullableColumn_OldDeviceSyncsMissingColor() async throws { + syncEngine.stop() + try await userDatabase.userWrite { db in - try db.seed { - remindersList + try #sql( + """ + ALTER TABLE "remindersLists" + ADD COLUMN "color" INTEGER DEFAULT 42 + """ + ) + .execute(db) + } + + // NB: Sync engine should start without emitting issue. + let relaunchedSyncEngine = try await SyncEngine( + container: syncEngine.container, + userDatabase: syncEngine.userDatabase, + tables: syncEngine.tables + .filter { $0.base != RemindersList.self } + + [ + SynchronizedTable(for: RemindersListWithColor.self), + ], + privateTables: syncEngine.privateTables + ) + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue(1, forKey: "id", at: now) + remindersListRecord.setValue("My stuff", forKey: "title", at: now) + try await relaunchedSyncEngine + .modifyRecords(scope: .private, saving: [remindersListRecord]) + .notify() + + try await userDatabase.read { db in + try #expect( + RemindersListWithColor.fetchAll(db) == [ + RemindersListWithColor(id: 1, title: "My stuff", color: 42) + ] + ) + } + assertInlineSnapshot(of: relaunchedSyncEngine.container, as: .customDump) { + """ + MockCloudContainer( + privateCloudDatabase: MockCloudDatabase( + databaseScope: .private, + storage: [ + [0]: CKRecord( + recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__), + recordType: "remindersLists", + parent: nil, + share: nil, + id: 1, + title: "My stuff" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ } } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) + } + /* + * Schema is migrated to add a "NULL DEFAULT _" column. + * New sync engine is launched. + * New record with NULL 'color' value synchronized + => Local database row created uses NULL for color + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func addNullableColumn_NewDeviceSyncsNullColor() async throws { syncEngine.stop() try await userDatabase.userWrite { db in @@ -338,10 +408,13 @@ try await withDependencies { $0.currentTime.now += 1 } operation: { - let remindersListRecord = try relaunchedSyncEngine.private.database.record( - for: RemindersList.recordID(for: 1) + let remindersListRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 1) ) + remindersListRecord.setValue(1, forKey: "id", at: now) remindersListRecord.setValue("My stuff", forKey: "title", at: now) + remindersListRecord.removeValue(forKey: "color", at: now) try await relaunchedSyncEngine .modifyRecords(scope: .private, saving: [remindersListRecord]) .notify() @@ -349,7 +422,7 @@ try await userDatabase.read { db in try #expect( RemindersListWithColor.fetchAll(db) == [ - RemindersListWithColor(id: 1, title: "My stuff", color: 42) + RemindersListWithColor(id: 1, title: "My stuff", color: nil) ] ) } diff --git a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift index 6283c358..7fff8667 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SharingTests.swift @@ -1592,6 +1592,7 @@ recordType: ModelA.tableName, recordID: ModelA.recordID(for: 1, zoneID: externalZone.zoneID) ) + modelARecord.setValue(1, forKey: "id", at: now) modelARecord.setValue(42, forKey: "count", at: now) let share = CKShare( @@ -1657,7 +1658,8 @@ recordType: "modelAs", parent: nil, share: CKReference(recordID: CKRecord.ID(share-1:modelAs/external.zone/external.owner)), - count: 42 + count: 42, + id: 1 ), [1]: CKRecord( recordID: CKRecord.ID(1:modelBs/external.zone/external.owner), diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index e5ad0bcd..4f755037 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -26,7 +26,7 @@ @FetchAll var modelAs: [ModelA] = [] try await database.write { db in try db.seed { - ModelA.Draft() + ModelA.Draft(id: 1) } } try await $modelAs.load() diff --git a/Tests/SQLiteDataTests/Internal/Schema.swift b/Tests/SQLiteDataTests/Internal/Schema.swift index ba018d1c..a9e965b9 100644 --- a/Tests/SQLiteDataTests/Internal/Schema.swift +++ b/Tests/SQLiteDataTests/Internal/Schema.swift @@ -94,7 +94,7 @@ func database( try #sql( """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' ) STRICT """ @@ -123,7 +123,7 @@ func database( try #sql( """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "dueDate" TEXT, "isCompleted" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "priority" INTEGER, @@ -146,7 +146,7 @@ func database( try #sql( """ CREATE TABLE "reminderTags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "reminderID" INTEGER NOT NULL REFERENCES "reminders"("id") ON DELETE CASCADE, "tagID" TEXT NOT NULL REFERENCES "tags"("title") ON DELETE CASCADE ON UPDATE CASCADE ) STRICT @@ -156,7 +156,7 @@ func database( try #sql( """ CREATE TABLE "parents"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) STRICT """ ) @@ -164,7 +164,7 @@ func database( try #sql( """ CREATE TABLE "childWithOnDeleteSetNulls"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER REFERENCES "parents"("id") ON DELETE SET NULL ON UPDATE SET NULL ) STRICT """ @@ -173,7 +173,7 @@ func database( try #sql( """ CREATE TABLE "childWithOnDeleteSetDefaults"( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "parentID" INTEGER NOT NULL DEFAULT 0 REFERENCES "parents"("id") ON DELETE SET DEFAULT ON UPDATE SET DEFAULT ) STRICT @@ -183,7 +183,7 @@ func database( try #sql( """ CREATE TABLE "localUsers" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "name" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "parentID" INTEGER REFERENCES "localUsers"("id") ON DELETE CASCADE ) STRICT @@ -193,7 +193,7 @@ func database( try #sql( """ CREATE TABLE "modelAs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "count" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "isEven" INTEGER GENERATED ALWAYS AS ("count" % 2 == 0) VIRTUAL ) @@ -203,7 +203,7 @@ func database( try #sql( """ CREATE TABLE "modelBs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "isOn" INTEGER NOT NULL ON CONFLICT REPLACE DEFAULT 0, "modelAID" INTEGER NOT NULL REFERENCES "modelAs"("id") ON DELETE CASCADE ) @@ -213,7 +213,7 @@ func database( try #sql( """ CREATE TABLE "modelCs" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "id" INT PRIMARY KEY NOT NULL, "title" TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '', "modelBID" INTEGER NOT NULL REFERENCES "modelBs"("id") ON DELETE CASCADE ) @@ -223,7 +223,7 @@ func database( try #sql( """ CREATE TABLE "unsyncedModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL + "id" INT PRIMARY KEY NOT NULL ) """ ) From 9b978a86a6fbd581949bcf9b548426fd6105bdc0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 17:31:04 -0600 Subject: [PATCH 5/7] clean up --- .../CloudKit/CloudKit+StructuredQueries.swift | 11 ++++------- Sources/SQLiteData/CloudKit/SyncEngine.swift | 4 ---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index f1698b78..c03123a1 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -128,13 +128,6 @@ } } -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -extension CKRecord { - func hasSet(key: String) -> Bool { - self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil - } -} - @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecordKeyValueSetting { fileprivate subscript(at key: String) -> Int64 { @@ -153,6 +146,10 @@ extension CKRecord { @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) extension CKRecord { + func hasSet(key: String) -> Bool { + self.encryptedValues["\(CKRecord.userModificationTimeKey)_\(key)"] != nil + } + @discardableResult package func setValue( _ newValue: some CKRecordValueProtocol & Equatable, diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 84b41ea4..83aaa3b1 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2458,10 +2458,6 @@ return (try? asset.fileURL.map { try dataManager.load($0) })? .queryFragment ?? "NULL" } else { - // no key present - // key present, nil value - // key present, non-nil value - return record.encryptedValues[columnName]?.queryFragment ?? "NULL" } } From 3d79965cd65c48d5cd04bca9b715bf1a16627c14 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 18:22:24 -0600 Subject: [PATCH 6/7] filter columnNames by allColumnNames --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 83aaa3b1..a8f4e0ec 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2438,20 +2438,19 @@ record: CKRecord, columnNames: some Collection ) -> QueryFragment { - let allColumnNames = T.TableColumns.writableColumns.map(\.name) - .filter { - record.hasSet(key: $0) - } - guard !allColumnNames.isEmpty + let setColumnNames = T.TableColumns.writableColumns.map(\.name) + .filter { record.hasSet(key: $0) } + guard !setColumnNames.isEmpty else { return "" } + let columnNames = columnNames.filter { setColumnNames.contains($0) } let hasNonPrimaryKeyColumns = columnNames.contains { $0 != T.primaryKey.name } var query: QueryFragment = "INSERT INTO \(T.self) (" - query.append(allColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) + query.append(setColumnNames.map { "\(quote: $0)" }.joined(separator: ", ")) query.append(") VALUES (") query.append( - allColumnNames + setColumnNames .map { columnName in if let asset = record[columnName] as? CKAsset { @Dependency(\.dataManager) var dataManager From c488f99e8e9685fbd95e587548994781a374f656 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Wed, 21 Jan 2026 18:24:21 -0600 Subject: [PATCH 7/7] clean up --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index a8f4e0ec..ec6c5455 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -2004,8 +2004,7 @@ let nonPrimaryKeyChangedColumns = changedColumnNames .filter { - $0 != T.primaryKey.name - && record.hasSet(key: $0) + $0 != T.primaryKey.name && record.hasSet(key: $0) } guard !nonPrimaryKeyChangedColumns.isEmpty