forked from apple/sample-cloudkit-sync-engine
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSyncedDatabase.swift
More file actions
394 lines (315 loc) · 16.9 KB
/
Copy pathSyncedDatabase.swift
File metadata and controls
394 lines (315 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
//
// SyncedDatabase.swift
// SyncEngine
//
import CloudKit
import Foundation
import os.log
final actor SyncedDatabase : Sendable, ObservableObject {
/// The CloudKit container to sync with.
static let container: CKContainer = CKContainer(identifier: "iCloud.com.apple.samples.cloudkit.SyncEngine")
/// The sync engine being used to sync.
/// This is lazily initialized. You can re-initialize the sync engine by setting `_syncEngine` to nil then calling `self.syncEngine`.
var syncEngine: CKSyncEngine {
if _syncEngine == nil {
self.initializeSyncEngine()
}
return _syncEngine!
}
var _syncEngine: CKSyncEngine?
/// True if we want the sync engine to sync automatically.
/// This should always be true in a production app, but we set this to false when testing.
let automaticallySync: Bool
/// The data to be used by the app's UI.
@MainActor @Published var viewModel = AppData()
/// The actual data for the app.
/// If you're accessing this from the UI, you should use `viewModel` instead.
var appData: AppData {
didSet {
// When the data is modified, let's update the view model.
Task {
let appData = self.appData
await MainActor.run {
self.viewModel = appData
}
}
}
}
/// The file URL we use to store our data.
/// Using a different file in the tests allows us to keep our test data and app data separate.
let dataURL: URL
/// The default data URL used to store data in the app.
static let defaultDataURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appending(component: "Contacts").appendingPathExtension("json")
init(automaticallySync: Bool = true, dataURL: URL = defaultDataURL) {
// Load the data from disk.
// Note that this is not a very efficient way to store data, but this is a sample app.
self.dataURL = dataURL
do {
let appDataBlob = try Data(contentsOf: dataURL)
self.appData = try JSONDecoder().decode(AppData.self, from: appDataBlob)
} catch {
// In a real app, we'd likely have much better error recovery here.
// However, in a sample application, let's just start from scratch.
Logger.database.error("Failed to load app data: \(error)")
self.appData = AppData()
}
self.automaticallySync = automaticallySync
Task {
/// We want to initialize our sync engine lazily, but we also want to make sure it happens pretty soon after launch.
await self.initializeSyncEngine()
}
}
func initializeSyncEngine() {
var configuration = CKSyncEngine.Configuration(
database: Self.container.privateCloudDatabase,
stateSerialization: self.appData.stateSerialization,
delegate: self
)
configuration.automaticallySync = self.automaticallySync
let syncEngine = CKSyncEngine(configuration)
_syncEngine = syncEngine
Logger.database.log("Initialized sync engine: \(syncEngine)")
}
}
// MARK: - CKSyncEngineDelegate
extension SyncedDatabase : CKSyncEngineDelegate {
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
Logger.database.debug("Handling event \(event)")
switch event {
case .stateUpdate(let event):
self.appData.stateSerialization = event.stateSerialization
try? self.persistLocalData() // This error should be handled, but we'll skip that for brevity in this sample app.
case .accountChange(let event):
self.handleAccountChange(event)
case .fetchedDatabaseChanges(let event):
self.handleFetchedDatabaseChanges(event)
case .fetchedRecordZoneChanges(let event):
self.handleFetchedRecordZoneChanges(event)
case .sentRecordZoneChanges(let event):
self.handleSentRecordZoneChanges(event)
case .sentDatabaseChanges:
// The sample app doesn't track sent database changes in any meaningful way, but this might be useful depending on your data model.
break
case .willFetchChanges, .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .didFetchChanges, .willSendChanges, .didSendChanges:
// We don't do anything here in the sample app, but these events might be helpful if you need to do any setup/cleanup when sync starts/ends.
break
@unknown default:
Logger.database.info("Received unknown event: \(event)")
}
}
func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
Logger.database.info("Returning next record change batch for context: \(context)")
let scope = context.options.scope
let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) }
let contacts = self.appData.contacts
let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in
if let contact = contacts[recordID.recordName] {
let record = contact.lastKnownRecord ?? CKRecord(recordType: Contact.recordType, recordID: recordID)
contact.populateRecord(record)
return record
} else {
// We might have pending changes that no longer exist in our database. We can remove those from the state.
syncEngine.state.remove(pendingRecordZoneChanges: [ .saveRecord(recordID) ])
return nil
}
}
return batch
}
// MARK: - CKSyncEngine Events
func handleFetchedRecordZoneChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
for modification in event.modifications {
// The sync engine fetched a record, and we want to merge it into our local persistence.
// If we already have this object locally, let's merge the data from the server.
// Otherwise, let's create a new local object.
let record = modification.record
let id = record.recordID.recordName
Logger.database.log("Received contact modification: \(record.recordID)")
var contact: Contact = self.appData.contacts[id] ?? Contact(id: id)
contact.mergeFromServerRecord(record)
contact.setLastKnownRecordIfNewer(record)
self.appData.contacts[id] = contact
}
for deletion in event.deletions {
// A record was deleted on the server, so let's remove it from our local persistence.
Logger.database.log("Received contact deletion: \(deletion.recordID)")
let id = deletion.recordID.recordName
self.appData.contacts[id] = nil
}
// If we had any changes, let's save to disk.
if !event.modifications.isEmpty || !event.deletions.isEmpty {
try? self.persistLocalData() // This error should be handled, but we'll skip that for brevity in this sample app.
}
}
func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) {
// If a zone was deleted, we should delete everything for that zone locally.
var needsToSave = false
for deletion in event.deletions {
switch deletion.zoneID.zoneName {
case Contact.zoneName:
self.appData.contacts = [:]
needsToSave = true
default:
Logger.database.info("Received deletion for unknown zone: \(deletion.zoneID)")
}
}
if needsToSave {
try? self.persistLocalData() // This error should be handled, but we'll skip that for brevity in this sample app.
}
}
func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) {
// If we failed to save a record, we might want to retry depending on the error code.
var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]()
var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]()
// Update the last known server record for each of the saved records.
for savedRecord in event.savedRecords {
let id = savedRecord.recordID.recordName
if var contact = self.appData.contacts[id] {
contact.setLastKnownRecordIfNewer(savedRecord)
self.appData.contacts[id] = contact
}
}
// Handle any failed record saves.
for failedRecordSave in event.failedRecordSaves {
let failedRecord = failedRecordSave.record
let contactID = failedRecord.recordID.recordName
var shouldClearServerRecord = false
switch failedRecordSave.error.code {
case .serverRecordChanged:
// Let's merge the record from the server into our own local copy.
// The `mergeFromServerRecord` function takes care of the conflict resolution.
guard let serverRecord = failedRecordSave.error.serverRecord else {
Logger.database.error("No server record for conflict \(failedRecordSave.error)")
continue
}
guard var contact = self.appData.contacts[contactID] else {
Logger.database.error("No local object for conflict \(failedRecordSave.error)")
continue
}
contact.mergeFromServerRecord(serverRecord)
contact.setLastKnownRecordIfNewer(serverRecord)
self.appData.contacts[contactID] = contact
newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
case .zoneNotFound:
// Looks like we tried to save a record in a zone that doesn't exist.
// Let's save that zone and retry saving the record.
// Also clear the last known server record if we have one, it's no longer valid.
let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID)
newPendingDatabaseChanges.append(.saveZone(zone))
newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
shouldClearServerRecord = true
case .unknownItem:
// We tried to save a record with a locally-cached server record, but that record no longer exists on the server.
// This might mean that another device deleted the record, but we still have the data for that record locally.
// We have the choice of either deleting the local data or re-uploading the local data.
// For this sample app, let's re-upload the local data.
newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
shouldClearServerRecord = true
case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled:
// There are several errors that the sync engine will automatically retry, let's just log and move on.
Logger.database.debug("Retryable error saving \(failedRecord.recordID): \(failedRecordSave.error)")
default:
// We got an error, but we don't know what it is or how to handle it.
// If you have any sort of telemetry system, you should consider tracking this scenario so you can understand which errors you see in the wild.
Logger.database.fault("Unknown error saving record \(failedRecord.recordID): \(failedRecordSave.error)")
}
if shouldClearServerRecord {
if var contact = self.appData.contacts[contactID] {
contact.lastKnownRecord = nil
self.appData.contacts[contactID] = contact
}
}
}
self.syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges)
self.syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges)
// Now that we've processed the batch, save to disk.
try? self.persistLocalData()
}
func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) {
// Handling account changes can be tricky.
//
// If the user signed out of their account, we want to delete all local data.
// However, what if there's some data that hasn't been uploaded yet?
// Should we keep that data? Prompt the user to keep the data? Or just delete it?
//
// Also, what if the user signs in to a new account, and there's already some data locally?
// Should we upload it to their account? Or should we delete it?
//
// Finally, what if the user signed in, but they were signed into a previous account before?
//
// Since we're in a sample app, we're going to take a relatively simple approach.
let shouldDeleteLocalData: Bool
let shouldReUploadLocalData: Bool
switch event.changeType {
case .signIn:
shouldDeleteLocalData = false
shouldReUploadLocalData = true
case .switchAccounts:
shouldDeleteLocalData = true
shouldReUploadLocalData = false
case .signOut:
shouldDeleteLocalData = true
shouldReUploadLocalData = false
@unknown default:
Logger.database.log("Unknown account change type: \(event)")
shouldDeleteLocalData = false
shouldReUploadLocalData = false
}
if shouldDeleteLocalData {
try? self.deleteLocalData() // This error should be handled, but we'll skip that for brevity in this sample app.
}
if shouldReUploadLocalData {
let recordZoneChanges: [CKSyncEngine.PendingRecordZoneChange] = self.appData.contacts.values.map { .saveRecord($0.recordID) }
self.syncEngine.state.add(pendingDatabaseChanges: [ .saveZone(CKRecordZone(zoneName: Contact.zoneName)) ])
self.syncEngine.state.add(pendingRecordZoneChanges: recordZoneChanges)
}
}
}
// MARK: - Data
extension SyncedDatabase {
func saveContacts(_ contacts: [Contact]) throws {
for var contact in contacts {
// Make sure we don't accidentally overwrite the existing last known record.
if let existingRecord = self.appData.contacts[contact.id]?.lastKnownRecord {
contact.setLastKnownRecordIfNewer(existingRecord)
}
self.appData.contacts[contact.id] = contact
}
try self.persistLocalData()
let pendingSaves: [CKSyncEngine.PendingRecordZoneChange] = contacts.map { .saveRecord($0.recordID) }
self.syncEngine.state.add(pendingRecordZoneChanges: pendingSaves)
}
func deleteContacts(_ ids: [Contact.ID]) throws {
let contacts = ids.compactMap { self.appData.contacts[$0] }
for id in ids {
self.appData.contacts[id] = nil
}
try self.persistLocalData()
let pendingDeletions: [CKSyncEngine.PendingRecordZoneChange] = contacts.map { .deleteRecord($0.recordID) }
self.syncEngine.state.add(pendingRecordZoneChanges: pendingDeletions)
}
func deleteLocalData() throws {
Logger.database.info("Deleting local data")
self.appData = AppData()
try self.persistLocalData()
// If we're deleting everything, we need to clear out all our sync engine state too.
// In order to do that, let's re-initialize our sync engine.
self.initializeSyncEngine()
}
func persistLocalData() throws {
Logger.database.debug("Saving to disk")
do {
let data = try JSONEncoder().encode(self.appData)
try data.write(to: self.dataURL)
} catch {
Logger.database.error("Failed to save to disk: \(error)")
throw error
}
}
func deleteServerData() async throws {
Logger.database.info("Deleting server data")
// Our data is all in a single zone. Let's delete that zone now.
let zoneID = CKRecordZone.ID(zoneName: Contact.zoneName)
self.syncEngine.state.add(pendingDatabaseChanges: [ .deleteZone(zoneID) ])
try await self.syncEngine.sendChanges()
}
}