Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@
with other: CKRecord,
row: T,
columnNames: inout [String],
parentForeignKey: ForeignKey?
parentForeignKey: ForeignKey?,
syncEngineHasPendingChanges: Bool
) {
typealias EquatableCKRecordValueProtocol = CKRecordValueProtocol & Equatable

Expand Down Expand Up @@ -323,7 +324,7 @@
return false
}
}
if didSet || isRowValueModified {
if didSet || (syncEngineHasPendingChanges && isRowValueModified) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a somewhat unrelated fixed, but it was exacerbated by the date problem. We should only skip updating a local field of a record if there are pending iCloud changes for that record. That's because in that situation we interpret that to mean the user made local changes that will eventually be sent out to iCloud.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, how is it possible for a row to have a modification without having pending changes enqueued in the sync engine? Aren’t row changes automatically picked up by a trigger and enqueued?

Or is this a guardrail against false positives from the equality check? If so, would it really work, as there might be unrelated changes to other fields of the same record?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukaskubanek It should not be possible, but 1) the user of the library could do something bad like make changes to the database with a different SQLite connection than what the SyncEngine has, and 2) some types may not roundtrip cleanly to and from SQLite (such as dates). Whenever either of those happen we don't want that field to be forever stuck and never update. Adding this condition allows us to kick things in the butt if we detect a change to a field that has no corresponding pending change.

If so, would it really work, as there might be unrelated changes to other fields of the same record?

Yeah, I believe this is not 100% foolproof, but it works one particular use case now, and we have good test coverage on it, so in the future we could explore more robust solutions.

columnNames.removeAll(where: { $0 == key })
if didSet, let parentForeignKey, key == parentForeignKey.from {
self.parent = other.parent
Expand Down
29 changes: 21 additions & 8 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -831,14 +831,14 @@
}
return
}
let oldSyncEngine = self.syncEngines.withValue {
oldZoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared
}
let syncEngine = self.syncEngines.withValue {
syncEngine(for: oldZoneID)?.state.add(pendingRecordZoneChanges: oldChanges)
syncEngine(for: zoneID)?.state.add(pendingRecordZoneChanges: newChanges)
}

fileprivate func syncEngine(for zoneID: CKRecordZone.ID) -> (any SyncEngineProtocol)? {
syncEngines.withValue {
zoneID.ownerName == CKCurrentUserDefaultName ? $0.private : $0.shared
}
oldSyncEngine?.state.add(pendingRecordZoneChanges: oldChanges)
syncEngine?.state.add(pendingRecordZoneChanges: newChanges)
}

@DatabaseFunction(
Expand Down Expand Up @@ -1941,7 +1941,19 @@
columnNames: &columnNames,
parentForeignKey: foreignKeysByTableName[T.tableName]?.count == 1
? foreignKeysByTableName[T.tableName]?.first
: nil
: nil,
syncEngineHasPendingChanges: syncEngine(for: serverRecord.recordID.zoneID)?.state
.pendingRecordZoneChanges.contains {
switch $0 {
case .saveRecord(let recordID):
return recordID == serverRecord.recordID
case .deleteRecord:
return false
@unknown default:
return false
}
}
?? false
)
}

Expand Down Expand Up @@ -2045,7 +2057,8 @@
if data == nil {
reportIssue("Asset data not found on disk")
}
return "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)"
return
"\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)"
} else {
return """
\(quote: columnName) = \
Expand Down
10 changes: 8 additions & 2 deletions Sources/SQLiteData/Internal/ISO8601.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import Foundation
extension Date {
@usableFromInline
var iso8601String: String {
let nextUpDate = Date(timeIntervalSinceReferenceDate: timeIntervalSinceReferenceDate.nextUp)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix for the date issue.

if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
return formatted(.iso8601.currentTimestamp(includingFractionalSeconds: true))
return
nextUpDate
.formatted(
.iso8601.currentTimestamp(includingFractionalSeconds: true)
)
} else {
return DateFormatter.iso8601(includingFractionalSeconds: true).string(from: self)
return DateFormatter.iso8601(includingFractionalSeconds: true)
.string(from: nextUpDate)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,66 @@
let record = CKRecord(recordType: "foo", recordID: CKRecord.ID(recordName: "bar"))
try await syncEngine.modifyRecords(scope: .private, saving: [record]).notify()
}

/*
* Local client has sync'd record.
* Local record is edited outside the sync engine (somehow)
* New record received from iCloud
=> Server record overwrites local changes
*/
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func serverRecordOverwritesLocalChangesWhenNoPendingChanges() async throws {
try await userDatabase.userWrite { db in
try db.seed {
RemindersList(id: 1, title: "Personal")
}
}
try await syncEngine.processPendingRecordZoneChanges(scope: .private)

try await userDatabase.write { db in
try RemindersList.update { $0.title = "My stuff" }.execute(db)
}

try await withDependencies {
$0.currentTime.now += 1
} operation: {
let remindersListRecord = try syncEngine.private.database.record(
for: RemindersList.recordID(for: 1)
)
remindersListRecord.setValue("Personal!", forKey: "title", at: now)
try await syncEngine.modifyRecords(scope: .private, saving: [remindersListRecord]).notify()
}

try await userDatabase.read { db in
expectNoDifference(
try RemindersList.fetchAll(db),
[RemindersList(id: 1, title: "Personal!")]
)
}
assertInlineSnapshot(of: syncEngine.container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__),
recordType: "remindersLists",
parent: nil,
share: nil,
id: 1,
title: "Personal!"
)
]
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: []
)
)
"""
}
}
}
}
#endif
3 changes: 1 addition & 2 deletions Tests/SQLiteDataTests/CloudKitTests/PreviewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@
}
}

@Test
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
func delete() async throws {
@Test func delete() async throws {
@FetchAll(RemindersList.all, database: userDatabase.database) var remindersLists

try await userDatabase.userWrite { db in
Expand Down
51 changes: 51 additions & 0 deletions Tests/SQLiteDataTests/DateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import DependenciesTestSupport
import Foundation
import SQLiteData
import SQLiteDataTestSupport
import Testing

@Suite(.dependency(\.defaultDatabase, try .database()))
struct DateTests {
@Dependency(\.defaultDatabase) var database

@Test func roundtrip() throws {
let date = Date(timeIntervalSinceReferenceDate: 793109282.061)
let insertedRecord = try database.write { db in
try Record.insert { Record.Draft(date: date) }
.returning(\.self)
.fetchOne(db)!
}
let updatedRecord = try database.write { db in
try Record
.update(insertedRecord)
.returning(\.self)
.fetchOne(db)!
}
#expect(insertedRecord.date == date)
#expect(insertedRecord.date == updatedRecord.date)
}
}

@Table
private struct Record: Equatable {
let id: Int
var date: Date
}

extension DatabaseWriter where Self == DatabaseQueue {
fileprivate static func database() throws -> DatabaseQueue {
let database = try DatabaseQueue()
try database.write { db in
try #sql(
"""
CREATE TABLE "records" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL
) STRICT
"""
)
.execute(db)
}
return database
}
}