Skip to content

Commit cc69401

Browse files
binaryunaryCopilot
andcommitted
Resend records that were sent but never saved when signing in
Signing in clears CKSyncEngine's pending changes, so `.signIn` re-enqueues the records CloudKit does not know about: `enqueueUnknownRecordsForCloudKit` touches every row without a last known server record. A record that was sent but never saved has one, though, because a record is stored as its own last known server record when it is built for sending. With pointfreeco#548 a save rejected with `quotaExceeded` is retried, but if the user signs out of iCloud, keeps their local data and signs back in, the retry is dropped with the other pending changes, and the record is never uploaded unless it is edited again. Also enqueue live rows whose last known server record has not been saved to the server. Their archives are decoded from a metadatabase cursor before the write, and the updates are batched to stay under SQLite's bound parameter limit. Covered by `saveWhileQuotaExceededThenSignOutAndIn`, which fails without this change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 2deb45f commit cc69401

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

‎Sources/SQLiteData/CloudKit/SyncEngine.swift‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,13 +664,48 @@
664664
}
665665

666666
private func enqueueUnknownRecordsForCloudKit() async throws {
667+
let unsavedRecordIDs = try await unsavedRecordIDs()
667668
try await userDatabase.write { db in
668669
try $_isSynchronizingChanges.withValue(false) {
669670
try SyncMetadata
670671
.where { !$0.hasLastKnownServerRecord }
671672
.update { $0.recordPrimaryKey = $0.recordPrimaryKey }
672673
.execute(db)
674+
// NB: Batched to stay well under SQLite's bound parameter limit.
675+
for start in stride(from: 0, to: unsavedRecordIDs.count, by: 500) {
676+
try SyncMetadata
677+
.findAll(unsavedRecordIDs[start..<min(start + 500, unsavedRecordIDs.count)])
678+
.update { $0.recordPrimaryKey = $0.recordPrimaryKey }
679+
.execute(db)
680+
}
681+
}
682+
}
683+
}
684+
685+
/// Records that were sent to CloudKit but never saved there, for example because the user's
686+
/// iCloud storage was full.
687+
///
688+
/// A record is stored as its own last known server record when it is built for sending, so
689+
/// unlike a record that was never sent it is not caught by `!hasLastKnownServerRecord`. Signing
690+
/// in clears the sync engine's pending changes, so these records must be enqueued again.
691+
private func unsavedRecordIDs() async throws -> [CKRecord.ID] {
692+
try await metadatabase.read { db in
693+
var recordIDs: [CKRecord.ID] = []
694+
let rows = try SyncMetadata
695+
.where { $0.hasLastKnownServerRecord && !$0._isDeleted }
696+
.select { ($0.recordName, $0.zoneName, $0.ownerName, $0.lastKnownServerRecord) }
697+
.fetchCursor(db)
698+
while let (recordName, zoneName, ownerName, lastKnownServerRecord) = try rows.next() {
699+
guard lastKnownServerRecord?.hasBeenSavedToServer != true
700+
else { continue }
701+
recordIDs.append(
702+
CKRecord.ID(
703+
recordName: recordName,
704+
zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
705+
)
706+
)
673707
}
708+
return recordIDs
674709
}
675710
}
676711

‎Tests/SQLiteDataTests/CloudKitTests/QuotaExceededTests.swift‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import SQLiteData
77
import SQLiteDataTestSupport
88
import SnapshotTestingCustomDump
9+
import TestLocals
910
import Testing
1011

1112
extension BaseCloudKitTests {
@@ -69,6 +70,71 @@
6970
"""
7071
}
7172
}
73+
74+
// * Local client saves records while the user's iCloud storage is full.
75+
// * User signs out of iCloud, keeps local data, and signs back in.
76+
// * User frees up iCloud storage.
77+
// => Records are uploaded.
78+
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
79+
@Test(.taskLocal($syncEngineDelegate, MyDelegate()))
80+
func saveWhileQuotaExceededThenSignOutAndIn() async throws {
81+
setQuotaExceeded(true)
82+
try await userDatabase.userWrite { db in
83+
try db.seed {
84+
RemindersList(id: 1, title: "Personal")
85+
Reminder(id: 1, title: "Get milk", remindersListID: 1)
86+
}
87+
}
88+
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
89+
90+
await signOut()
91+
await signIn()
92+
try await syncEngine.processPendingDatabaseChanges(scope: .private)
93+
94+
#expect(
95+
Set(syncEngine.private.state.pendingRecordZoneChanges) == [
96+
.saveRecord(RemindersList.recordID(for: 1)),
97+
.saveRecord(Reminder.recordID(for: 1)),
98+
]
99+
)
100+
101+
setQuotaExceeded(false)
102+
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
103+
104+
assertInlineSnapshot(of: container, as: .customDump) {
105+
"""
106+
MockCloudContainer(
107+
privateCloudDatabase: MockCloudDatabase(
108+
databaseScope: .private,
109+
storage: [
110+
[0]: CKRecord(
111+
recordID: CKRecord.ID(1:reminders/zone/__defaultOwner__),
112+
recordType: "reminders",
113+
parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)),
114+
share: nil,
115+
id: 1,
116+
isCompleted: 0,
117+
remindersListID: 1,
118+
title: "Get milk"
119+
),
120+
[1]: CKRecord(
121+
recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__),
122+
recordType: "remindersLists",
123+
parent: nil,
124+
share: nil,
125+
id: 1,
126+
title: "Personal"
127+
)
128+
]
129+
),
130+
sharedCloudDatabase: MockCloudDatabase(
131+
databaseScope: .shared,
132+
storage: []
133+
)
134+
)
135+
"""
136+
}
137+
}
72138
}
73139
}
74140
#endif

0 commit comments

Comments
 (0)