@expo-kit/cloudkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +145 -2
  2. package/app.plugin.js +1 -0
  3. package/build/codec.d.ts +6 -0
  4. package/build/codec.d.ts.map +1 -0
  5. package/build/codec.js +34 -0
  6. package/build/codec.js.map +1 -0
  7. package/build/index.d.ts +7 -0
  8. package/build/index.d.ts.map +1 -0
  9. package/build/index.js +9 -0
  10. package/build/index.js.map +1 -0
  11. package/build/native.d.ts +86 -0
  12. package/build/native.d.ts.map +1 -0
  13. package/build/native.js +7 -0
  14. package/build/native.js.map +1 -0
  15. package/build/schema.d.ts +98 -0
  16. package/build/schema.d.ts.map +1 -0
  17. package/build/schema.js +83 -0
  18. package/build/schema.js.map +1 -0
  19. package/build/sync.d.ts +78 -0
  20. package/build/sync.d.ts.map +1 -0
  21. package/build/sync.js +236 -0
  22. package/build/sync.js.map +1 -0
  23. package/build/use-cloud-sync.d.ts +5 -0
  24. package/build/use-cloud-sync.d.ts.map +1 -0
  25. package/build/use-cloud-sync.js +6 -0
  26. package/build/use-cloud-sync.js.map +1 -0
  27. package/expo-module.config.json +7 -0
  28. package/ios/CloudSyncEngineObject.swift +62 -0
  29. package/ios/ExpoKitCloudKit.podspec +28 -0
  30. package/ios/ExpoKitCloudKitModule.swift +50 -0
  31. package/ios/LocalStore.swift +351 -0
  32. package/ios/PushSubscriber.swift +14 -0
  33. package/ios/RecordMapper.swift +237 -0
  34. package/ios/Records.swift +60 -0
  35. package/ios/SchemaModel.swift +68 -0
  36. package/ios/SyncCore.swift +586 -0
  37. package/package.json +61 -4
  38. package/plugin/build/index.d.ts +7 -0
  39. package/plugin/build/index.js +68 -0
  40. package/src/codec.ts +36 -0
  41. package/src/index.ts +26 -0
  42. package/src/native.ts +83 -0
  43. package/src/schema.ts +169 -0
  44. package/src/sync.ts +322 -0
  45. package/src/use-cloud-sync.ts +9 -0
