From 6a9bab105705006666847df8cb05a2fa803b8519 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 23 Jan 2026 17:16:23 -0600 Subject: [PATCH 1/5] Fixes to date synchronization. --- .../CloudKit/CloudKit+StructuredQueries.swift | 5 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 29 ++++++--- Sources/SQLiteData/Internal/ISO8601.swift | 10 +++- .../FetchRecordZoneChangesTests.swift | 60 +++++++++++++++++++ .../CloudKitTests/PreviewTests.swift | 3 +- Tests/SQLiteDataTests/DateTests.swift | 51 ++++++++++++++++ 6 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 Tests/SQLiteDataTests/DateTests.swift diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 2c64af7b..d4ddeb5b 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -278,7 +278,8 @@ with other: CKRecord, row: T, columnNames: inout [String], - parentForeignKey: ForeignKey? + parentForeignKey: ForeignKey?, + syncEngineHasPendingChanges: Bool ) { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable @@ -323,7 +324,7 @@ return false } } - if didSet || isRowValueModified { + if didSet || (syncEngineHasPendingChanges && isRowValueModified) { columnNames.removeAll(where: { $0 == key }) if didSet, let parentForeignKey, key == parentForeignKey.from { self.parent = other.parent diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 878e832f..b1b16507 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -831,14 +831,14 @@ } return } - let oldSyncEngine = self.syncEngines.withValue { - oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared - } - let syncEngine = self.syncEngines.withValue { + syncEngine(for: oldZoneID)?.state.add(pendingRecordZoneChanges: oldChanges) + syncEngine(for: zoneID)?.state.add(pendingRecordZoneChanges: newChanges) + } + + fileprivate func syncEngine(for zoneID: CKRecordZone.ID) -> (any SyncEngineProtocol)? { + syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } - oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) - syncEngine?.state.add(pendingRecordZoneChanges: newChanges) } @DatabaseFunction( @@ -1941,7 +1941,19 @@ columnNames: &columnNames, parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 ? foreignKeysByTableName[T.tableName]?.first - : nil + : nil, + syncEngineHasPendingChanges: syncEngine(for: serverRecord.recordID.zoneID)?.state + .pendingRecordZoneChanges.contains { + switch $0 { + case .saveRecord(let recordID): + return recordID == serverRecord.recordID + case .deleteRecord: + return false + @unknown default: + return false + } + } + ?? false ) } @@ -2045,7 +2057,8 @@ if data == nil { reportIssue("Asset data not found on disk") } - return "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" + return + "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" } else { return """ \(quote: columnName) = \ diff --git a/Sources/SQLiteData/Internal/ISO8601.swift b/Sources/SQLiteData/Internal/ISO8601.swift index 451d16c0..1e9ce773 100644 --- a/Sources/SQLiteData/Internal/ISO8601.swift +++ b/Sources/SQLiteData/Internal/ISO8601.swift @@ -3,10 +3,16 @@ import Foundation extension Date { @usableFromInline var iso8601String: String { + let nextUpDate = Date(timeIntervalSinceReferenceDate: timeIntervalSinceReferenceDate.nextUp) if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { - return formatted(.iso8601.currentTimestamp(includingFractionalSeconds: true)) + return + nextUpDate + .formatted( + .iso8601.currentTimestamp(includingFractionalSeconds: true) + ) } else { - return DateFormatter.iso8601(includingFractionalSeconds: true).string(from: self) + return DateFormatter.iso8601(includingFractionalSeconds: true) + .string(from: nextUpDate) } } diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 207728c7..8fa49c2d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -745,6 +745,66 @@ let record = CKRecord(recordType: "foo", recordID: CKRecord.ID(recordName: "bar")) try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() } + + /* + * Local client has sync'd record. + * Local record is edited outside the sync engine (somehow) + * New record received from iCloud + => Server record overwrites local changes + */ + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func serverRecordOverwritesLocalChangesWhenNoPendingChanges() async throws { + try await userDatabase.userWrite { db in + try db.seed { + RemindersList(id: 1, title: "Personal") + } + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + try await userDatabase.write { db in + try RemindersList.update { $0.title = "My stuff" }.execute(db) + } + + try await withDependencies { + $0.currentTime.now += 1 + } operation: { + let remindersListRecord = try syncEngine.private.database.record( + for: RemindersList.recordID(for: 1) + ) + remindersListRecord.setValue("Personal!", forKey: "title", at: now) + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() + } + + try await userDatabase.read { db in + expectNoDifference( + try RemindersList.fetchAll(db), + [RemindersList(id: 1, title: "Personal!")] + ) + } + assertInlineSnapshot(of: syncEngine.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: "Personal!" + ) + ] + ), + sharedCloudDatabase: MockCloudDatabase( + databaseScope: .shared, + storage: [] + ) + ) + """ + } + } } } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift index 10dffa67..1a30bcd7 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift @@ -43,9 +43,8 @@ } } - @Test @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - func delete() async throws { + @Test func delete() async throws { @FetchAll(RemindersList.all, database: userDatabase.database) var remindersLists try await userDatabase.userWrite { db in diff --git a/Tests/SQLiteDataTests/DateTests.swift b/Tests/SQLiteDataTests/DateTests.swift new file mode 100644 index 00000000..5415a916 --- /dev/null +++ b/Tests/SQLiteDataTests/DateTests.swift @@ -0,0 +1,51 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import SQLiteDataTestSupport +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) +struct DateTests { + @Dependency(\.defaultDatabase) var database + + @Test func roundtrip() throws { + let date = Date(timeIntervalSinceReferenceDate: 793109282.061) + let insertedRecord = try database.write { db in + try Record.insert { Record.Draft(date: date) } + .returning(\.self) + .fetchOne(db)! + } + let updatedRecord = try database.write { db in + try Record + .update(insertedRecord) + .returning(\.self) + .fetchOne(db)! + } + #expect(insertedRecord.date == date) + #expect(insertedRecord.date == updatedRecord.date) + } +} + +@Table +private struct Record: Equatable { + let id: Int + var date: Date +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "date" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + return database + } +} From 9f10711b2b70e5a5e63a0604559c69300a3a21bf Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 17:31:00 -0600 Subject: [PATCH 2/5] add test for bad representation --- .../CloudKit/CloudKit+StructuredQueries.swift | 10 +++ .../FetchRecordZoneChangesTests.swift | 66 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index d4ddeb5b..b585df9a 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -324,6 +324,16 @@ return false } } + if isRowValueModified { + print("!?!?!?!?!") + } + if !syncEngineHasPendingChanges && isRowValueModified { + reportIssue(""" + Roundtrip error detected for '\(T.tableName).\(key)'. The value that was decoded \ + from SQLite does not match the value that was encoded to SQLite. If you are using \ + custom representable SQLite types, make sure their encoding and decoding roundtrip. + """) + } if didSet || (syncEngineHasPendingChanges && isRowValueModified) { columnNames.removeAll(where: { $0 == key }) if didSet, let parentForeignKey, key == parentForeignKey.from { diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 8fa49c2d..97c876a4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -772,7 +772,8 @@ for: RemindersList.recordID(for: 1) ) remindersListRecord.setValue("Personal!", forKey: "title", at: now) - try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify() + try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) + .notify() } try await userDatabase.read { db in @@ -805,6 +806,69 @@ """ } } + + @Test func badRepresentation() async throws { + let database = UserDatabase(database: try SQLiteData.defaultDatabase()) + try await database.write { db in + try #sql(""" + CREATE TABLE "badTables" ( + "id" INT PRIMARY KEY NOT NULL, + "value" INTEGER NOT NULL DEFAULT 0, + "title" TEXT NOT NULL DEFAULT '' + ) + """) + .execute(db) + } + let syncEngine = try await SyncEngine( + container: self.syncEngine.container, + userDatabase: database, + tables: BadTable.self + ) + try await syncEngine.processPendingDatabaseChanges(scope: .private) + try await database.userWrite { db in + try BadTable.insert { BadTable.Draft(id: 0) }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + let record = try syncEngine.private.database.record(for: BadTable.recordID(for: 0)) + record.setValue(1, forKey: "value", at: 1) + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + + do { + let record = try syncEngine.private.database.record(for: BadTable.recordID(for: 0)) + record.setValue(2, forKey: "value", at: 2) + await withKnownIssue { + try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() + } matching: { issue in + issue.description.hasSuffix(""" + Roundtrip error detected for 'badTables.value'. The value that was decoded from \ + SQLite does not match the value that was encoded to SQLite. If you are using custom \ + representable SQLite types, make sure their encoding and decoding roundtrip. + """) + } + } + } + } + } + + @Table struct BadTable { + let id: Int + @Column(as: Int.BadRepresentation.self) + var value = 0 + var title = "" + } + + extension Int { + struct BadRepresentation: QueryBindable, QueryDecodable, QueryRepresentable { + var queryBinding: StructuredQueriesCore.QueryBinding { + .int(Int64(queryOutput)) + } + init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { + queryOutput = try Int(decoder: &decoder) + 1 + } + let queryOutput: Int + init(queryOutput: Int) { + self.queryOutput = queryOutput + } } } #endif From 3c2cac2f316738ae95d8bd9bee2ebb4e58e983a0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 17:39:44 -0600 Subject: [PATCH 3/5] back out of pending changes stuff --- .../CloudKit/CloudKit+StructuredQueries.swift | 15 +---- Sources/SQLiteData/CloudKit/SyncEngine.swift | 14 +---- .../FetchRecordZoneChangesTests.swift | 63 ------------------- 3 files changed, 3 insertions(+), 89 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index b585df9a..2c64af7b 100644 --- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift +++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift @@ -278,8 +278,7 @@ with other: CKRecord, row: T, columnNames: inout [String], - parentForeignKey: ForeignKey?, - syncEngineHasPendingChanges: Bool + parentForeignKey: ForeignKey? ) { typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable @@ -324,17 +323,7 @@ return false } } - if isRowValueModified { - print("!?!?!?!?!") - } - if !syncEngineHasPendingChanges && isRowValueModified { - reportIssue(""" - Roundtrip error detected for '\(T.tableName).\(key)'. The value that was decoded \ - from SQLite does not match the value that was encoded to SQLite. If you are using \ - custom representable SQLite types, make sure their encoding and decoding roundtrip. - """) - } - if didSet || (syncEngineHasPendingChanges && isRowValueModified) { + if didSet || isRowValueModified { columnNames.removeAll(where: { $0 == key }) if didSet, let parentForeignKey, key == parentForeignKey.from { self.parent = other.parent diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index b1b16507..c3089977 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1941,19 +1941,7 @@ columnNames: &columnNames, parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1 ? foreignKeysByTableName[T.tableName]?.first - : nil, - syncEngineHasPendingChanges: syncEngine(for: serverRecord.recordID.zoneID)?.state - .pendingRecordZoneChanges.contains { - switch $0 { - case .saveRecord(let recordID): - return recordID == serverRecord.recordID - case .deleteRecord: - return false - @unknown default: - return false - } - } - ?? false + : nil ) } diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 97c876a4..fb6a3e26 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -806,69 +806,6 @@ """ } } - - @Test func badRepresentation() async throws { - let database = UserDatabase(database: try SQLiteData.defaultDatabase()) - try await database.write { db in - try #sql(""" - CREATE TABLE "badTables" ( - "id" INT PRIMARY KEY NOT NULL, - "value" INTEGER NOT NULL DEFAULT 0, - "title" TEXT NOT NULL DEFAULT '' - ) - """) - .execute(db) - } - let syncEngine = try await SyncEngine( - container: self.syncEngine.container, - userDatabase: database, - tables: BadTable.self - ) - try await syncEngine.processPendingDatabaseChanges(scope: .private) - try await database.userWrite { db in - try BadTable.insert { BadTable.Draft(id: 0) }.execute(db) - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - let record = try syncEngine.private.database.record(for: BadTable.recordID(for: 0)) - record.setValue(1, forKey: "value", at: 1) - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - - do { - let record = try syncEngine.private.database.record(for: BadTable.recordID(for: 0)) - record.setValue(2, forKey: "value", at: 2) - await withKnownIssue { - try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() - } matching: { issue in - issue.description.hasSuffix(""" - Roundtrip error detected for 'badTables.value'. The value that was decoded from \ - SQLite does not match the value that was encoded to SQLite. If you are using custom \ - representable SQLite types, make sure their encoding and decoding roundtrip. - """) - } - } - } - } - } - - @Table struct BadTable { - let id: Int - @Column(as: Int.BadRepresentation.self) - var value = 0 - var title = "" - } - - extension Int { - struct BadRepresentation: QueryBindable, QueryDecodable, QueryRepresentable { - var queryBinding: StructuredQueriesCore.QueryBinding { - .int(Int64(queryOutput)) - } - init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws { - queryOutput = try Int(decoder: &decoder) + 1 - } - let queryOutput: Int - init(queryOutput: Int) { - self.queryOutput = queryOutput - } } } #endif From f2af2963e6aa1ed79d8f14f6b1490dabd3076cd8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 17:45:17 -0600 Subject: [PATCH 4/5] clean up --- Sources/SQLiteData/CloudKit/SyncEngine.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 57f420c3..ec6c5455 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -831,14 +831,14 @@ } return } - syncEngine(for: oldZoneID)?.state.add(pendingRecordZoneChanges: oldChanges) - syncEngine(for: zoneID)?.state.add(pendingRecordZoneChanges: newChanges) - } - - fileprivate func syncEngine(for zoneID: CKRecordZone.ID) -> (any SyncEngineProtocol)? { - syncEngines.withValue { + let oldSyncEngine = self.syncEngines.withValue { + oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared + } + let syncEngine = self.syncEngines.withValue { zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared } + oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges) + syncEngine?.state.add(pendingRecordZoneChanges: newChanges) } @DatabaseFunction( @@ -2047,8 +2047,7 @@ if data == nil { reportIssue("Asset data not found on disk") } - return - "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" + return "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)" } else { return """ \(quote: columnName) = \ From 1cdc2d6a4afa482a308aa8139cd76303d4c2d6d9 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Jan 2026 17:45:38 -0600 Subject: [PATCH 5/5] wip --- .../FetchRecordZoneChangesTests.swift | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift index 725042b0..d0a82cb3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/FetchRecordZoneChangesTests.swift @@ -745,67 +745,6 @@ let record = CKRecord(recordType: "foo", recordID: CKRecord.ID(recordName: "bar")) try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify() } - - /* - * Local client has sync'd record. - * Local record is edited outside the sync engine (somehow) - * New record received from iCloud - => Server record overwrites local changes - */ - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func serverRecordOverwritesLocalChangesWhenNoPendingChanges() async throws { - try await userDatabase.userWrite { db in - try db.seed { - RemindersList(id: 1, title: "Personal") - } - } - try await syncEngine.processPendingRecordZoneChanges(scope: .private) - - try await userDatabase.write { db in - try RemindersList.update { $0.title = "My stuff" }.execute(db) - } - - try await withDependencies { - $0.currentTime.now += 1 - } operation: { - let remindersListRecord = try syncEngine.private.database.record( - for: RemindersList.recordID(for: 1) - ) - remindersListRecord.setValue("Personal!", forKey: "title", at: now) - try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]) - .notify() - } - - try await userDatabase.read { db in - expectNoDifference( - try RemindersList.fetchAll(db), - [RemindersList(id: 1, title: "Personal!")] - ) - } - assertInlineSnapshot(of: syncEngine.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: "Personal!" - ) - ] - ), - sharedCloudDatabase: MockCloudDatabase( - databaseScope: .shared, - storage: [] - ) - ) - """ - } - } } } #endif