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
6 changes: 6 additions & 0 deletions Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@
get { self[#function] }
set { self[#function] = newValue }
}

/// Whether this record came back from the server: only saved records carry a change tag.
/// (The mock database stores its change tag in `_recordChangeTag`.)
package var hasServerChangeTag: Bool {
recordChangeTag != nil || _recordChangeTag != nil
}
}

extension DataProtocol {
Expand Down
30 changes: 30 additions & 0 deletions Sources/SQLiteData/CloudKit/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,19 @@
await clearServerRecord()

case .referenceViolation:
// NB: The server does not have this record's parent. That only means the parent was
// deleted if the server ever confirmed it. A parent the server never confirmed was
// never uploaded (for example, its save failed with an error that is not retried),
// so applying the 'ON DELETE' action here would delete the child and every record
// that cascades from it. Queue both again instead: parents sort before their
// children, so the parent is sent first.
if let parentRecordID = failedRecord.parent?.recordID,
await isAwaitingFirstUpload(parentRecordID)
{
newPendingRecordZoneChanges.append(.saveRecord(parentRecordID))
newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
continue
}
guard
let recordPrimaryKey = failedRecord.recordID.recordPrimaryKey,
let table = tablesByName[failedRecord.recordType],
Expand Down Expand Up @@ -2048,6 +2061,23 @@
}
}

/// Whether a record exists locally but the server has never confirmed a save of it.
///
/// Only records returned by the server carry a change tag. A last known server record without
/// one was built locally for a send that never succeeded.
private func isAwaitingFirstUpload(_ recordID: CKRecord.ID) async -> Bool {
await withErrorReporting(.sqliteDataCloudKitFailure) {
try await metadatabase.read { db in
guard
let metadata = try SyncMetadata.find(recordID).fetchOne(db),
!metadata._isDeleted
else { return false }
return !(metadata.lastKnownServerRecord?.hasServerChangeTag ?? false)
}
}
?? false
}

private func refreshLastKnownServerRecord(_ record: CKRecord) async {
await withErrorReporting(.sqliteDataCloudKitFailure) {
try await userDatabase.write { db in
Expand Down
80 changes: 80 additions & 0 deletions Tests/SQLiteDataTests/CloudKitTests/ReferenceViolationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,90 @@
import SQLiteDataTestSupport
import SnapshotTestingCustomDump
import Testing
import TestLocals

extension BaseCloudKitTests {
@MainActor
final class ReferenceViolationTests: BaseCloudKitTests, @unchecked Sendable {
// * The user's iCloud storage is full.
// * Local client creates a list and a reminder in it. Both saves fail with
// 'quotaExceeded' and wait until a later save succeeds.
// * The user frees up space, then adds another reminder to the list.
// => The new reminder is kept, and the list and both reminders are uploaded.
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test(.taskLocal($quota, 0))
func addReminderAfterListFailedToSaveWithQuotaExceeded() async throws {
try await userDatabase.userWrite { db in
try db.seed {
RemindersList(id: 1, title: "Personal")
Reminder(id: 1, title: "Get milk", remindersListID: 1)
}
}
try await syncEngine.processPendingRecordZoneChanges(scope: .private)

syncEngine.private.database.state.withValue { $0.quota = .max }
try await userDatabase.userWrite { db in
try Reminder.insert { Reminder(id: 2, title: "Get bread", remindersListID: 1) }
.execute(db)
}
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
try await syncEngine.processPendingRecordZoneChanges(scope: .private)

let reminders = try await userDatabase.read { db in
try Reminder.order(by: \.id).fetchAll(db)
}
#expect(
reminders == [
Reminder(id: 1, title: "Get milk", remindersListID: 1),
Reminder(id: 2, title: "Get bread", remindersListID: 1),
]
)
assertInlineSnapshot(of: container, as: .customDump) {
"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
storage: [
[0]: CKRecord(
recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__),
recordType: "reminders",
parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)),
share: nil,
id: 1,
isCompleted: 0,
remindersListID: 1,
title: "Get milk"
),
[1]: CKRecord(
recordID: CKRecord.ID(2:reminders/zone/__defaultOwner__),
recordType: "reminders",
parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)),
share: nil,
id: 2,
isCompleted: 0,
remindersListID: 1,
title: "Get bread"
),
[2]: CKRecord(
recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__),
recordType: "remindersLists",
parent: nil,
share: nil,
id: 1,
title: "Personal"
)
]
),
sharedCloudDatabase: MockCloudDatabase(
databaseScope: .shared,
storage: []
)
)
"""
}
}

// * Local client moves a reminder to a list.
// * At same time, remote deletes that list.
// => When data is synchronized the reminder and list are deleted.
Expand Down