Skip to content

Commit befd4f5

Browse files
committed
wip2
1 parent d07f413 commit befd4f5

3 files changed

Lines changed: 231 additions & 52 deletions

File tree

Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -296,58 +296,67 @@
296296
}
297297

298298
func update<T: PrimaryKeyedTable>(
299-
with other: CKRecord,
300-
row: T,
301-
columnNames: inout [String],
299+
with lastKnownServerRecord: CKRecord,
300+
clientRow: T,
301+
clientUserModificationTime: Int64,
302+
columnNamesToUpsert: inout Set<String>,
302303
parentForeignKey: ForeignKey?
303304
) {
304305
typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable
305306

306-
self.userModificationTime = other.userModificationTime
307+
self.userModificationTime = lastKnownServerRecord.userModificationTime
307308
for column in T.TableColumns.writableColumns {
308309
func open<Root, Value>(_ column: some WritableTableColumnExpression<Root, Value>) {
309310
let key = column.name
310311
let keyPath = column.keyPath as! KeyPath<T, Value.QueryOutput>
311-
let didSet: Bool
312-
if let value = other[key] as? CKAsset {
313-
didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key])
314-
} else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol {
315-
didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key])
316-
} else if other.encryptedValues[key] == nil {
317-
didSet = removeValue(forKey: key, at: other.encryptedValues[at: key])
312+
let didSetLastKnownServerValue: Bool
313+
if let value = lastKnownServerRecord[key] as? CKAsset {
314+
didSetLastKnownServerValue = setAsset(value, forKey: key, at: lastKnownServerRecord.encryptedValues[at: key])
315+
} else if let value = lastKnownServerRecord.encryptedValues[key] as? any EquatableCKRecordValueProtocol {
316+
didSetLastKnownServerValue = setValue(value, forKey: key, at: lastKnownServerRecord.encryptedValues[at: key])
317+
} else if lastKnownServerRecord.encryptedValues[key] == nil {
318+
didSetLastKnownServerValue = removeValue(forKey: key, at: lastKnownServerRecord.encryptedValues[at: key])
318319
} else {
319-
didSet = false
320+
didSetLastKnownServerValue = false
320321
}
321-
/// The row value has been modified more recently than the last known record.
322-
var isRowValueModified: Bool {
323-
switch Value(queryOutput: row[keyPath: keyPath]).queryBinding {
322+
var hasClientValueChanged: Bool {
323+
switch Value(queryOutput: clientRow[keyPath: keyPath]).queryBinding {
324324
case .blob(let value):
325-
return other.encryptedValues[hash: key] != value.sha256
325+
return lastKnownServerRecord.encryptedValues[hash: key] != value.sha256
326326
case .bool(let value):
327-
return other.encryptedValues[key] != value
327+
return lastKnownServerRecord.encryptedValues[key] != value
328328
case .double(let value):
329-
return other.encryptedValues[key] != value
329+
return lastKnownServerRecord.encryptedValues[key] != value
330330
case .date(let value):
331-
return other.encryptedValues[key] != value
331+
return lastKnownServerRecord.encryptedValues[key] != value
332332
case .int(let value):
333-
return other.encryptedValues[key] != value
333+
return lastKnownServerRecord.encryptedValues[key] != value
334334
case .null:
335-
return other.encryptedValues[key] != nil
335+
return lastKnownServerRecord.encryptedValues[key] != nil
336336
case .text(let value):
337-
return other.encryptedValues[key] != value
337+
return lastKnownServerRecord.encryptedValues[key] != value
338338
case .uint(let value):
339-
return other.encryptedValues[key] != value
339+
return lastKnownServerRecord.encryptedValues[key] != value
340340
case .uuid(let value):
341-
return other.encryptedValues[key] != value.uuidString.lowercased()
341+
return lastKnownServerRecord.encryptedValues[key] != value.uuidString.lowercased()
342342
case .invalid(let error):
343343
reportIssue(error)
344344
return false
345345
}
346346
}
347-
if didSet || isRowValueModified {
348-
columnNames.removeAll(where: { $0 == key })
349-
if didSet, let parentForeignKey, key == parentForeignKey.from {
350-
self.parent = other.parent
347+
if didSetLastKnownServerValue {
348+
columnNamesToUpsert.remove(key)
349+
if let parentForeignKey, key == parentForeignKey.from {
350+
self.parent = lastKnownServerRecord.parent
351+
}
352+
} else if hasClientValueChanged {
353+
let lastKnownServerValueModificationTime = lastKnownServerRecord.encryptedValues[at: key]
354+
let serverValueModificationTime = self.encryptedValues[at: key]
355+
let hasServerValueChanged = serverValueModificationTime > lastKnownServerValueModificationTime
356+
let isClientValueNewer = clientUserModificationTime >= serverValueModificationTime
357+
358+
if !hasServerValueChanged || isClientValueNewer {
359+
columnNamesToUpsert.remove(key)
351360
}
352361
}
353362
}

Sources/SQLiteData/CloudKit/SyncEngine.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,15 +1946,16 @@
19461946
serverRecord.userModificationTime = metadata.userModificationTime
19471947

19481948
func open<T>(_ table: some SynchronizableTable<T>) throws {
1949-
var columnNames: [String] = T.TableColumns.writableColumns.map(\.name)
1949+
var columnNamesToUpsert = Set(T.TableColumns.writableColumns.map(\.name))
19501950
if !force,
19511951
let allFields = metadata._lastKnownServerRecordAllFields,
19521952
let row = try T.find(#sql("\(bind: metadata.recordPrimaryKey)")).fetchOne(db)
19531953
{
19541954
serverRecord.update(
19551955
with: allFields,
1956-
row: T(queryOutput: row),
1957-
columnNames: &columnNames,
1956+
clientRow: T(queryOutput: row),
1957+
clientUserModificationTime: metadata.userModificationTime,
1958+
columnNamesToUpsert: &columnNamesToUpsert,
19581959
parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1
19591960
? foreignKeysByTableName[T.tableName]?.first
19601961
: nil
@@ -1963,7 +1964,7 @@
19631964

19641965
do {
19651966
try $_currentZoneID.withValue(serverRecord.recordID.zoneID) {
1966-
try #sql(upsert(table, record: serverRecord, columnNames: columnNames)).execute(db)
1967+
try #sql(upsert(table, record: serverRecord, columnNames: columnNamesToUpsert)).execute(db)
19671968
}
19681969
try UnsyncedRecordID.find(serverRecord.recordID).delete().execute(db)
19691970
try SyncMetadata

Tests/SQLiteDataTests/CloudKitTests/MergeConflictTests.swift

Lines changed: 189 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -899,11 +899,53 @@
899899
// Step 6: Fetch arrives (no-op, conflict already resolved)
900900
await fetchedRecordZoneChangesCallback.notify()
901901

902-
await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") {
903-
try await userDatabase.read { db in
904-
let post = try #require(try Post.find(1).fetchOne(db))
905-
#expect(post.title == "Hello from server")
906-
}
902+
assertQuery(
903+
Post.find(1)
904+
.join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
905+
.select {
906+
SyncedRow<Post>.Columns(
907+
row: $0,
908+
userModificationTime: $1.userModificationTime
909+
)
910+
},
911+
database: userDatabase.database
912+
) {
913+
"""
914+
┌─────────────────────────────────┐
915+
│ SyncedRow( │
916+
│ row: Post( │
917+
│ id: 1, │
918+
│ title: "Hello from server", │
919+
│ body: nil, │
920+
│ isPublished: false │
921+
│ ), │
922+
│ userModificationTime: 60 │
923+
│ ) │
924+
└─────────────────────────────────┘
925+
"""
926+
}
927+
assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) {
928+
"""
929+
MockCloudDatabase(
930+
databaseScope: .private,
931+
storage: [
932+
[0]: CKRecord(
933+
recordID: CKRecord.ID(1:posts/zone/__defaultOwner__),
934+
recordType: "posts",
935+
parent: nil,
936+
share: nil,
937+
body🗓️: 0,
938+
id: 1,
939+
id🗓️: 0,
940+
isPublished: 0,
941+
isPublished🗓️: 0,
942+
title: "Hello from server",
943+
title🗓️: 60,
944+
🗓️: 60
945+
)
946+
]
947+
)
948+
"""
907949
}
908950
}
909951

@@ -1119,11 +1161,53 @@
11191161
// Step 6: Retry send
11201162
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
11211163

1122-
await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") {
1123-
try await userDatabase.read { db in
1124-
let post = try #require(try Post.find(1).fetchOne(db))
1125-
#expect(post.title == "Hello from server")
1126-
}
1164+
assertQuery(
1165+
Post.find(1)
1166+
.join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
1167+
.select {
1168+
SyncedRow<Post>.Columns(
1169+
row: $0,
1170+
userModificationTime: $1.userModificationTime
1171+
)
1172+
},
1173+
database: userDatabase.database
1174+
) {
1175+
"""
1176+
┌─────────────────────────────────┐
1177+
│ SyncedRow( │
1178+
│ row: Post( │
1179+
│ id: 1, │
1180+
│ title: "Hello from server", │
1181+
│ body: nil, │
1182+
│ isPublished: false │
1183+
│ ), │
1184+
│ userModificationTime: 60 │
1185+
│ ) │
1186+
└─────────────────────────────────┘
1187+
"""
1188+
}
1189+
assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) {
1190+
"""
1191+
MockCloudDatabase(
1192+
databaseScope: .private,
1193+
storage: [
1194+
[0]: CKRecord(
1195+
recordID: CKRecord.ID(1:posts/zone/__defaultOwner__),
1196+
recordType: "posts",
1197+
parent: nil,
1198+
share: nil,
1199+
body🗓️: 0,
1200+
id: 1,
1201+
id🗓️: 0,
1202+
isPublished: 0,
1203+
isPublished🗓️: 0,
1204+
title: "Hello from server",
1205+
title🗓️: 60,
1206+
🗓️: 60
1207+
)
1208+
]
1209+
)
1210+
"""
11271211
}
11281212
}
11291213

@@ -1416,11 +1500,53 @@
14161500
// Step 5: Send (merged result)
14171501
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
14181502

1419-
await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") {
1420-
try await userDatabase.read { db in
1421-
let post = try #require(try Post.find(1).fetchOne(db))
1422-
#expect(post.title == "Hello from server")
1423-
}
1503+
assertQuery(
1504+
Post.find(1)
1505+
.join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
1506+
.select {
1507+
SyncedRow<Post>.Columns(
1508+
row: $0,
1509+
userModificationTime: $1.userModificationTime
1510+
)
1511+
},
1512+
database: userDatabase.database
1513+
) {
1514+
"""
1515+
┌─────────────────────────────────┐
1516+
│ SyncedRow( │
1517+
│ row: Post( │
1518+
│ id: 1, │
1519+
│ title: "Hello from server", │
1520+
│ body: nil, │
1521+
│ isPublished: false │
1522+
│ ), │
1523+
│ userModificationTime: 60 │
1524+
│ ) │
1525+
└─────────────────────────────────┘
1526+
"""
1527+
}
1528+
assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) {
1529+
"""
1530+
MockCloudDatabase(
1531+
databaseScope: .private,
1532+
storage: [
1533+
[0]: CKRecord(
1534+
recordID: CKRecord.ID(1:posts/zone/__defaultOwner__),
1535+
recordType: "posts",
1536+
parent: nil,
1537+
share: nil,
1538+
body🗓️: 0,
1539+
id: 1,
1540+
id🗓️: 0,
1541+
isPublished: 0,
1542+
isPublished🗓️: 0,
1543+
title: "Hello from server",
1544+
title🗓️: 60,
1545+
🗓️: 60
1546+
)
1547+
]
1548+
)
1549+
"""
14241550
}
14251551
}
14261552

@@ -1642,11 +1768,54 @@
16421768
// Step 6: Fetch arrives (no-op, conflict already resolved)
16431769
await fetchedRecordZoneChangesCallback.notify()
16441770

1645-
await withKnownIssue("Server should win same-field conflict when it has a newer timestamp") {
1646-
try await userDatabase.read { db in
1647-
let post = try #require(try Post.find(1).fetchOne(db))
1648-
#expect(post.body == "Server body")
1649-
}
1771+
assertQuery(
1772+
Post.find(1)
1773+
.join(SyncMetadata.all) { $0.syncMetadataID.eq($1.id) }
1774+
.select {
1775+
SyncedRow<Post>.Columns(
1776+
row: $0,
1777+
userModificationTime: $1.userModificationTime
1778+
)
1779+
},
1780+
database: userDatabase.database
1781+
) {
1782+
"""
1783+
┌────────────────────────────┐
1784+
│ SyncedRow( │
1785+
│ row: Post( │
1786+
│ id: 1, │
1787+
│ title: "Hello", │
1788+
│ body: "Server body", │
1789+
│ isPublished: false │
1790+
│ ), │
1791+
│ userModificationTime: 60 │
1792+
│ ) │
1793+
└────────────────────────────┘
1794+
"""
1795+
}
1796+
assertInlineSnapshot(of: container.privateCloudDatabase, as: .customDump) {
1797+
"""
1798+
MockCloudDatabase(
1799+
databaseScope: .private,
1800+
storage: [
1801+
[0]: CKRecord(
1802+
recordID: CKRecord.ID(1:posts/zone/__defaultOwner__),
1803+
recordType: "posts",
1804+
parent: nil,
1805+
share: nil,
1806+
body: "Server body",
1807+
body🗓️: 60,
1808+
id: 1,
1809+
id🗓️: 0,
1810+
isPublished: 0,
1811+
isPublished🗓️: 0,
1812+
title: "Hello",
1813+
title🗓️: 0,
1814+
🗓️: 60
1815+
)
1816+
]
1817+
)
1818+
"""
16501819
}
16511820
}
16521821

0 commit comments

Comments
 (0)