From df29b11a87e9356fe2779760efed26725c27e3a6 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 8 Jan 2026 16:36:07 +0100 Subject: [PATCH 01/22] Initial draft --- .../ConflictResolutionPlayground.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift new file mode 100644 index 00000000..f9917b95 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -0,0 +1,55 @@ +#if canImport(CloudKit) + import CloudKit + import Foundation + import SQLiteData + import Testing + + struct RowVersion { + let row: T + + init( + clientRow row: T, + userModificationTime: Int64, + ancestorVersion: RowVersion + ) { + self.row = row + fatalError("Not implemented") + } + + init(from record: CKRecord) throws { + fatalError("Not implemented") + } + + func modificationDate(for column: PartialKeyPath) -> Int64 { + fatalError("Not implemented") + } + } + + struct MergeConflict { + let ancestor: RowVersion + let client: RowVersion + let server: RowVersion + } + + extension MergeConflict { + func mergedValue( + for keyPath: some KeyPath + ) -> V { + fatalError("Not implemented") + } + } + + @Table + private struct Counter { + let id: Int + var title: String + var count: Int + } + + @Suite + struct ConflictResolutionPlaygroundTests { + @Test + func placeholder() { + } + } +#endif From 776a1ee094b6f05a48ef08e9806af7685c4e7d6f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 8 Jan 2026 17:00:38 +0100 Subject: [PATCH 02/22] Add modification times handling and implement client row initializer --- .../ConflictResolutionPlayground.swift | 87 +++++++++++++++++-- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index f9917b95..70bf3fe8 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -6,25 +6,68 @@ struct RowVersion { let row: T - + private let modificationTimes: [PartialKeyPath: Int64] + + package init( + row: T, + modificationTimes: [PartialKeyPath: Int64] + ) { + self.row = row + self.modificationTimes = modificationTimes + } + init( clientRow row: T, userModificationTime: Int64, ancestorVersion: RowVersion ) { - self.row = row - fatalError("Not implemented") + var modificationTimes: [PartialKeyPath: Int64] = [:] + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let keyPath = column.keyPath as! KeyPath + + let clientValue = row[keyPath: keyPath] + let ancestorValue = ancestorVersion.row[keyPath: keyPath] + + if areEqual(clientValue, ancestorValue) { + modificationTimes[keyPath] = ancestorVersion.modificationTime(for: keyPath) + } else { + modificationTimes[keyPath] = userModificationTime + } + } + open(column) + } + + self.init( + row: row, + modificationTimes: modificationTimes + ) } init(from record: CKRecord) throws { fatalError("Not implemented") } - func modificationDate(for column: PartialKeyPath) -> Int64 { - fatalError("Not implemented") + func modificationTime(for column: PartialKeyPath) -> Int64 { + return modificationTimes[column] ?? -1 } } + private func areEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard + let lhs = lhs as? any Equatable, + let rhs = rhs as? any Equatable + else { + return false + } + + func open(_ lhs: E, _ rhs: Any) -> Bool { + guard let rhs = rhs as? E else { return false } + return lhs == rhs + } + return open(lhs, rhs) + } + struct MergeConflict { let ancestor: RowVersion let client: RowVersion @@ -41,7 +84,7 @@ @Table private struct Counter { - let id: Int + let id: UUID var title: String var count: Int } @@ -49,7 +92,37 @@ @Suite struct ConflictResolutionPlaygroundTests { @Test - func placeholder() { + func init_rowAndModificationTimes() { + let version = RowVersion( + row: Counter(id: UUID(0), title: "MyCounter", count: 0), + modificationTimes: [ + \.title: 100, + \.count: 0 + ] + ) + + #expect(version.modificationTime(for: \.title) == 100) + #expect(version.modificationTime(for: \.count) == 0) + } + + @Test + func init_clientRow() { + let ancestor = RowVersion( + row: Counter(id: UUID(0), title: "", count: 0), + modificationTimes: [ + \.title: 50, + \.count: 50 + ] + ) + + let client = RowVersion( + clientRow: Counter(id: UUID(0), title: "My Counter", count: 0), + userModificationTime: 100, + ancestorVersion: ancestor + ) + + #expect(client.modificationTime(for: \.title) == 100) + #expect(client.modificationTime(for: \.count) == 50) } } #endif From 2c26d558f406e98b46d1c6f76845b9796601dda9 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 11:32:22 +0100 Subject: [PATCH 03/22] Value equality check now operates on QueryBinding --- .../ConflictResolutionPlayground.swift | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 70bf3fe8..00695f34 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -1,5 +1,6 @@ #if canImport(CloudKit) import CloudKit + import CryptoKit import Foundation import SQLiteData import Testing @@ -24,12 +25,12 @@ var modificationTimes: [PartialKeyPath: Int64] = [:] for column in T.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { - let keyPath = column.keyPath as! KeyPath + let keyPath = column.keyPath as! KeyPath let clientValue = row[keyPath: keyPath] let ancestorValue = ancestorVersion.row[keyPath: keyPath] - if areEqual(clientValue, ancestorValue) { + if areEqual(clientValue, ancestorValue, as: Value.self) { modificationTimes[keyPath] = ancestorVersion.modificationTime(for: keyPath) } else { modificationTimes[keyPath] = userModificationTime @@ -53,21 +54,6 @@ } } - private func areEqual(_ lhs: Any, _ rhs: Any) -> Bool { - guard - let lhs = lhs as? any Equatable, - let rhs = rhs as? any Equatable - else { - return false - } - - func open(_ lhs: E, _ rhs: Any) -> Bool { - guard let rhs = rhs as? E else { return false } - return lhs == rhs - } - return open(lhs, rhs) - } - struct MergeConflict { let ancestor: RowVersion let client: RowVersion @@ -82,6 +68,50 @@ } } + /// Compares values using their database representation (`QueryBinding`), which eliminates + /// the need for `Equatable` conformance and efficiently handles special cases. + fileprivate func areEqual( + _ lhs: Value.QueryOutput, + _ rhs: Value.QueryOutput, + as: Value.Type + ) -> Bool { + let lhsBinding = Value(queryOutput: lhs).queryBinding + let rhsBinding = Value(queryOutput: rhs).queryBinding + + switch (lhsBinding, rhsBinding) { + case (.blob(let lhsValue), .blob(let rhsValue)): + return lhsValue.sha256 == rhsValue.sha256 + case (.bool(let lhsValue), .bool(let rhsValue)): + return lhsValue == rhsValue + case (.double(let lhsValue), .double(let rhsValue)): + return lhsValue == rhsValue + case (.date(let lhsValue), .date(let rhsValue)): + return lhsValue == rhsValue + case (.int(let lhsValue), .int(let rhsValue)): + return lhsValue == rhsValue + case (.null, .null): + return true + case (.text(let lhsValue), .text(let rhsValue)): + return lhsValue == rhsValue + case (.uint(let lhsValue), .uint(let rhsValue)): + return lhsValue == rhsValue + case (.uuid(let lhsValue), .uuid(let rhsValue)): + // TODO: Can't we compare the UUID instances directly? + return lhsValue.uuidString.lowercased() == rhsValue.uuidString.lowercased() + case (.invalid(let error), _), (_, .invalid(let error)): + reportIssue(error) + return false + default: + return false + } + } + + extension DataProtocol { + fileprivate var sha256: Data { + Data(SHA256.hash(data: self)) + } + } + @Table private struct Counter { let id: UUID From de8d2d55908e6e7b2163f67695b473daa995e524 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 10:01:25 +0100 Subject: [PATCH 04/22] Initial take on `MergeConflict.mergedValue(for:)` --- .../ConflictResolutionPlayground.swift | 137 +++++++++++++++++- 1 file changed, 130 insertions(+), 7 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 00695f34..e5cb7d69 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -61,10 +61,36 @@ } extension MergeConflict { - func mergedValue( - for keyPath: some KeyPath - ) -> V { - fatalError("Not implemented") + func mergedValue( + for keyPath: some KeyPath + ) -> Column.QueryValue.QueryOutput + where Column.Root == T { + let column = T.columns[keyPath: keyPath] + let rowKeyPath = column.keyPath + + let ancestorValue = ancestor.row[keyPath: rowKeyPath] + let clientValue = client.row[keyPath: rowKeyPath] + let serverValue = server.row[keyPath: rowKeyPath] + + let clientChanged = !areEqual(ancestorValue, clientValue, as: Column.QueryValue.self) + let serverChanged = !areEqual(ancestorValue, serverValue, as: Column.QueryValue.self) + + switch (clientChanged, serverChanged) { + case (false, false): + return clientValue + case (true, false): + return clientValue + case (false, true): + return serverValue + case (true, true): + if areEqual(clientValue, serverValue, as: Column.QueryValue.self) { + return clientValue + } + + let clientTime = client.modificationTime(for: rowKeyPath) + let serverTime = server.modificationTime(for: rowKeyPath) + return serverTime > clientTime ? serverValue : clientValue + } } } @@ -119,12 +145,24 @@ var count: Int } + @Table + private struct MergeExample { + let id: UUID + var field1: String + var field2: String + var field3: String + var field4: String + var field5: String + var field6: String + var field7: String + } + @Suite struct ConflictResolutionPlaygroundTests { @Test - func init_rowAndModificationTimes() { + func versionInit_rowAndModificationTimes() { let version = RowVersion( - row: Counter(id: UUID(0), title: "MyCounter", count: 0), + row: Counter(id: UUID(0), title: "My Counter", count: 0), modificationTimes: [ \.title: 100, \.count: 0 @@ -136,7 +174,7 @@ } @Test - func init_clientRow() { + func versionInit_clientRow() { let ancestor = RowVersion( row: Counter(id: UUID(0), title: "", count: 0), modificationTimes: [ @@ -154,5 +192,90 @@ #expect(client.modificationTime(for: \.title) == 100) #expect(client.modificationTime(for: \.count) == 50) } + + /// Tests the field-wise last edit wins strategy with all seven merge scenarios. + /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md + @Test + func mergeConflict_fieldWiseLastEditWins() { + let ancestor = RowVersion( + row: MergeExample( + id: UUID(0), + field1: "foo", + field2: "foo", + field3: "foo", + field4: "foo", + field5: "foo", + field6: "foo", + field7: "foo" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 0, + \.field4: 0, + \.field5: 0, + \.field6: 0, + \.field7: 0 + ] + ) + + let client = RowVersion( + row: MergeExample( + id: UUID(0), + field1: "foo", + field2: "bar", + field3: "foo", + field4: "bar", + field5: "bar", + field6: "bar", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 100, + \.field3: 0, + \.field4: 100, + \.field5: 100, + \.field6: 100, + \.field7: 100 + ] + ) + + let server = RowVersion( + row: MergeExample( + id: UUID(0), + field1: "foo", + field2: "foo", + field3: "baz", + field4: "baz", + field5: "baz", + field6: "baz", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 200, + \.field4: 200, + \.field5: 50, + \.field6: 100, + \.field7: 200 + ] + ) + + let conflict = MergeConflict( + ancestor: ancestor, + client: client, + server: server + ) + + #expect(conflict.mergedValue(for: \.field1) == "foo") + #expect(conflict.mergedValue(for: \.field2) == "bar") + #expect(conflict.mergedValue(for: \.field3) == "baz") + #expect(conflict.mergedValue(for: \.field4) == "baz") + #expect(conflict.mergedValue(for: \.field5) == "bar") + #expect(conflict.mergedValue(for: \.field6) == "bar") + #expect(conflict.mergedValue(for: \.field7) == "bar") + } } #endif From 316b07f2c9aa7d054eabd049148ccea90cc8053a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 10:23:28 +0100 Subject: [PATCH 05/22] Change table used in tests --- .../ConflictResolutionPlayground.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index e5cb7d69..ba7534a2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -139,10 +139,12 @@ } @Table - private struct Counter { + private struct Todo { let id: UUID var title: String - var count: Int + var isCompleted: Bool + @Column(as: Set.JSONRepresentation.self) + var tags: Set = [] } @Table @@ -162,35 +164,37 @@ @Test func versionInit_rowAndModificationTimes() { let version = RowVersion( - row: Counter(id: UUID(0), title: "My Counter", count: 0), + row: Todo(id: UUID(0), title: "Buy milk", isCompleted: false), modificationTimes: [ \.title: 100, - \.count: 0 + \.isCompleted: 0, + \.tags: 0 ] ) - + #expect(version.modificationTime(for: \.title) == 100) - #expect(version.modificationTime(for: \.count) == 0) + #expect(version.modificationTime(for: \.isCompleted) == 0) } @Test func versionInit_clientRow() { let ancestor = RowVersion( - row: Counter(id: UUID(0), title: "", count: 0), + row: Todo(id: UUID(0), title: "", isCompleted: false), modificationTimes: [ \.title: 50, - \.count: 50 + \.isCompleted: 50, + \.tags: 0 ] ) let client = RowVersion( - clientRow: Counter(id: UUID(0), title: "My Counter", count: 0), + clientRow: Todo(id: UUID(0), title: "Buy milk", isCompleted: false), userModificationTime: 100, ancestorVersion: ancestor ) #expect(client.modificationTime(for: \.title) == 100) - #expect(client.modificationTime(for: \.count) == 50) + #expect(client.modificationTime(for: \.isCompleted) == 50) } /// Tests the field-wise last edit wins strategy with all seven merge scenarios. From da8736532737c75e2cde6d37cac101b87872659e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 10:23:07 +0100 Subject: [PATCH 06/22] Add test for conflict resolution with custom representations --- .../ConflictResolutionPlayground.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index ba7534a2..0873d59a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -281,5 +281,49 @@ #expect(conflict.mergedValue(for: \.field6) == "bar") #expect(conflict.mergedValue(for: \.field7) == "bar") } + + @Test + func mergeConflict_customRepresentation() { + let ancestor = RowVersion( + row: Todo( + id: UUID(0), + title: "Task", + isCompleted: false, + tags: ["work"] + ), + modificationTimes: [\.tags: 100] + ) + + let client = RowVersion( + row: Todo( + id: UUID(0), + title: "Task", + isCompleted: false, + tags: ["work", "urgent"] + ), + modificationTimes: [\.tags: 200] + ) + + let server = RowVersion( + row: Todo( + id: UUID(0), + title: "Task", + isCompleted: false, + tags: ["work", "personal"] + ), + modificationTimes: [\.tags: 150] + ) + + let conflict = MergeConflict( + ancestor: ancestor, + client: client, + server: server + ) + + // - `QueryValue`: `Set.JSONRepresentation` (the storage type) + // - `QueryOutput`: `Set` (the Swift type) + // - `QueryBinding`: `.text(…)` (the JSON serialized representation) + #expect(conflict.mergedValue(for: \.tags) == ["work", "urgent"]) + } } #endif From 72ed0dd6408424017211de912f1a3815edb1d733 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 10:50:14 +0100 Subject: [PATCH 07/22] Introduce notion of field merge policy --- .../ConflictResolutionPlayground.swift | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 0873d59a..c4911842 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -62,7 +62,8 @@ extension MergeConflict { func mergedValue( - for keyPath: some KeyPath + for keyPath: some KeyPath, + policy: FieldMergePolicy ) -> Column.QueryValue.QueryOutput where Column.Root == T { let column = T.columns[keyPath: keyPath] @@ -83,13 +84,42 @@ case (false, true): return serverValue case (true, true): - if areEqual(clientValue, serverValue, as: Column.QueryValue.self) { - return clientValue - } + let ancestorVersion = FieldVersion( + value: Column.QueryValue(queryOutput: ancestorValue), + modificationTime: ancestor.modificationTime(for: rowKeyPath) + ) + let clientVersion = FieldVersion( + value: Column.QueryValue(queryOutput: clientValue), + modificationTime: client.modificationTime(for: rowKeyPath) + ) + let serverVersion = FieldVersion( + value: Column.QueryValue(queryOutput: serverValue), + modificationTime: server.modificationTime(for: rowKeyPath) + ) + + let resolved = policy.resolve(ancestorVersion, clientVersion, serverVersion) + return resolved.queryOutput + } + } + } + + struct FieldVersion { + let value: Value + let modificationTime: Int64 + } + + struct FieldMergePolicy { + let resolve: ( + _ ancestor: FieldVersion, + _ client: FieldVersion, + _ server: FieldVersion + ) -> Value + } - let clientTime = client.modificationTime(for: rowKeyPath) - let serverTime = server.modificationTime(for: rowKeyPath) - return serverTime > clientTime ? serverValue : clientValue + extension FieldMergePolicy { + static var latest: Self { + Self { _, client, server in + server.modificationTime > client.modificationTime ? server.value : client.value } } } @@ -200,7 +230,7 @@ /// Tests the field-wise last edit wins strategy with all seven merge scenarios. /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md @Test - func mergeConflict_fieldWiseLastEditWins() { + func mergedValue_latestPolicy() { let ancestor = RowVersion( row: MergeExample( id: UUID(0), @@ -273,17 +303,17 @@ server: server ) - #expect(conflict.mergedValue(for: \.field1) == "foo") - #expect(conflict.mergedValue(for: \.field2) == "bar") - #expect(conflict.mergedValue(for: \.field3) == "baz") - #expect(conflict.mergedValue(for: \.field4) == "baz") - #expect(conflict.mergedValue(for: \.field5) == "bar") - #expect(conflict.mergedValue(for: \.field6) == "bar") - #expect(conflict.mergedValue(for: \.field7) == "bar") + #expect(conflict.mergedValue(for: \.field1, policy: .latest) == "foo") + #expect(conflict.mergedValue(for: \.field2, policy: .latest) == "bar") + #expect(conflict.mergedValue(for: \.field3, policy: .latest) == "baz") + #expect(conflict.mergedValue(for: \.field4, policy: .latest) == "baz") + #expect(conflict.mergedValue(for: \.field5, policy: .latest) == "bar") + #expect(conflict.mergedValue(for: \.field6, policy: .latest) == "bar") + #expect(conflict.mergedValue(for: \.field7, policy: .latest) == "bar") } - + @Test - func mergeConflict_customRepresentation() { + func mergedValue_customRepresentation() { let ancestor = RowVersion( row: Todo( id: UUID(0), @@ -319,11 +349,11 @@ client: client, server: server ) - + // - `QueryValue`: `Set.JSONRepresentation` (the storage type) // - `QueryOutput`: `Set` (the Swift type) // - `QueryBinding`: `.text(…)` (the JSON serialized representation) - #expect(conflict.mergedValue(for: \.tags) == ["work", "urgent"]) + #expect(conflict.mergedValue(for: \.tags, policy: .latest) == ["work", "urgent"]) } } #endif From 83cd9ae9114fdf6306e75646e0bf158fbe27d2a6 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 11:02:06 +0100 Subject: [PATCH 08/22] Change table used in tests once again --- .../ConflictResolutionPlayground.swift | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index c4911842..41e599e8 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -169,10 +169,10 @@ } @Table - private struct Todo { + private struct Post { let id: UUID var title: String - var isCompleted: Bool + var upvotes = 0 @Column(as: Set.JSONRepresentation.self) var tags: Set = [] } @@ -194,37 +194,39 @@ @Test func versionInit_rowAndModificationTimes() { let version = RowVersion( - row: Todo(id: UUID(0), title: "Buy milk", isCompleted: false), + row: Post(id: UUID(0), title: "My Post"), modificationTimes: [ \.title: 100, - \.isCompleted: 0, + \.upvotes: 0, \.tags: 0 ] ) #expect(version.modificationTime(for: \.title) == 100) - #expect(version.modificationTime(for: \.isCompleted) == 0) + #expect(version.modificationTime(for: \.upvotes) == 0) + #expect(version.modificationTime(for: \.tags) == 0) } @Test func versionInit_clientRow() { let ancestor = RowVersion( - row: Todo(id: UUID(0), title: "", isCompleted: false), + row: Post(id: UUID(0), title: ""), modificationTimes: [ \.title: 50, - \.isCompleted: 50, - \.tags: 0 + \.upvotes: 50, + \.tags: 50 ] ) let client = RowVersion( - clientRow: Todo(id: UUID(0), title: "Buy milk", isCompleted: false), + clientRow: Post(id: UUID(0), title: "My Post"), userModificationTime: 100, ancestorVersion: ancestor ) #expect(client.modificationTime(for: \.title) == 100) - #expect(client.modificationTime(for: \.isCompleted) == 50) + #expect(client.modificationTime(for: \.upvotes) == 50) + #expect(client.modificationTime(for: \.tags) == 50) } /// Tests the field-wise last edit wins strategy with all seven merge scenarios. @@ -313,37 +315,34 @@ } @Test - func mergedValue_customRepresentation() { + func mergedValue_differentPoliciesAndCustomRepresentation() { let ancestor = RowVersion( - row: Todo( + row: Post( id: UUID(0), - title: "Task", - isCompleted: false, - tags: ["work"] + title: "My Post", + tags: ["hobby"] ), modificationTimes: [\.tags: 100] ) - + let client = RowVersion( - row: Todo( + row: Post( id: UUID(0), - title: "Task", - isCompleted: false, - tags: ["work", "urgent"] + title: "My Post", + tags: ["hobby", "tech"] ), modificationTimes: [\.tags: 200] ) - + let server = RowVersion( - row: Todo( + row: Post( id: UUID(0), - title: "Task", - isCompleted: false, - tags: ["work", "personal"] + title: "My Post", + tags: ["hobby", "photography"] ), modificationTimes: [\.tags: 150] ) - + let conflict = MergeConflict( ancestor: ancestor, client: client, @@ -353,7 +352,7 @@ // - `QueryValue`: `Set.JSONRepresentation` (the storage type) // - `QueryOutput`: `Set` (the Swift type) // - `QueryBinding`: `.text(…)` (the JSON serialized representation) - #expect(conflict.mergedValue(for: \.tags, policy: .latest) == ["work", "urgent"]) + #expect(conflict.mergedValue(for: \.tags, policy: .latest) == ["hobby", "tech"]) } } #endif From aeb61df56ae49ba789ed2de106e1416dc10114b1 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 11:13:06 +0100 Subject: [PATCH 09/22] =?UTF-8?q?Introduce=20=E2=80=9Ccounter=E2=80=9D=20f?= =?UTF-8?q?ield=20merge=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConflictResolutionPlayground.swift | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 41e599e8..cd8c0f0a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -124,6 +124,16 @@ } } + extension FieldMergePolicy where Value: BinaryInteger { + static var counter: Self { + Self { ancestor, client, server in + ancestor.value + + (client.value - ancestor.value) + + (server.value - ancestor.value) + } + } + } + /// Compares values using their database representation (`QueryBinding`), which eliminates /// the need for `Equatable` conformance and efficiently handles special cases. fileprivate func areEqual( @@ -320,27 +330,42 @@ row: Post( id: UUID(0), title: "My Post", + upvotes: 0, tags: ["hobby"] ), - modificationTimes: [\.tags: 100] + modificationTimes: [ + \.title: 0, + \.upvotes: 0, + \.tags: 0 + ] ) let client = RowVersion( row: Post( id: UUID(0), - title: "My Post", + title: "My Great Post", + upvotes: 2, tags: ["hobby", "tech"] ), - modificationTimes: [\.tags: 200] + modificationTimes: [ + \.title: 100, + \.upvotes: 100, + \.tags: 100 + ] ) let server = RowVersion( row: Post( id: UUID(0), - title: "My Post", + title: "My Awesome Post", + upvotes: 3, tags: ["hobby", "photography"] ), - modificationTimes: [\.tags: 150] + modificationTimes: [ + \.title: 50, + \.upvotes: 50, + \.tags: 50 + ] ) let conflict = MergeConflict( @@ -349,6 +374,9 @@ server: server ) + #expect(conflict.mergedValue(for: \.title, policy: .latest) == "My Great Post") + #expect(conflict.mergedValue(for: \.upvotes, policy: .counter) == 5) + // - `QueryValue`: `Set.JSONRepresentation` (the storage type) // - `QueryOutput`: `Set` (the Swift type) // - `QueryBinding`: `.text(…)` (the JSON serialized representation) From afcc7836afe35b506343b5f7db9345d78a251920 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 9 Jan 2026 11:31:30 +0100 Subject: [PATCH 10/22] =?UTF-8?q?Introduce=20=E2=80=9Cset=E2=80=9D=20field?= =?UTF-8?q?=20merge=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConflictResolutionPlayground.swift | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index cd8c0f0a..1f74f1d3 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -63,7 +63,7 @@ extension MergeConflict { func mergedValue( for keyPath: some KeyPath, - policy: FieldMergePolicy + policy: FieldMergePolicy ) -> Column.QueryValue.QueryOutput where Column.Root == T { let column = T.columns[keyPath: keyPath] @@ -85,30 +85,29 @@ return serverValue case (true, true): let ancestorVersion = FieldVersion( - value: Column.QueryValue(queryOutput: ancestorValue), + value: ancestorValue, modificationTime: ancestor.modificationTime(for: rowKeyPath) ) let clientVersion = FieldVersion( - value: Column.QueryValue(queryOutput: clientValue), + value: clientValue, modificationTime: client.modificationTime(for: rowKeyPath) ) let serverVersion = FieldVersion( - value: Column.QueryValue(queryOutput: serverValue), + value: serverValue, modificationTime: server.modificationTime(for: rowKeyPath) ) - let resolved = policy.resolve(ancestorVersion, clientVersion, serverVersion) - return resolved.queryOutput + return policy.resolve(ancestorVersion, clientVersion, serverVersion) } } } - struct FieldVersion { + struct FieldVersion { let value: Value let modificationTime: Int64 } - struct FieldMergePolicy { + struct FieldMergePolicy { let resolve: ( _ ancestor: FieldVersion, _ client: FieldVersion, @@ -134,6 +133,23 @@ } } + extension FieldMergePolicy where Value: SetAlgebra, Value.Element: Equatable { + static var set: Self { + Self { ancestor, client, server in + let notDeleted = ancestor.value + .intersection(client.value) + .intersection(server.value) + + let addedByClient = client.value.subtracting(ancestor.value) + let addedByServer = server.value.subtracting(ancestor.value) + + return notDeleted + .union(addedByClient) + .union(addedByServer) + } + } + } + /// Compares values using their database representation (`QueryBinding`), which eliminates /// the need for `Equatable` conformance and efficiently handles special cases. fileprivate func areEqual( @@ -331,7 +347,7 @@ id: UUID(0), title: "My Post", upvotes: 0, - tags: ["hobby"] + tags: ["hobby", "travel"] ), modificationTimes: [ \.title: 0, @@ -345,7 +361,7 @@ id: UUID(0), title: "My Great Post", upvotes: 2, - tags: ["hobby", "tech"] + tags: ["hobby", "travel", "photography"] ), modificationTimes: [ \.title: 100, @@ -359,7 +375,7 @@ id: UUID(0), title: "My Awesome Post", upvotes: 3, - tags: ["hobby", "photography"] + tags: ["hobby", "tech"] ), modificationTimes: [ \.title: 50, @@ -380,7 +396,7 @@ // - `QueryValue`: `Set.JSONRepresentation` (the storage type) // - `QueryOutput`: `Set` (the Swift type) // - `QueryBinding`: `.text(…)` (the JSON serialized representation) - #expect(conflict.mergedValue(for: \.tags, policy: .latest) == ["hobby", "tech"]) + #expect(conflict.mergedValue(for: \.tags, policy: .set) == ["hobby", "photography", "tech"]) } } #endif From 949e1f0324fb83c04da707dc105ae8fc1f5d589a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sun, 11 Jan 2026 07:06:09 +0100 Subject: [PATCH 11/22] =?UTF-8?q?Tweak=20`MergeConflict.mergedValue(?= =?UTF-8?q?=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConflictResolutionPlayground.swift | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 1f74f1d3..bc11175f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -64,21 +64,28 @@ func mergedValue( for keyPath: some KeyPath, policy: FieldMergePolicy - ) -> Column.QueryValue.QueryOutput - where Column.Root == T { - let column = T.columns[keyPath: keyPath] - let rowKeyPath = column.keyPath - - let ancestorValue = ancestor.row[keyPath: rowKeyPath] - let clientValue = client.row[keyPath: rowKeyPath] - let serverValue = server.row[keyPath: rowKeyPath] - + ) -> Column.QueryValue.QueryOutput where Column.Root == T { + mergedValue( + column: T.columns[keyPath: keyPath], + policy: policy + ) + } + + func mergedValue( + column: Column, + policy: FieldMergePolicy + ) -> Column.QueryValue.QueryOutput where Column.Root == T { + let keyPath = column.keyPath + let ancestorValue = ancestor.row[keyPath: keyPath] + let clientValue = client.row[keyPath: keyPath] + let serverValue = server.row[keyPath: keyPath] + let clientChanged = !areEqual(ancestorValue, clientValue, as: Column.QueryValue.self) let serverChanged = !areEqual(ancestorValue, serverValue, as: Column.QueryValue.self) - + switch (clientChanged, serverChanged) { case (false, false): - return clientValue + return ancestorValue case (true, false): return clientValue case (false, true): @@ -86,17 +93,17 @@ case (true, true): let ancestorVersion = FieldVersion( value: ancestorValue, - modificationTime: ancestor.modificationTime(for: rowKeyPath) + modificationTime: ancestor.modificationTime(for: keyPath) ) let clientVersion = FieldVersion( value: clientValue, - modificationTime: client.modificationTime(for: rowKeyPath) + modificationTime: client.modificationTime(for: keyPath) ) let serverVersion = FieldVersion( value: serverValue, - modificationTime: server.modificationTime(for: rowKeyPath) + modificationTime: server.modificationTime(for: keyPath) ) - + return policy.resolve(ancestorVersion, clientVersion, serverVersion) } } From 9bf0aaef91a99c6ef918c595d30b154d8e391267 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sun, 11 Jan 2026 07:34:00 +0100 Subject: [PATCH 12/22] Support for decoding RowVersion from CKRecord --- .../CloudKit/CloudKit+StructuredQueries.swift | 2 +- .../ConflictResolutionPlayground.swift | 132 +++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift index 6e9eacd9..e436b6f9 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/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index bc11175f..2fd39e80 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -38,7 +38,7 @@ } open(column) } - + self.init( row: row, modificationTimes: modificationTimes @@ -46,7 +46,22 @@ } init(from record: CKRecord) throws { - fatalError("Not implemented") + var decoder = CKRecordQueryDecoder(record: record) + let row = try T(decoder: &decoder) + + var modificationTimes: [PartialKeyPath: Int64] = [:] + for column in T.TableColumns.writableColumns { + func open(_ column: some WritableTableColumnExpression) { + let keyPath = column.keyPath as! PartialKeyPath + modificationTimes[keyPath] = record.encryptedValues[at: column.name] + } + open(column) + } + + self.init( + row: row, + modificationTimes: modificationTimes + ) } func modificationTime(for column: PartialKeyPath) -> Int64 { @@ -54,6 +69,96 @@ } } + struct CKRecordQueryDecoder: QueryDecoder { + let record: CKRecord + var columnIterator: IndexingIterator<[String]> + + init(record: CKRecord) { + self.record = record + self.columnIterator = T.TableColumns.allColumns.map(\.name).makeIterator() + } + + mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + + if let asset = record[key] as? CKAsset, + let fileURL = asset.fileURL { + @Dependency(\.dataManager) var dataManager + return try [UInt8](dataManager.load(fileURL)) + } else if let value = record.encryptedValues[key] as? Data { + return [UInt8](value) + } + + return nil + } + + mutating func decode(_ columnType: Bool.Type) throws -> Bool? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + return record.encryptedValues[key] as? Bool + } + + mutating func decode(_ columnType: Date.Type) throws -> Date? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + return record.encryptedValues[key] as? Date + } + + mutating func decode(_ columnType: Double.Type) throws -> Double? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + return record.encryptedValues[key] as? Double + } + + mutating func decode(_ columnType: Int.Type) throws -> Int? { + try decode(Int64.self).map(Int.init) + } + + mutating func decode(_ columnType: Int64.Type) throws -> Int64? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + return record.encryptedValues[key] as? Int64 + } + + mutating func decode(_ columnType: String.Type) throws -> String? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + return record.encryptedValues[key] as? String + } + + mutating func decode(_ columnType: UInt64.Type) throws -> UInt64? { + guard let n = try decode(Int64.self) else { return nil } + guard n >= 0 else { throw UInt64OverflowError() } + return UInt64(n) + } + + mutating func decode(_ columnType: UUID.Type) throws -> UUID? { + guard let key = columnIterator.next() else { + throw MissingColumn() + } + + if let uuidString = record.encryptedValues[key] as? String { + guard let uuid = UUID(uuidString: uuidString) else { + throw InvalidUUID() + } + return uuid + } + + return nil + } + + private struct MissingColumn: Error {} + private struct InvalidUUID: Error {} + private struct UInt64OverflowError: Error {} + } + struct MergeConflict { let ancestor: RowVersion let client: RowVersion @@ -262,6 +367,29 @@ #expect(client.modificationTime(for: \.tags) == 50) } + @Test + func versionInit_fromRecord() throws { + let record = CKRecord(recordType: "Post") + record.setValue(UUID(0).uuidString.lowercased(), forKey: "id", at: 0) + record.setValue("My Post", forKey: "title", at: 100) + record.setValue(42, forKey: "upvotes", at: 50) + record.setValue(#"["hobby","travel"]"#, forKey: "tags", at: 50) + + let version = try RowVersion(from: record) + + #expect(version.row.id == UUID(0)) + #expect(version.modificationTime(for: \.id) == 0) + + #expect(version.row.title == "My Post") + #expect(version.modificationTime(for: \.title) == 100) + + #expect(version.row.upvotes == 42) + #expect(version.modificationTime(for: \.upvotes) == 50) + + #expect(version.row.tags == ["hobby", "travel"]) + #expect(version.modificationTime(for: \.tags) == 50) + } + /// Tests the field-wise last edit wins strategy with all seven merge scenarios. /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md @Test From db64edd84402bf271896629192c2e464c4d7fb3e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sun, 11 Jan 2026 09:21:40 +0100 Subject: [PATCH 13/22] Switch CKRecord-to-row decoding to synthetic SELECT --- .../ConflictResolutionPlayground.swift | 129 +++++------------- 1 file changed, 35 insertions(+), 94 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 2fd39e80..46838af4 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -1,8 +1,9 @@ #if canImport(CloudKit) import CloudKit import CryptoKit + import DependenciesTestSupport import Foundation - import SQLiteData + @testable import SQLiteData import Testing struct RowVersion { @@ -46,9 +47,39 @@ } init(from record: CKRecord) throws { - var decoder = CKRecordQueryDecoder(record: record) - let row = try T(decoder: &decoder) + @Dependency(\.defaultDatabase) var database + @Dependency(\.dataManager) var dataManager + + func makeQuery() -> SQLQueryExpression { + let values = T.TableColumns.allColumns.map { column in + let value = record.encryptedValues[column.name] + + if let asset = value as? CKAsset, + let data = try? asset.fileURL.map({ try dataManager.load($0) }) { + return data.queryFragment + } + + if let value { + return value.queryFragment + } + + return "NULL" + } + + return #sql("SELECT \(values.joined(separator: ", "))") + } + // Convert CKRecord values into a SQL SELECT with literal values and execute through + // the database. This leverages SQLiteQueryDecoder to handle all type conversions + // and produces a properly decoded T instance. + let query = makeQuery() + let row = try database.read { db in + // TODO: The synthetic selection always returns exactly one row, should we force-cast instead? + guard let row = try query.fetchOne(db) else { throw NotFound() } + // TODO: Is there a way to make the compiler aware of T.QueryOutput == T? + return row as! T + } + var modificationTimes: [PartialKeyPath: Int64] = [:] for column in T.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { @@ -69,96 +100,6 @@ } } - struct CKRecordQueryDecoder: QueryDecoder { - let record: CKRecord - var columnIterator: IndexingIterator<[String]> - - init(record: CKRecord) { - self.record = record - self.columnIterator = T.TableColumns.allColumns.map(\.name).makeIterator() - } - - mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - - if let asset = record[key] as? CKAsset, - let fileURL = asset.fileURL { - @Dependency(\.dataManager) var dataManager - return try [UInt8](dataManager.load(fileURL)) - } else if let value = record.encryptedValues[key] as? Data { - return [UInt8](value) - } - - return nil - } - - mutating func decode(_ columnType: Bool.Type) throws -> Bool? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - return record.encryptedValues[key] as? Bool - } - - mutating func decode(_ columnType: Date.Type) throws -> Date? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - return record.encryptedValues[key] as? Date - } - - mutating func decode(_ columnType: Double.Type) throws -> Double? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - return record.encryptedValues[key] as? Double - } - - mutating func decode(_ columnType: Int.Type) throws -> Int? { - try decode(Int64.self).map(Int.init) - } - - mutating func decode(_ columnType: Int64.Type) throws -> Int64? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - return record.encryptedValues[key] as? Int64 - } - - mutating func decode(_ columnType: String.Type) throws -> String? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - return record.encryptedValues[key] as? String - } - - mutating func decode(_ columnType: UInt64.Type) throws -> UInt64? { - guard let n = try decode(Int64.self) else { return nil } - guard n >= 0 else { throw UInt64OverflowError() } - return UInt64(n) - } - - mutating func decode(_ columnType: UUID.Type) throws -> UUID? { - guard let key = columnIterator.next() else { - throw MissingColumn() - } - - if let uuidString = record.encryptedValues[key] as? String { - guard let uuid = UUID(uuidString: uuidString) else { - throw InvalidUUID() - } - return uuid - } - - return nil - } - - private struct MissingColumn: Error {} - private struct InvalidUUID: Error {} - private struct UInt64OverflowError: Error {} - } - struct MergeConflict { let ancestor: RowVersion let client: RowVersion @@ -327,7 +268,7 @@ var field7: String } - @Suite + @Suite(.dependency(\.defaultDatabase, try DatabaseQueue())) struct ConflictResolutionPlaygroundTests { @Test func versionInit_rowAndModificationTimes() { From b8ddfbe3d4654143524e6a8aa75fd38693ca4dff Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 08:25:34 +0100 Subject: [PATCH 14/22] Add `MergeConflict.makeUpdateQuery()` --- .../ConflictResolutionPlayground.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 46838af4..d8bbdd46 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -100,7 +100,7 @@ } } - struct MergeConflict { + struct MergeConflict where T.TableColumns.PrimaryColumn: WritableTableColumnExpression { let ancestor: RowVersion let client: RowVersion let server: RowVersion @@ -153,6 +153,26 @@ return policy.resolve(ancestorVersion, clientVersion, serverVersion) } } + + func makeUpdateQuery() -> QueryFragment { + let assignments = T.TableColumns.writableColumns.compactMap { column in + func open( + _ column: some WritableTableColumnExpression + ) -> (column: String, value: QueryBinding)? { + guard column.name != T.primaryKey.name else { return nil } + let column = column as! (any WritableTableColumnExpression) + let merged = mergedValue(column: column, policy: .latest) + return (column: column.name, value: Value(queryOutput: merged).queryBinding) + } + return open(column) + } + + return """ + UPDATE \(T.self) + SET \(assignments.map { "\(quote: $0.column) = \($0.value)" }.joined(separator: ", ")) + WHERE (\(T.primaryKey)) = (\(T.PrimaryKey(queryOutput: ancestor.row.primaryKey))) + """ + } } struct FieldVersion { From 393b2812d033657c760abcbd4c4e0674a86147db Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 09:48:04 +0100 Subject: [PATCH 15/22] Refactor merge conflict test helpers --- .../ConflictResolutionPlayground.swift | 148 +++++++++--------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index d8bbdd46..0af0ac8f 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -277,7 +277,7 @@ } @Table - private struct MergeExample { + private struct MergeModel { let id: UUID var field1: String var field2: String @@ -288,6 +288,78 @@ var field7: String } + /// Creates a three-way merge conflict covering all seven canonical merge scenarios. + /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md + private func makeTestMergeConflict() -> MergeConflict { + let ancestor = RowVersion( + row: MergeModel( + id: UUID(0), + field1: "foo", // Scenario 1: No changes + field2: "foo", // Scenario 2: Client-only change + field3: "foo", // Scenario 3: Server-only change + field4: "foo", // Scenario 4: Both changed, server newer + field5: "foo", // Scenario 5: Both changed, client newer + field6: "foo", // Scenario 6: Both changed, equal timestamps + field7: "foo" // Scenario 7: Both changed, same value + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 0, + \.field4: 0, + \.field5: 0, + \.field6: 0, + \.field7: 0 + ] + ) + + let client = RowVersion( + row: MergeModel( + id: UUID(0), + field1: "foo", + field2: "bar", + field3: "foo", + field4: "bar", + field5: "bar", + field6: "bar", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 100, + \.field3: 0, + \.field4: 100, + \.field5: 100, + \.field6: 100, + \.field7: 100 + ] + ) + + let server = RowVersion( + row: MergeModel( + id: UUID(0), + field1: "foo", + field2: "foo", + field3: "baz", + field4: "baz", + field5: "baz", + field6: "baz", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 200, + \.field4: 200, + \.field5: 50, + \.field6: 100, + \.field7: 200 + ] + ) + + return MergeConflict(ancestor: ancestor, client: client, server: server) + } + @Suite(.dependency(\.defaultDatabase, try DatabaseQueue())) struct ConflictResolutionPlaygroundTests { @Test @@ -351,81 +423,9 @@ #expect(version.modificationTime(for: \.tags) == 50) } - /// Tests the field-wise last edit wins strategy with all seven merge scenarios. - /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md @Test func mergedValue_latestPolicy() { - let ancestor = RowVersion( - row: MergeExample( - id: UUID(0), - field1: "foo", - field2: "foo", - field3: "foo", - field4: "foo", - field5: "foo", - field6: "foo", - field7: "foo" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 0, - \.field4: 0, - \.field5: 0, - \.field6: 0, - \.field7: 0 - ] - ) - - let client = RowVersion( - row: MergeExample( - id: UUID(0), - field1: "foo", - field2: "bar", - field3: "foo", - field4: "bar", - field5: "bar", - field6: "bar", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 100, - \.field3: 0, - \.field4: 100, - \.field5: 100, - \.field6: 100, - \.field7: 100 - ] - ) - - let server = RowVersion( - row: MergeExample( - id: UUID(0), - field1: "foo", - field2: "foo", - field3: "baz", - field4: "baz", - field5: "baz", - field6: "baz", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 200, - \.field4: 200, - \.field5: 50, - \.field6: 100, - \.field7: 200 - ] - ) - - let conflict = MergeConflict( - ancestor: ancestor, - client: client, - server: server - ) + let conflict = makeTestMergeConflict() #expect(conflict.mergedValue(for: \.field1, policy: .latest) == "foo") #expect(conflict.mergedValue(for: \.field2, policy: .latest) == "bar") From 754aff4b378441f2f2a09677116616a3dad4f39b Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 09:48:40 +0100 Subject: [PATCH 16/22] Add test for update query creation --- .../ConflictResolutionPlayground.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 0af0ac8f..62f146a2 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -3,7 +3,9 @@ import CryptoKit import DependenciesTestSupport import Foundation + import InlineSnapshotTesting @testable import SQLiteData + import StructuredQueriesTestSupport import Testing struct RowVersion { @@ -494,5 +496,18 @@ // - `QueryBinding`: `.text(…)` (the JSON serialized representation) #expect(conflict.mergedValue(for: \.tags, policy: .set) == ["hobby", "photography", "tech"]) } + + @Test + func makeUpdateQuery_latestPolicy() { + let conflict = makeTestMergeConflict() + + assertInlineSnapshot(of: #sql(conflict.makeUpdateQuery()), as: .sql) { + """ + UPDATE "mergeModels" + SET "field1" = 'foo', "field2" = 'bar', "field3" = 'baz', "field4" = 'baz', "field5" = 'bar', "field6" = 'bar', "field7" = 'bar' + WHERE ("mergeModels"."id") = ('00000000-0000-0000-0000-000000000000') + """ + } + } } #endif From 95b560d7902306b6c4f25ef33b6dcd9076076281 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 10:49:39 +0100 Subject: [PATCH 17/22] Polish tests --- .../ConflictResolutionPlayground.swift | 159 +++++++++--------- 1 file changed, 83 insertions(+), 76 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 62f146a2..f2f01546 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -109,6 +109,8 @@ } extension MergeConflict { + /// Computes the merged value for a field identified by key path using the given merge policy, + /// delegating to `mergedValue(column:policy:)`. func mergedValue( for keyPath: some KeyPath, policy: FieldMergePolicy @@ -119,6 +121,8 @@ ) } + /// Computes the merged value for a field identified by column using three-way merge logic, + /// applying the given merge policy when both versions changed. func mergedValue( column: Column, policy: FieldMergePolicy @@ -156,6 +160,7 @@ } } + /// Generates an UPDATE statement that resolves the merge conflict using the `.latest` policy. func makeUpdateQuery() -> QueryFragment { let assignments = T.TableColumns.writableColumns.compactMap { column in func open( @@ -280,7 +285,7 @@ @Table private struct MergeModel { - let id: UUID + let id: Int var field1: String var field2: String var field3: String @@ -290,79 +295,81 @@ var field7: String } - /// Creates a three-way merge conflict covering all seven canonical merge scenarios. - /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md - private func makeTestMergeConflict() -> MergeConflict { - let ancestor = RowVersion( - row: MergeModel( - id: UUID(0), - field1: "foo", // Scenario 1: No changes - field2: "foo", // Scenario 2: Client-only change - field3: "foo", // Scenario 3: Server-only change - field4: "foo", // Scenario 4: Both changed, server newer - field5: "foo", // Scenario 5: Both changed, client newer - field6: "foo", // Scenario 6: Both changed, equal timestamps - field7: "foo" // Scenario 7: Both changed, same value - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 0, - \.field4: 0, - \.field5: 0, - \.field6: 0, - \.field7: 0 - ] - ) - - let client = RowVersion( - row: MergeModel( - id: UUID(0), - field1: "foo", - field2: "bar", - field3: "foo", - field4: "bar", - field5: "bar", - field6: "bar", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 100, - \.field3: 0, - \.field4: 100, - \.field5: 100, - \.field6: 100, - \.field7: 100 - ] - ) - - let server = RowVersion( - row: MergeModel( - id: UUID(0), - field1: "foo", - field2: "foo", - field3: "baz", - field4: "baz", - field5: "baz", - field6: "baz", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 200, - \.field4: 200, - \.field5: 50, - \.field6: 100, - \.field7: 200 - ] - ) - - return MergeConflict(ancestor: ancestor, client: client, server: server) } @Suite(.dependency(\.defaultDatabase, try DatabaseQueue())) + extension MergeModel { + /// Creates a three-way merge conflict covering all seven canonical merge scenarios. + /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md + fileprivate static func makeCanonicalConflict() -> MergeConflict { + let ancestor = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", // Scenario 1: No changes + field2: "foo", // Scenario 2: Client-only change + field3: "foo", // Scenario 3: Server-only change + field4: "foo", // Scenario 4: Both changed, server newer + field5: "foo", // Scenario 5: Both changed, client newer + field6: "foo", // Scenario 6: Both changed, equal timestamps + field7: "foo" // Scenario 7: Both changed, same value + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 0, + \.field4: 0, + \.field5: 0, + \.field6: 0, + \.field7: 0 + ] + ) + + let client = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", + field2: "bar", + field3: "foo", + field4: "bar", + field5: "bar", + field6: "bar", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 100, + \.field3: 0, + \.field4: 100, + \.field5: 100, + \.field6: 100, + \.field7: 100 + ] + ) + + let server = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", + field2: "foo", + field3: "baz", + field4: "baz", + field5: "baz", + field6: "baz", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 200, + \.field4: 200, + \.field5: 50, + \.field6: 100, + \.field7: 200 + ] + ) + + return MergeConflict(ancestor: ancestor, client: client, server: server) + } struct ConflictResolutionPlaygroundTests { @Test func versionInit_rowAndModificationTimes() { @@ -426,8 +433,8 @@ } @Test - func mergedValue_latestPolicy() { - let conflict = makeTestMergeConflict() + func mergedValue_canonicalConflictWithLatestPolicy() { + let conflict = MergeModel.makeCanonicalConflict() #expect(conflict.mergedValue(for: \.field1, policy: .latest) == "foo") #expect(conflict.mergedValue(for: \.field2, policy: .latest) == "bar") @@ -498,14 +505,14 @@ } @Test - func makeUpdateQuery_latestPolicy() { - let conflict = makeTestMergeConflict() + func makeUpdateQuery_canonicalConflictWithLatestPolicy() { + let conflict = MergeModel.makeCanonicalConflict() assertInlineSnapshot(of: #sql(conflict.makeUpdateQuery()), as: .sql) { """ UPDATE "mergeModels" SET "field1" = 'foo', "field2" = 'bar', "field3" = 'baz', "field4" = 'baz', "field5" = 'bar', "field6" = 'bar', "field7" = 'bar' - WHERE ("mergeModels"."id") = ('00000000-0000-0000-0000-000000000000') + WHERE ("mergeModels"."id") = (0) """ } } From 61549fdfbe3835140372f11472f28dd3b1a4bd1f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 10:56:01 +0100 Subject: [PATCH 18/22] Add test for full-circle conflict resolution --- .../ConflictResolutionPlayground.swift | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index f2f01546..ac2e2f1b 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -295,9 +295,25 @@ var field7: String } + extension DatabaseWriter { + /// Resolves a merge conflict via a database roundtrip using the “last edit wins” field merge + /// policy. Primarily used for testing conflict resolution logic. + fileprivate func resolve( + conflict: MergeConflict + ) throws -> T where T == T.QueryOutput, T.TableColumns.PrimaryColumn: WritableTableColumnExpression { + try write { db in + // Insert the initial client row. + try T.insert { conflict.client.row }.execute(db) + + // Perform the update query resolving the conflict. + try #sql(conflict.makeUpdateQuery()).execute(db) + + // Fetch the updated client row. + return try T.fetchOne(db)! + } + } } - @Suite(.dependency(\.defaultDatabase, try DatabaseQueue())) extension MergeModel { /// Creates a three-way merge conflict covering all seven canonical merge scenarios. /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md @@ -370,6 +386,44 @@ return MergeConflict(ancestor: ancestor, client: client, server: server) } + } + + extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func databaseForMergeConflicts() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "posts" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "upvotes" INTEGER NOT NULL DEFAULT 0, + "tags" TEXT NOT NULL + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "mergeModels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "field1" TEXT NOT NULL, + "field2" TEXT NOT NULL, + "field3" TEXT NOT NULL, + "field4" TEXT NOT NULL, + "field5" TEXT NOT NULL, + "field6" TEXT NOT NULL, + "field7" TEXT NOT NULL + ) + """ + ) + .execute(db) + } + return database + } + } + + @Suite(.dependency(\.defaultDatabase, try .databaseForMergeConflicts())) struct ConflictResolutionPlaygroundTests { @Test func versionInit_rowAndModificationTimes() { @@ -516,5 +570,20 @@ """ } } + + @Test + func resolve_canonicalConflictWithLatestPolicy() throws { + @Dependency(\.defaultDatabase) var database + let conflict = MergeModel.makeCanonicalConflict() + let merged = try database.resolve(conflict: conflict) + + #expect(merged.field1 == "foo") + #expect(merged.field2 == "bar") + #expect(merged.field3 == "baz") + #expect(merged.field4 == "baz") + #expect(merged.field5 == "bar") + #expect(merged.field6 == "bar") + #expect(merged.field7 == "bar") + } } #endif From 5aef7538cdc6c6b57ded81e13e49372a682db7e9 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 13 Jan 2026 11:51:06 +0100 Subject: [PATCH 19/22] Reorder code --- .../ConflictResolutionPlayground.swift | 476 +++++++++--------- 1 file changed, 238 insertions(+), 238 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index ac2e2f1b..74ed7701 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -8,10 +8,90 @@ import StructuredQueriesTestSupport import Testing + struct MergeConflict where T.TableColumns.PrimaryColumn: WritableTableColumnExpression { + let ancestor: RowVersion + let client: RowVersion + let server: RowVersion + } + + extension MergeConflict { + /// Computes the merged value for a field identified by key path using the given merge policy, + /// delegating to `mergedValue(column:policy:)`. + func mergedValue( + for keyPath: some KeyPath, + policy: FieldMergePolicy + ) -> Column.QueryValue.QueryOutput where Column.Root == T { + mergedValue( + column: T.columns[keyPath: keyPath], + policy: policy + ) + } + + /// Computes the merged value for a field identified by column using three-way merge logic, + /// applying the given merge policy when both versions changed. + func mergedValue( + column: Column, + policy: FieldMergePolicy + ) -> Column.QueryValue.QueryOutput where Column.Root == T { + let keyPath = column.keyPath + let ancestorValue = ancestor.row[keyPath: keyPath] + let clientValue = client.row[keyPath: keyPath] + let serverValue = server.row[keyPath: keyPath] + + let clientChanged = !areEqual(ancestorValue, clientValue, as: Column.QueryValue.self) + let serverChanged = !areEqual(ancestorValue, serverValue, as: Column.QueryValue.self) + + switch (clientChanged, serverChanged) { + case (false, false): + return ancestorValue + case (true, false): + return clientValue + case (false, true): + return serverValue + case (true, true): + let ancestorVersion = FieldVersion( + value: ancestorValue, + modificationTime: ancestor.modificationTime(for: keyPath) + ) + let clientVersion = FieldVersion( + value: clientValue, + modificationTime: client.modificationTime(for: keyPath) + ) + let serverVersion = FieldVersion( + value: serverValue, + modificationTime: server.modificationTime(for: keyPath) + ) + + return policy.resolve(ancestorVersion, clientVersion, serverVersion) + } + } + + /// Generates an UPDATE statement that resolves the merge conflict using the `.latest` policy. + func makeUpdateQuery() -> QueryFragment { + let assignments = T.TableColumns.writableColumns.compactMap { column in + func open( + _ column: some WritableTableColumnExpression + ) -> (column: String, value: QueryBinding)? { + guard column.name != T.primaryKey.name else { return nil } + let column = column as! (any WritableTableColumnExpression) + let merged = mergedValue(column: column, policy: .latest) + return (column: column.name, value: Value(queryOutput: merged).queryBinding) + } + return open(column) + } + + return """ + UPDATE \(T.self) + SET \(assignments.map { "\(quote: $0.column) = \($0.value)" }.joined(separator: ", ")) + WHERE (\(T.primaryKey)) = (\(T.PrimaryKey(queryOutput: ancestor.row.primaryKey))) + """ + } + } + struct RowVersion { let row: T private let modificationTimes: [PartialKeyPath: Int64] - + package init( row: T, modificationTimes: [PartialKeyPath: Int64] @@ -19,7 +99,7 @@ self.row = row self.modificationTimes = modificationTimes } - + init( clientRow row: T, userModificationTime: Int64, @@ -29,10 +109,10 @@ for column in T.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { let keyPath = column.keyPath as! KeyPath - + let clientValue = row[keyPath: keyPath] let ancestorValue = ancestorVersion.row[keyPath: keyPath] - + if areEqual(clientValue, ancestorValue, as: Value.self) { modificationTimes[keyPath] = ancestorVersion.modificationTime(for: keyPath) } else { @@ -41,13 +121,13 @@ } open(column) } - + self.init( row: row, modificationTimes: modificationTimes ) } - + init(from record: CKRecord) throws { @Dependency(\.defaultDatabase) var database @Dependency(\.dataManager) var dataManager @@ -70,7 +150,7 @@ return #sql("SELECT \(values.joined(separator: ", "))") } - + // Convert CKRecord values into a SQL SELECT with literal values and execute through // the database. This leverages SQLiteQueryDecoder to handle all type conversions // and produces a properly decoded T instance. @@ -90,98 +170,18 @@ } open(column) } - + self.init( row: row, modificationTimes: modificationTimes ) } - + func modificationTime(for column: PartialKeyPath) -> Int64 { return modificationTimes[column] ?? -1 } } - struct MergeConflict where T.TableColumns.PrimaryColumn: WritableTableColumnExpression { - let ancestor: RowVersion - let client: RowVersion - let server: RowVersion - } - - extension MergeConflict { - /// Computes the merged value for a field identified by key path using the given merge policy, - /// delegating to `mergedValue(column:policy:)`. - func mergedValue( - for keyPath: some KeyPath, - policy: FieldMergePolicy - ) -> Column.QueryValue.QueryOutput where Column.Root == T { - mergedValue( - column: T.columns[keyPath: keyPath], - policy: policy - ) - } - - /// Computes the merged value for a field identified by column using three-way merge logic, - /// applying the given merge policy when both versions changed. - func mergedValue( - column: Column, - policy: FieldMergePolicy - ) -> Column.QueryValue.QueryOutput where Column.Root == T { - let keyPath = column.keyPath - let ancestorValue = ancestor.row[keyPath: keyPath] - let clientValue = client.row[keyPath: keyPath] - let serverValue = server.row[keyPath: keyPath] - - let clientChanged = !areEqual(ancestorValue, clientValue, as: Column.QueryValue.self) - let serverChanged = !areEqual(ancestorValue, serverValue, as: Column.QueryValue.self) - - switch (clientChanged, serverChanged) { - case (false, false): - return ancestorValue - case (true, false): - return clientValue - case (false, true): - return serverValue - case (true, true): - let ancestorVersion = FieldVersion( - value: ancestorValue, - modificationTime: ancestor.modificationTime(for: keyPath) - ) - let clientVersion = FieldVersion( - value: clientValue, - modificationTime: client.modificationTime(for: keyPath) - ) - let serverVersion = FieldVersion( - value: serverValue, - modificationTime: server.modificationTime(for: keyPath) - ) - - return policy.resolve(ancestorVersion, clientVersion, serverVersion) - } - } - - /// Generates an UPDATE statement that resolves the merge conflict using the `.latest` policy. - func makeUpdateQuery() -> QueryFragment { - let assignments = T.TableColumns.writableColumns.compactMap { column in - func open( - _ column: some WritableTableColumnExpression - ) -> (column: String, value: QueryBinding)? { - guard column.name != T.primaryKey.name else { return nil } - let column = column as! (any WritableTableColumnExpression) - let merged = mergedValue(column: column, policy: .latest) - return (column: column.name, value: Value(queryOutput: merged).queryBinding) - } - return open(column) - } - - return """ - UPDATE \(T.self) - SET \(assignments.map { "\(quote: $0.column) = \($0.value)" }.joined(separator: ", ")) - WHERE (\(T.primaryKey)) = (\(T.PrimaryKey(queryOutput: ancestor.row.primaryKey))) - """ - } - } - struct FieldVersion { let value: Value let modificationTime: Int64 @@ -274,155 +274,6 @@ } } - @Table - private struct Post { - let id: UUID - var title: String - var upvotes = 0 - @Column(as: Set.JSONRepresentation.self) - var tags: Set = [] - } - - @Table - private struct MergeModel { - let id: Int - var field1: String - var field2: String - var field3: String - var field4: String - var field5: String - var field6: String - var field7: String - } - - extension DatabaseWriter { - /// Resolves a merge conflict via a database roundtrip using the “last edit wins” field merge - /// policy. Primarily used for testing conflict resolution logic. - fileprivate func resolve( - conflict: MergeConflict - ) throws -> T where T == T.QueryOutput, T.TableColumns.PrimaryColumn: WritableTableColumnExpression { - try write { db in - // Insert the initial client row. - try T.insert { conflict.client.row }.execute(db) - - // Perform the update query resolving the conflict. - try #sql(conflict.makeUpdateQuery()).execute(db) - - // Fetch the updated client row. - return try T.fetchOne(db)! - } - } - } - - extension MergeModel { - /// Creates a three-way merge conflict covering all seven canonical merge scenarios. - /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md - fileprivate static func makeCanonicalConflict() -> MergeConflict { - let ancestor = RowVersion( - row: MergeModel( - id: 0, - field1: "foo", // Scenario 1: No changes - field2: "foo", // Scenario 2: Client-only change - field3: "foo", // Scenario 3: Server-only change - field4: "foo", // Scenario 4: Both changed, server newer - field5: "foo", // Scenario 5: Both changed, client newer - field6: "foo", // Scenario 6: Both changed, equal timestamps - field7: "foo" // Scenario 7: Both changed, same value - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 0, - \.field4: 0, - \.field5: 0, - \.field6: 0, - \.field7: 0 - ] - ) - - let client = RowVersion( - row: MergeModel( - id: 0, - field1: "foo", - field2: "bar", - field3: "foo", - field4: "bar", - field5: "bar", - field6: "bar", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 100, - \.field3: 0, - \.field4: 100, - \.field5: 100, - \.field6: 100, - \.field7: 100 - ] - ) - - let server = RowVersion( - row: MergeModel( - id: 0, - field1: "foo", - field2: "foo", - field3: "baz", - field4: "baz", - field5: "baz", - field6: "baz", - field7: "bar" - ), - modificationTimes: [ - \.field1: 0, - \.field2: 0, - \.field3: 200, - \.field4: 200, - \.field5: 50, - \.field6: 100, - \.field7: 200 - ] - ) - - return MergeConflict(ancestor: ancestor, client: client, server: server) - } - } - - extension DatabaseWriter where Self == DatabaseQueue { - fileprivate static func databaseForMergeConflicts() throws -> DatabaseQueue { - let database = try DatabaseQueue() - try database.write { db in - try #sql( - """ - CREATE TABLE "posts" ( - "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL, - "upvotes" INTEGER NOT NULL DEFAULT 0, - "tags" TEXT NOT NULL - ) - """ - ) - .execute(db) - try #sql( - """ - CREATE TABLE "mergeModels" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "field1" TEXT NOT NULL, - "field2" TEXT NOT NULL, - "field3" TEXT NOT NULL, - "field4" TEXT NOT NULL, - "field5" TEXT NOT NULL, - "field6" TEXT NOT NULL, - "field7" TEXT NOT NULL - ) - """ - ) - .execute(db) - } - return database - } - } - @Suite(.dependency(\.defaultDatabase, try .databaseForMergeConflicts())) struct ConflictResolutionPlaygroundTests { @Test @@ -586,4 +437,153 @@ #expect(merged.field7 == "bar") } } + + @Table + private struct Post { + let id: UUID + var title: String + var upvotes = 0 + @Column(as: Set.JSONRepresentation.self) + var tags: Set = [] + } + + @Table + private struct MergeModel { + let id: Int + var field1: String + var field2: String + var field3: String + var field4: String + var field5: String + var field6: String + var field7: String + } + + extension MergeModel { + /// Creates a three-way merge conflict covering all seven canonical merge scenarios. + /// See: https://github.com/structuredpath/sqlite-data-sync-notes/blob/main/BuiltInConflictResolutionModel.md + fileprivate static func makeCanonicalConflict() -> MergeConflict { + let ancestor = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", // Scenario 1: No changes + field2: "foo", // Scenario 2: Client-only change + field3: "foo", // Scenario 3: Server-only change + field4: "foo", // Scenario 4: Both changed, server newer + field5: "foo", // Scenario 5: Both changed, client newer + field6: "foo", // Scenario 6: Both changed, equal timestamps + field7: "foo" // Scenario 7: Both changed, same value + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 0, + \.field4: 0, + \.field5: 0, + \.field6: 0, + \.field7: 0 + ] + ) + + let client = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", + field2: "bar", + field3: "foo", + field4: "bar", + field5: "bar", + field6: "bar", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 100, + \.field3: 0, + \.field4: 100, + \.field5: 100, + \.field6: 100, + \.field7: 100 + ] + ) + + let server = RowVersion( + row: MergeModel( + id: 0, + field1: "foo", + field2: "foo", + field3: "baz", + field4: "baz", + field5: "baz", + field6: "baz", + field7: "bar" + ), + modificationTimes: [ + \.field1: 0, + \.field2: 0, + \.field3: 200, + \.field4: 200, + \.field5: 50, + \.field6: 100, + \.field7: 200 + ] + ) + + return MergeConflict(ancestor: ancestor, client: client, server: server) + } + } + + extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func databaseForMergeConflicts() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "posts" ( + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "title" TEXT NOT NULL, + "upvotes" INTEGER NOT NULL DEFAULT 0, + "tags" TEXT NOT NULL + ) + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "mergeModels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "field1" TEXT NOT NULL, + "field2" TEXT NOT NULL, + "field3" TEXT NOT NULL, + "field4" TEXT NOT NULL, + "field5" TEXT NOT NULL, + "field6" TEXT NOT NULL, + "field7" TEXT NOT NULL + ) + """ + ) + .execute(db) + } + return database + } + } + + extension DatabaseWriter { + /// Resolves a merge conflict via a database roundtrip using the “last edit wins” field merge + /// policy. Primarily used for testing conflict resolution logic. + fileprivate func resolve( + conflict: MergeConflict + ) throws -> T where T == T.QueryOutput, T.TableColumns.PrimaryColumn: WritableTableColumnExpression { + try write { db in + // Insert the initial client row. + try T.insert { conflict.client.row }.execute(db) + + // Perform the update query resolving the conflict. + try #sql(conflict.makeUpdateQuery()).execute(db) + + // Fetch the updated client row. + return try T.fetchOne(db)! + } + } + } #endif From 745d2c8e3f6d0fe2409ae96cbe09bfe4412c8e14 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 11:31:29 +0100 Subject: [PATCH 20/22] Add comments to field merge policies --- .../CloudKitTests/ConflictResolutionPlayground.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index 74ed7701..c89cae1a 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -196,6 +196,8 @@ } extension FieldMergePolicy { + /// Last-edit-wins merge policy that picks the edited value with the newer modification + /// timestamp (ties favor the client). static var latest: Self { Self { _, client, server in server.modificationTime > client.modificationTime ? server.value : client.value @@ -204,6 +206,8 @@ } extension FieldMergePolicy where Value: BinaryInteger { + /// Counter merge policy that combines the independent increments and decrements from + /// both edited values. static var counter: Self { Self { ancestor, client, server in ancestor.value @@ -214,6 +218,8 @@ } extension FieldMergePolicy where Value: SetAlgebra, Value.Element: Equatable { + /// Set merge policy that preserves elements not deleted on either side and adds new elements + /// from either side. static var set: Self { Self { ancestor, client, server in let notDeleted = ancestor.value From 349788a4f4dbc1480cb8595128f7272ed29a1a1d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 14:48:08 +0100 Subject: [PATCH 21/22] =?UTF-8?q?`FieldMergePolicy.resolve`=20=E2=86=92=20?= =?UTF-8?q?`merge`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CloudKitTests/ConflictResolutionPlayground.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index c89cae1a..fcfb8a02 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -62,7 +62,7 @@ modificationTime: server.modificationTime(for: keyPath) ) - return policy.resolve(ancestorVersion, clientVersion, serverVersion) + return policy.merge(ancestorVersion, clientVersion, serverVersion) } } @@ -188,7 +188,7 @@ } struct FieldMergePolicy { - let resolve: ( + let merge: ( _ ancestor: FieldVersion, _ client: FieldVersion, _ server: FieldVersion From a150bd4be6622db99412fc87e217d7438b274590 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Mon, 12 Jan 2026 15:28:59 +0100 Subject: [PATCH 22/22] =?UTF-8?q?RowVersion=20=E2=86=92=20RowVersion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To align generics in RowVersion & FieldVersion… --- .../ConflictResolutionPlayground.swift | 93 ++++++++++--------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift index fcfb8a02..43d541a9 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/ConflictResolutionPlayground.swift @@ -8,21 +8,22 @@ import StructuredQueriesTestSupport import Testing - struct MergeConflict where T.TableColumns.PrimaryColumn: WritableTableColumnExpression { - let ancestor: RowVersion - let client: RowVersion - let server: RowVersion + struct MergeConflict + where Table.TableColumns.PrimaryColumn: WritableTableColumnExpression { + let ancestor: RowVersion
+ let client: RowVersion
+ let server: RowVersion
} extension MergeConflict { /// Computes the merged value for a field identified by key path using the given merge policy, /// delegating to `mergedValue(column:policy:)`. func mergedValue( - for keyPath: some KeyPath, + for keyPath: some KeyPath, policy: FieldMergePolicy - ) -> Column.QueryValue.QueryOutput where Column.Root == T { + ) -> Column.QueryValue.QueryOutput where Column.Root == Table { mergedValue( - column: T.columns[keyPath: keyPath], + column: Table.columns[keyPath: keyPath], policy: policy ) } @@ -32,7 +33,7 @@ func mergedValue( column: Column, policy: FieldMergePolicy - ) -> Column.QueryValue.QueryOutput where Column.Root == T { + ) -> Column.QueryValue.QueryOutput where Column.Root == Table { let keyPath = column.keyPath let ancestorValue = ancestor.row[keyPath: keyPath] let clientValue = client.row[keyPath: keyPath] @@ -68,12 +69,12 @@ /// Generates an UPDATE statement that resolves the merge conflict using the `.latest` policy. func makeUpdateQuery() -> QueryFragment { - let assignments = T.TableColumns.writableColumns.compactMap { column in + let assignments = Table.TableColumns.writableColumns.compactMap { column in func open( _ column: some WritableTableColumnExpression ) -> (column: String, value: QueryBinding)? { - guard column.name != T.primaryKey.name else { return nil } - let column = column as! (any WritableTableColumnExpression) + guard column.name != Table.primaryKey.name else { return nil } + let column = column as! (any WritableTableColumnExpression) let merged = mergedValue(column: column, policy: .latest) return (column: column.name, value: Value(queryOutput: merged).queryBinding) } @@ -81,38 +82,38 @@ } return """ - UPDATE \(T.self) + UPDATE \(Table.self) SET \(assignments.map { "\(quote: $0.column) = \($0.value)" }.joined(separator: ", ")) - WHERE (\(T.primaryKey)) = (\(T.PrimaryKey(queryOutput: ancestor.row.primaryKey))) + WHERE (\(Table.primaryKey)) = (\(Table.PrimaryKey(queryOutput: ancestor.row.primaryKey))) """ } } - struct RowVersion { - let row: T - private let modificationTimes: [PartialKeyPath: Int64] - + struct RowVersion { + let row: Table + private let modificationTimes: [PartialKeyPath
: Int64] + package init( - row: T, - modificationTimes: [PartialKeyPath: Int64] + row: Table, + modificationTimes: [PartialKeyPath
: Int64] ) { self.row = row self.modificationTimes = modificationTimes } init( - clientRow row: T, + clientRow row: Table, userModificationTime: Int64, - ancestorVersion: RowVersion + ancestorVersion: RowVersion
) { - var modificationTimes: [PartialKeyPath: Int64] = [:] - for column in T.TableColumns.writableColumns { + var modificationTimes: [PartialKeyPath
: Int64] = [:] + for column in Table.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { - let keyPath = column.keyPath as! KeyPath - + let keyPath = column.keyPath as! KeyPath + let clientValue = row[keyPath: keyPath] let ancestorValue = ancestorVersion.row[keyPath: keyPath] - + if areEqual(clientValue, ancestorValue, as: Value.self) { modificationTimes[keyPath] = ancestorVersion.modificationTime(for: keyPath) } else { @@ -121,7 +122,7 @@ } open(column) } - + self.init( row: row, modificationTimes: modificationTimes @@ -131,53 +132,53 @@ init(from record: CKRecord) throws { @Dependency(\.defaultDatabase) var database @Dependency(\.dataManager) var dataManager - - func makeQuery() -> SQLQueryExpression { - let values = T.TableColumns.allColumns.map { column in + + func makeQuery() -> SQLQueryExpression
{ + let values = Table.TableColumns.allColumns.map { column in let value = record.encryptedValues[column.name] - + if let asset = value as? CKAsset, let data = try? asset.fileURL.map({ try dataManager.load($0) }) { return data.queryFragment } - + if let value { return value.queryFragment } - + return "NULL" } - + return #sql("SELECT \(values.joined(separator: ", "))") } - - // Convert CKRecord values into a SQL SELECT with literal values and execute through - // the database. This leverages SQLiteQueryDecoder to handle all type conversions - // and produces a properly decoded T instance. + + // Convert `CKRecord` values into a SQL SELECT with literal values and execute through + // the database. This leverages `SQLiteQueryDecoder` to handle all type conversions + // and produces a properly decoded `Table` instance. let query = makeQuery() let row = try database.read { db in // TODO: The synthetic selection always returns exactly one row, should we force-cast instead? guard let row = try query.fetchOne(db) else { throw NotFound() } - // TODO: Is there a way to make the compiler aware of T.QueryOutput == T? - return row as! T + // TODO: Is there a way to make the compiler aware of Table.QueryOutput == Table? + return row as! Table } - - var modificationTimes: [PartialKeyPath: Int64] = [:] - for column in T.TableColumns.writableColumns { + + var modificationTimes: [PartialKeyPath
: Int64] = [:] + for column in Table.TableColumns.writableColumns { func open(_ column: some WritableTableColumnExpression) { - let keyPath = column.keyPath as! PartialKeyPath + let keyPath = column.keyPath as! PartialKeyPath
modificationTimes[keyPath] = record.encryptedValues[at: column.name] } open(column) } - + self.init( row: row, modificationTimes: modificationTimes ) } - func modificationTime(for column: PartialKeyPath) -> Int64 { + func modificationTime(for column: PartialKeyPath
) -> Int64 { return modificationTimes[column] ?? -1 } }