@@ -0,0 +1,68 @@
1
+ import Foundation
2
+
3
+ // Plain Swift model of the app's schema. No Expo types here, so it can be tested on its own.
4
+
5
+ enum FieldKind: String, CaseIterable {
6
+ case string, int, double, bool, date, stringList, intList, doubleList, reference, asset
7
+ }
8
+
9
+ struct FieldModel: Equatable {
10
+ let name: String
11
+ let kind: FieldKind
12
+ let optional: Bool
13
+ /// For references: the record type it links to.
14
+ let target: String?
15
+ }
16
+
17
+ struct TypeModel: Equatable {
18
+ let name: String
19
+ let fields: [String: FieldModel]
20
+ }
21
+
22
+ struct SchemaModel: Equatable {
23
+ let types: [String: TypeModel]
24
+
25
+ func type(_ name: String) throws -> TypeModel {
26
+ guard let type = types[name] else { throw CloudKitSyncError.unknownType(name) }
27
+ return type
28
+ }
29
+ }
30
+
31
+ enum CloudKitSyncError: Error, LocalizedError, Equatable {
32
+ case unknownType(String)
33
+ case invalidSchema(String)
34
+ case invalidID(String)
35
+ case invalidValue(String)
36
+ case notStarted
37
+ case storage(String)
38
+
39
+ var errorDescription: String? {
40
+ switch self {
41
+ case .unknownType(let name): return "\"\(name)\" isn't a record type in the schema."
42
+ case .invalidSchema(let message), .invalidID(let message), .invalidValue(let message), .storage(let message):
43
+ return message
44
+ case .notStarted: return "The sync engine isn't started. Call start() first."
45
+ }
46
+ }
47
+ }
48
+
49
+ /// The ID of a record in iCloud is "<Type>.<id>": CloudKit IDs must be unique across all types in a zone.
50
+ enum RecordName {
51
+ static func make(type: String, id: String) -> String { "\(type).\(id)" }
52
+
53
+ /// Splits "<Type>.<id>". Type names can't contain dots, so the first dot is the separator.
54
+ static func parse(_ recordName: String) -> (type: String, id: String)? {
55
+ guard let dot = recordName.firstIndex(of: ".") else { return nil }
56
+ let type = String(recordName[..<dot])
57
+ let id = String(recordName[recordName.index(after: dot)...])
58
+ return type.isEmpty || id.isEmpty ? nil : (type, id)
59
+ }
60
+
61
+ static func validate(type: String, id: String) throws {
62
+ if id.isEmpty { throw CloudKitSyncError.invalidID("\(type): the id is empty.") }
63
+ if !id.allSatisfy(\.isASCII) { throw CloudKitSyncError.invalidID("\(type).\(id): ids can only use ASCII characters.") }
64
+ if make(type: type, id: id).utf8.count > 255 {
65
+ throw CloudKitSyncError.invalidID("\(type).\(id): the id is too long (type + id must be at most 254 characters).")
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,586 @@
1
+ import CloudKit
2
+ import Foundation
3
+
4
+ /// How a conflict between this device and iCloud is settled.
5
+ enum ConflictPolicy: String {
6
+ /// The edit with the later `ekModifiedAt` wins. A delete from iCloud wins over a local edit.
7
+ case newest
8
+ /// iCloud's version always wins.
9
+ case server
10
+ /// This device's version always wins, also over a delete from iCloud.
11
+ case device
12
+ }
13
+
14
+ /// What the engine reports to JS.
15
+ protocol SyncCoreDelegate: AnyObject {
16
+ func syncCore(_ core: SyncCore, didChangeStatus status: [String: Any])
17
+ func syncCoreHasNewChanges(_ core: SyncCore)
18
+ func syncCore(_ core: SyncCore, didChangeAccount change: [String: Any])
19
+ func syncCore(_ core: SyncCore, didFail error: Error, context: String)
20
+ }
21
+
22
+ /// All sync logic around CKSyncEngine, without Expo types. The Expo shared object wraps it.
23
+ ///
24
+ /// Flow: local changes go to the outbox, then to CKSyncEngine as pending changes.
25
+ /// CKSyncEngine asks for records in `nextRecordZoneChangeBatch`, built from the outbox.
26
+ /// Changes from iCloud go to the inbox, and the app takes them with `pendingChanges()` + `ack()`.
27
+ final class SyncCore: CKSyncEngineDelegate, @unchecked Sendable {
28
+ private enum MetaKey {
29
+ static let engineState = "engineState"
30
+ static let initialFetchDone = "initialFetchDone"
31
+ static let zoneCreated = "zoneCreated"
32
+ static let lastSyncedAt = "lastSyncedAt"
33
+ }
34
+
35
+ let mapper: RecordMapper
36
+ let store: LocalStore
37
+ let policy: ConflictPolicy
38
+ weak var delegate: SyncCoreDelegate?
39
+
40
+ private let container: CKContainer
41
+ private let zoneID: CKRecordZone.ID
42
+ private let assetsDirectory: URL
43
+ private var engine: CKSyncEngine?
44
+ private let lock = NSRecursiveLock()
45
+ /// Outbox sequence of each record being uploaded right now.
46
+ private var inFlight: [String: Int64] = [:]
47
+ private var fetching = false
48
+ private var sending = false
49
+ private var lastError: String?
50
+ private var accountStatus = "unknown"
51
+
52
+ init(container: CKContainer, zoneName: String, schema: SchemaModel, policy: ConflictPolicy, store: LocalStore, assetsDirectory: URL) {
53
+ self.container = container
54
+ self.zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
55
+ self.mapper = RecordMapper(schema: schema, zoneID: zoneID)
56
+ self.policy = policy
57
+ self.store = store
58
+ self.assetsDirectory = assetsDirectory
59
+ }
60
+
61
+ // MARK: Lifecycle
62
+
63
+ private var accountObserver: NSObjectProtocol?
64
+
65
+ /// Starts CKSyncEngine from the saved state. Downloads first; local changes upload only after that.
66
+ func start() async {
67
+ if accountObserver == nil {
68
+ // CKSyncEngine reports changes while it runs. This also covers signing in after a sign-out,
69
+ // when no engine is running.
70
+ accountObserver = NotificationCenter.default.addObserver(forName: .CKAccountChanged, object: nil, queue: nil) { [weak self] _ in
71
+ guard let self else { return }
72
+ Task { await self.accountDidChange() }
73
+ }
74
+ }
75
+ if withLock({ engine }) != nil {
76
+ await syncNow() // Already running.
77
+ return
78
+ }
79
+ await refreshAccountStatus()
80
+ guard accountStatus == "available" else {
81
+ publishStatus()
82
+ return
83
+ }
84
+ startEngine()
85
+ await syncNow()
86
+ }
87
+
88
+ private func startEngine() {
89
+ let saved = (try? store.meta(MetaKey.engineState)).flatMap {
90
+ try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
91
+ }
92
+ var configuration = CKSyncEngine.Configuration(
93
+ database: container.privateCloudDatabase,
94
+ stateSerialization: saved,
95
+ delegate: self
96
+ )
97
+ configuration.automaticallySync = true
98
+ let engine = CKSyncEngine(configuration)
99
+ withLock { self.engine = engine }
100
+
101
+ if (try? store.flag(MetaKey.zoneCreated)) != true {
102
+ engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
103
+ }
104
+ // Local changes wait in the outbox until the first download has finished,
105
+ // so a fresh install never overwrites what's already in iCloud.
106
+ if (try? store.flag(MetaKey.initialFetchDone)) == true {
107
+ registerOutbox(engine)
108
+ }
109
+ }
110
+
111
+ private func accountDidChange() async {
112
+ await refreshAccountStatus()
113
+ if accountStatus == "available", withLock({ engine }) == nil {
114
+ startEngine()
115
+ await syncNow()
116
+ }
117
+ publishStatus()
118
+ }
119
+
120
+ deinit {
121
+ if let accountObserver { NotificationCenter.default.removeObserver(accountObserver) }
122
+ }
123
+
124
+ func stop() async {
125
+ await detachEngine()?.cancelOperations()
126
+ }
127
+
128
+ /// Downloads, then uploads. Safe to call any time, e.g. pull to refresh or app to the front.
129
+ func syncNow() async {
130
+ if withLock({ engine }) == nil {
131
+ await refreshAccountStatus()
132
+ guard accountStatus == "available" else { publishStatus(); return }
133
+ startEngine()
134
+ }
135
+ guard let engine = withLock({ self.engine }) else { return }
136
+ do {
137
+ try await engine.fetchChanges()
138
+ if (try? store.flag(MetaKey.initialFetchDone)) != true {
139
+ try store.setFlag(MetaKey.initialFetchDone, true)
140
+ registerOutbox(engine)
141
+ }
142
+ try await engine.sendChanges()
143
+ try store.setMeta(MetaKey.lastSyncedAt, String(Date().timeIntervalSince1970 * 1000).data(using: .utf8))
144
+ withLock { lastError = nil }
145
+ } catch {
146
+ handle(error, context: "syncNow")
147
+ }
148
+ publishStatus()
149
+ }
150
+
151
+ // MARK: Local changes
152
+
153
+ func save(type: String, id: String, fields: [String: Any]) throws {
154
+ let normalized = try mapper.normalize(type: type, id: id, fields: fields)
155
+ try store.enqueue(type: type, id: id, operation: .save, fields: normalized, modifiedAt: Date())
156
+ addPending(.saveRecord(recordID(type: type, id: id)))
157
+ }
158
+
159
+ func delete(type: String, id: String) throws {
160
+ _ = try mapper.schema.type(type)
161
+ try RecordName.validate(type: type, id: id)
162
+ try store.enqueue(type: type, id: id, operation: .delete, fields: [:], modifiedAt: Date())
163
+ removeAssets(type: type, id: id)
164
+ addPending(.deleteRecord(recordID(type: type, id: id)))
165
+ }
166
+
167
+ private func addPending(_ change: CKSyncEngine.PendingRecordZoneChange) {
168
+ guard let engine = withLock({ self.engine }), (try? store.flag(MetaKey.initialFetchDone)) == true else {
169
+ publishStatus()
170
+ return
171
+ }
172
+ engine.state.add(pendingRecordZoneChanges: [change])
173
+ publishStatus()
174
+ }
175
+
176
+ private func registerOutbox(_ engine: CKSyncEngine) {
177
+ guard let entries = try? store.outboxEntries(), !entries.isEmpty else { return }
178
+ engine.state.add(pendingRecordZoneChanges: entries.map { entry in
179
+ let id = CKRecord.ID(recordName: entry.recordName, zoneID: zoneID)
180
+ return entry.operation == .save ? .saveRecord(id) : .deleteRecord(id)
181
+ })
182
+ }
183
+
184
+ // MARK: Changes for the app
185
+
186
+ func pendingChanges(limit: Int) throws -> [[String: Any]] {
187
+ try store.inbox(limit: limit).map { entry in
188
+ [
189
+ "sequence": entry.sequence,
190
+ "type": entry.type,
191
+ "id": entry.id,
192
+ "operation": entry.operation.rawValue,
193
+ "fields": entry.fields as [String: Any],
194
+ ]
195
+ }
196
+ }
197
+
198
+ func ack(_ sequences: [Int64]) throws {
199
+ try store.ackInbox(sequences)
200
+ publishStatus()
201
+ }
202
+
203
+ // MARK: Status
204
+
205
+ func status() -> [String: Any] {
206
+ let counts = (try? store.counts()) ?? (outbox: 0, inbox: 0)
207
+ let lastSyncedAt = (try? store.meta(MetaKey.lastSyncedAt)).flatMap { $0 }.flatMap { Double(String(decoding: $0, as: UTF8.self)) }
208
+ return withLock {
209
+ [
210
+ "account": accountStatus,
211
+ "syncing": fetching || sending,
212
+ "initialSyncDone": (try? store.flag(MetaKey.initialFetchDone)) == true,
213
+ "pendingUploads": counts.outbox,
214
+ "pendingChanges": counts.inbox,
215
+ "lastSyncedAt": lastSyncedAt as Any? ?? NSNull(),
216
+ "error": lastError as Any? ?? NSNull(),
217
+ ]
218
+ }
219
+ }
220
+
221
+ private func publishStatus() {
222
+ delegate?.syncCore(self, didChangeStatus: status())
223
+ }
224
+
225
+ func refreshAccountStatus() async {
226
+ let status: String
227
+ do {
228
+ switch try await container.accountStatus() {
229
+ case .available: status = "available"
230
+ case .noAccount: status = "noAccount"
231
+ case .restricted: status = "restricted"
232
+ case .temporarilyUnavailable: status = "temporarilyUnavailable"
233
+ case .couldNotDetermine: status = "couldNotDetermine"
234
+ @unknown default: status = "couldNotDetermine"
235
+ }
236
+ } catch {
237
+ status = "couldNotDetermine"
238
+ }
239
+ withLock { accountStatus = status }
240
+ }
241
+
242
+ // MARK: CKSyncEngineDelegate
243
+
244
+ func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
245
+ // An engine that was replaced (account change, iCloud data deleted) must not
246
+ // write its old state over the fresh one.
247
+ guard withLock({ engine }) === syncEngine else { return }
248
+ switch event {
249
+ case .stateUpdate(let update):
250
+ if let data = try? JSONEncoder().encode(update.stateSerialization) {
251
+ try? store.setMeta(MetaKey.engineState, data)
252
+ }
253
+
254
+ case .accountChange(let change):
255
+ await handleAccountChange(change)
256
+
257
+ case .fetchedDatabaseChanges(let changes):
258
+ if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
259
+ await handleZoneDeleted()
260
+ }
261
+
262
+ case .fetchedRecordZoneChanges(let changes):
263
+ handleFetched(changes)
264
+
265
+ case .sentDatabaseChanges(let changes):
266
+ if changes.savedZones.contains(where: { $0.zoneID == zoneID }) {
267
+ try? store.setFlag(MetaKey.zoneCreated, true)
268
+ }
269
+ for failure in changes.failedZoneSaves where failure.zone.zoneID == zoneID {
270
+ handle(failure.error, context: "saveZone")
271
+ }
272
+
273
+ case .sentRecordZoneChanges(let changes):
274
+ handleSent(changes, engine: syncEngine)
275
+
276
+ case .willFetchChanges:
277
+ withLock { fetching = true }
278
+ publishStatus()
279
+ case .didFetchChanges:
280
+ withLock { fetching = false }
281
+ publishStatus()
282
+ case .willSendChanges:
283
+ withLock { sending = true }
284
+ publishStatus()
285
+ case .didSendChanges:
286
+ withLock { sending = false }
287
+ publishStatus()
288
+
289
+ case .willFetchRecordZoneChanges, .didFetchRecordZoneChanges:
290
+ break
291
+ @unknown default:
292
+ break
293
+ }
294
+ }
295
+
296
+ func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
297
+ let changes = syncEngine.state.pendingRecordZoneChanges.filter { context.options.scope.contains($0) }
298
+ guard !changes.isEmpty else { return nil }
299
+ return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { [weak self] recordID in
300
+ self?.record(for: recordID, engine: syncEngine)
301
+ }
302
+ }
303
+
304
+ /// Builds the record to upload from the outbox. Starts from the saved system fields,
305
+ /// so iCloud sees it as an edit of the version it has, not a new record.
306
+ private func record(for recordID: CKRecord.ID, engine: CKSyncEngine) -> CKRecord? {
307
+ guard let entry = try? store.outboxEntry(recordID.recordName), entry.operation == .save else {
308
+ engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
309
+ return nil
310
+ }
311
+ let record = existingRecord(recordID) ?? CKRecord(recordType: entry.type, recordID: recordID)
312
+ do {
313
+ try mapper.apply(fields: entry.fields, modifiedAt: entry.modifiedAt, to: record)
314
+ } catch {
315
+ // The data can't be uploaded (e.g. a file is gone). Keep it out of the loop and tell the app.
316
+ engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
317
+ try? store.discardOutbox(recordID.recordName)
318
+ handle(error, context: "buildRecord \(recordID.recordName)")
319
+ return nil
320
+ }
321
+ withLock { inFlight[recordID.recordName] = entry.sequence }
322
+ return record
323
+ }
324
+
325
+ // MARK: Sent changes
326
+
327
+ private func handleSent(_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine) {
328
+ for record in changes.savedRecords {
329
+ let name = record.recordID.recordName
330
+ saveSystemFields(record)
331
+ let sequence = withLock { inFlight.removeValue(forKey: name) }
332
+ if let sequence, (try? store.removeOutbox(name, sequence: sequence)) == false,
333
+ let newer = try? store.outboxEntry(name) {
334
+ // Changed again while uploading: upload the newer version too.
335
+ engine.state.add(pendingRecordZoneChanges: [newer.operation == .save ? .saveRecord(record.recordID) : .deleteRecord(record.recordID)])
336
+ }
337
+ }
338
+
339
+ for recordID in changes.deletedRecordIDs {
340
+ finishDelete(recordID)
341
+ }
342
+
343
+ for (recordID, error) in changes.failedRecordDeletes {
344
+ if error.code == .unknownItem {
345
+ finishDelete(recordID) // Already gone in iCloud.
346
+ } else if !isRetried(error) {
347
+ handle(error, context: "delete \(recordID.recordName)")
348
+ }
349
+ }
350
+
351
+ for failure in changes.failedRecordSaves {
352
+ let recordID = failure.record.recordID
353
+ let name = recordID.recordName
354
+ withLock { _ = inFlight.removeValue(forKey: name) }
355
+ switch failure.error.code {
356
+ case .serverRecordChanged:
357
+ guard let serverRecord = failure.error.serverRecord else { break }
358
+ resolveConflict(local: try? store.outboxEntry(name), server: serverRecord, engine: engine)
359
+ case .zoneNotFound:
360
+ // The zone is gone (e.g. deleted from another device). Create it again, then retry.
361
+ try? store.setFlag(MetaKey.zoneCreated, false)
362
+ try? store.setSystemFields(name, nil)
363
+ engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
364
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
365
+ case .unknownItem:
366
+ // Deleted in iCloud since we last saw it. Upload it as a new record.
367
+ try? store.setSystemFields(name, nil)
368
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
369
+ case .quotaExceeded:
370
+ handle(failure.error, context: "iCloud storage is full")
371
+ default:
372
+ if !isRetried(failure.error) { handle(failure.error, context: "save \(name)") }
373
+ }
374
+ }
375
+ publishStatus()
376
+ }
377
+
378
+ private func finishDelete(_ recordID: CKRecord.ID) {
379
+ let name = recordID.recordName
380
+ try? store.setSystemFields(name, nil)
381
+ if let entry = try? store.outboxEntry(name), entry.operation == .delete {
382
+ _ = try? store.removeOutbox(name, sequence: entry.sequence)
383
+ }
384
+ }
385
+
386
+ /// iCloud has a different version than the one this edit started from.
387
+ private func resolveConflict(local: LocalStore.OutboxEntry?, server: CKRecord, engine: CKSyncEngine) {
388
+ let name = server.recordID.recordName
389
+ saveSystemFields(server)
390
+ guard let local, local.operation == .save else {
391
+ if deliver(server) { delegate?.syncCoreHasNewChanges(self) }
392
+ return
393
+ }
394
+ if localWins(local: local, serverModifiedAt: server[RecordMapper.modifiedAtKey] as? Date) {
395
+ // Upload again, now based on the server's version (keeps its unknown fields).
396
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(server.recordID)])
397
+ } else {
398
+ try? store.discardOutbox(name)
399
+ engine.state.remove(pendingRecordZoneChanges: [.saveRecord(server.recordID)])
400
+ if deliver(server) { delegate?.syncCoreHasNewChanges(self) }
401
+ }
402
+ }
403
+
404
+ private func localWins(local: LocalStore.OutboxEntry, serverModifiedAt: Date?) -> Bool {
405
+ switch policy {
406
+ case .device: return true
407
+ case .server: return false
408
+ case .newest:
409
+ guard let serverModifiedAt else { return true }
410
+ return local.modifiedAt >= serverModifiedAt
411
+ }
412
+ }
413
+
414
+ // MARK: Fetched changes
415
+
416
+ private func handleFetched(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) {
417
+ var delivered = false
418
+ for modification in changes.modifications {
419
+ let record = modification.record
420
+ guard record.recordID.zoneID == zoneID else { continue }
421
+ saveSystemFields(record)
422
+ if let local = try? store.outboxEntry(record.recordID.recordName), local.operation == .save {
423
+ // A local edit is waiting for the same record.
424
+ if localWins(local: local, serverModifiedAt: record[RecordMapper.modifiedAtKey] as? Date) { continue }
425
+ try? store.discardOutbox(record.recordID.recordName)
426
+ engine?.state.remove(pendingRecordZoneChanges: [.saveRecord(record.recordID)])
427
+ }
428
+ delivered = deliver(record) || delivered
429
+ }
430
+
431
+ for deletion in changes.deletions {
432
+ let recordID = deletion.recordID
433
+ guard recordID.zoneID == zoneID, let name = RecordName.parse(recordID.recordName) else { continue }
434
+ try? store.setSystemFields(recordID.recordName, nil)
435
+ if let local = try? store.outboxEntry(recordID.recordName), local.operation == .save, policy == .device {
436
+ continue // This device's edit wins: it will be uploaded as a new record.
437
+ }
438
+ try? store.discardOutbox(recordID.recordName)
439
+ engine?.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
440
+ guard mapper.schema.types[name.type] != nil else { continue }
441
+ removeAssets(type: name.type, id: name.id)
442
+ do {
443
+ try store.addInbox(type: name.type, id: name.id, operation: .delete, fields: [:])
444
+ delivered = true
445
+ } catch {
446
+ handle(error, context: "inbox delete \(recordID.recordName)")
447
+ }
448
+ }
449
+
450
+ if delivered { delegate?.syncCoreHasNewChanges(self) }
451
+ publishStatus()
452
+ }
453
+
454
+ /// Puts a record from iCloud in the inbox for the app. Returns false for unknown types.
455
+ @discardableResult
456
+ private func deliver(_ record: CKRecord) -> Bool {
457
+ do {
458
+ guard let read = try mapper.read(record, saveAsset: saveAsset) else { return false }
459
+ try store.addInbox(type: read.type, id: read.id, operation: .save, fields: read.fields)
460
+ return true
461
+ } catch {
462
+ handle(error, context: "inbox save \(record.recordID.recordName)")
463
+ return false
464
+ }
465
+ }
466
+
467
+ /// Copies a downloaded file out of CloudKit's cache to a permanent place, keeping its extension.
468
+ private func saveAsset(_ target: RecordMapper.AssetTarget) throws -> String? {
469
+ guard let source = target.asset.fileURL else { return nil }
470
+ let fileManager = FileManager.default
471
+ let directory = assetDirectory(type: target.type, id: target.id)
472
+ try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
473
+ // Remove earlier copies of this field (the extension may have changed).
474
+ for old in (try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
475
+ where old.deletingPathExtension().lastPathComponent == target.field {
476
+ try? fileManager.removeItem(at: old)
477
+ }
478
+ var destination = directory.appendingPathComponent(target.field)
479
+ if !target.fileExtension.isEmpty { destination.appendPathExtension(target.fileExtension) }
480
+ try fileManager.copyItem(at: source, to: destination)
481
+ return destination.absoluteString
482
+ }
483
+
484
+ private func assetDirectory(type: String, id: String) -> URL {
485
+ assetsDirectory.appendingPathComponent(type).appendingPathComponent(id)
486
+ }
487
+
488
+ /// Downloaded files of a deleted record aren't needed anymore.
489
+ private func removeAssets(type: String, id: String) {
490
+ try? FileManager.default.removeItem(at: assetDirectory(type: type, id: id))
491
+ }
492
+
493
+ // MARK: Account and zone
494
+
495
+ private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) async {
496
+ let kind: String
497
+ switch change.changeType {
498
+ case .signIn: kind = "signIn"
499
+ case .signOut: kind = "signOut"
500
+ case .switchAccounts: kind = "switchAccounts"
501
+ @unknown default: kind = "unknown"
502
+ }
503
+ var replaced: CKSyncEngine?
504
+ if kind == "signOut" || kind == "switchAccounts" {
505
+ // The data in iCloud belongs to another account now. Forget everything about it.
506
+ // The app decides what to do with its local data.
507
+ replaced = detachEngine()
508
+ try? store.reset(keepOutbox: false)
509
+ }
510
+ await refreshAccountStatus()
511
+ delegate?.syncCore(self, didChangeAccount: ["change": kind, "account": accountStatus])
512
+ if let replaced {
513
+ // Restart outside this callback: cancelling an engine from inside its own event can deadlock.
514
+ Task { await self.restart(replacing: replaced) }
515
+ }
516
+ publishStatus()
517
+ }
518
+
519
+ /// Takes the engine out of use right away, so none of its later events are handled.
520
+ private func detachEngine() -> CKSyncEngine? {
521
+ withLock {
522
+ let current = engine
523
+ engine = nil
524
+ inFlight.removeAll()
525
+ return current
526
+ }
527
+ }
528
+
529
+ /// Cancels the replaced engine (its saved state was cleared) and starts a fresh one if signed in.
530
+ private func restart(replacing old: CKSyncEngine) async {
531
+ await old.cancelOperations()
532
+ await refreshAccountStatus()
533
+ if accountStatus == "available" {
534
+ startEngine()
535
+ await syncNow()
536
+ }
537
+ publishStatus()
538
+ }
539
+
540
+ private func handleZoneDeleted() async {
541
+ // The user deleted this app's iCloud data (e.g. in Settings). Start fresh; the app decides
542
+ // whether to upload its local data again with importAll().
543
+ guard let replaced = detachEngine() else { return }
544
+ try? store.reset(keepOutbox: false)
545
+ delegate?.syncCore(self, didChangeAccount: ["change": "cloudDataDeleted", "account": accountStatus])
546
+ Task { await self.restart(replacing: replaced) }
547
+ }
548
+
549
+ // MARK: Helpers
550
+
551
+ private func recordID(type: String, id: String) -> CKRecord.ID {
552
+ CKRecord.ID(recordName: RecordName.make(type: type, id: id), zoneID: zoneID)
553
+ }
554
+
555
+ private func existingRecord(_ recordID: CKRecord.ID) -> CKRecord? {
556
+ guard let data = try? store.systemFields(recordID.recordName),
557
+ let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
558
+ coder.requiresSecureCoding = true
559
+ defer { coder.finishDecoding() }
560
+ return CKRecord(coder: coder)
561
+ }
562
+
563
+ private func saveSystemFields(_ record: CKRecord) {
564
+ let coder = NSKeyedArchiver(requiringSecureCoding: true)
565
+ record.encodeSystemFields(with: coder)
566
+ coder.finishEncoding()
567
+ try? store.setSystemFields(record.recordID.recordName, coder.encodedData)
568
+ }
569
+
570
+ /// Errors CKSyncEngine retries by itself.
571
+ private func isRetried(_ error: CKError) -> Bool {
572
+ [.networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled, .requestRateLimited]
573
+ .contains(error.code)
574
+ }
575
+
576
+ private func handle(_ error: Error, context: String) {
577
+ withLock { lastError = "\(context): \(error.localizedDescription)" }
578
+ delegate?.syncCore(self, didFail: error, context: context)
579
+ }
580
+
581
+ private func withLock<T>(_ body: () -> T) -> T {
582
+ lock.lock()
583
+ defer { lock.unlock() }
584
+ return body()
585
+ }
586
+ }