|
Bogdan Timofte
authored
2 weeks ago
|
1
|
# CloudKit Sync
|
|
|
2
|
|
|
|
3
|
Mecanismul de replicare a datelor către iCloud.
|
|
|
4
|
|
|
|
5
|
## Arhitectură
|
|
|
6
|
|
|
|
7
|
### Components
|
|
|
8
|
|
|
|
9
|
1. **NSPersistentCloudKitContainer**: gestionează Core Data + CloudKit automat
|
|
|
10
|
2. **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
|
|
|
11
|
3. **Core Data Model**: `CKModel.xcdatamodeld` (curent: USB_Meter 2)
|
|
|
12
|
4. **CloudKit Container**: `iCloud.ro.xdev.USB-Meter`
|
|
|
13
|
|
|
|
14
|
### Model versioning
|
|
|
15
|
|
|
|
16
|
```
|
|
|
17
|
USB_Meter 1 (original)
|
|
|
18
|
↓
|
|
|
19
|
USB_Meter 2 (fix: removed uniqueness constraints)
|
|
|
20
|
↓
|
|
|
21
|
USB_Meter 3? (future)
|
|
|
22
|
```
|
|
|
23
|
|
|
|
24
|
**Curent**: USB_Meter 2 (schema v20)
|
|
|
25
|
|
|
|
26
|
## Data Schema
|
|
|
27
|
|
|
|
28
|
### DeviceSettings entity (Core Data)
|
|
|
29
|
|
|
|
30
|
| Field | Type | Notes |
|
|
|
31
|
|-------|------|-------|
|
|
|
32
|
| `id` | UUID | Primary key |
|
|
|
33
|
| `macAddress` | String | Optional (for migration) |
|
|
|
34
|
| `meterName` | String | Chosen by user |
|
|
|
35
|
| `tc66TemperatureUnit` | String | "celsius" / "fahrenheit" |
|
|
|
36
|
| `createdAt` | Date | Immutable |
|
|
|
37
|
| `updatedAt` | Date | Last sync timestamp |
|
|
|
38
|
| `connectionMetadata` | JSON blob | Device, timestamp, expiry |
|
|
|
39
|
| `discoveryMetadata` | JSON blob | Last seen, seen by |
|
|
|
40
|
| `cloudKitRecordID` | String | Reference toward CloudKit |
|
|
|
41
|
|
|
|
42
|
### Invarianţi
|
|
|
43
|
|
|
|
44
|
- **MUST**: `macAddress` nu are `uniquenessConstraint` (not CloudKit safe)
|
|
|
45
|
- **MUST**: Chiar dacă `macAddress` optional, duplicate entries ar trebui merged
|
|
|
46
|
- **MUST**: `meterName` e unic per meter (no machine-generated names)
|
|
|
47
|
- **MUST**: `updatedAt` se schimbă la fiecare sync
|
|
|
48
|
- **SHOULD**: `connectionMetadata.expiresAt` = now() + 24h
|
|
|
49
|
|
|
|
50
|
## Sync Lifecycle
|
|
|
51
|
|
|
|
52
|
### Push (local → CloudKit)
|
|
|
53
|
|
|
|
54
|
```
|
|
|
55
|
User changes meterName
|
|
|
56
|
↓
|
|
|
57
|
AppData calls cloudStore.upsertDeviceSettings(...)
|
|
|
58
|
↓
|
|
|
59
|
NSManagedObjectContext saves
|
|
|
60
|
↓
|
|
|
61
|
NSPersistentCloudKitContainer auto-uploads to CloudKit
|
|
|
62
|
↓
|
|
|
63
|
CloudKit replica updated
|
|
|
64
|
↓
|
|
|
65
|
Other devices see change via NSPersistentCloudKitContainerEventChangeNotification
|
|
|
66
|
```
|
|
|
67
|
|
|
|
68
|
- **MUST**: Core Data save trebuie să se afle pe main thread
|
|
|
69
|
- **MUST**: CloudKit sync e asincronă (fire-and-forget)
|
|
|
70
|
- **SHOULD**: Notify UI după local save (don't wait for CloudKit)
|
|
|
71
|
- **MAY**: Log upload errors
|
|
|
72
|
|
|
|
73
|
### Pull (CloudKit → local)
|
|
|
74
|
|
|
|
75
|
```
|
|
|
76
|
iCloud change appears
|
|
|
77
|
↓
|
|
|
78
|
NSPersistentCloudKitContainer notifies
|
|
|
79
|
↓
|
|
|
80
|
AppData observes NSPersistentCloudKitContainerEventChangeNotification
|
|
|
81
|
↓
|
|
|
82
|
Merge remote change with local state
|
|
|
83
|
↓
|
|
|
84
|
Core Data context updated
|
|
|
85
|
↓
|
|
|
86
|
UI refreshes
|
|
|
87
|
```
|
|
|
88
|
|
|
|
89
|
- **MUST**: Merge strategy = "last write wins" (basate pe timestamp)
|
|
|
90
|
- **MUST**: Conflict resolution e automat (NSMergeByPropertyObjectTrumpMergePolicy)
|
|
|
91
|
- **SHOULD**: Loghează merge pentru debugging
|
|
|
92
|
- **MAY**: Notifică user dacă conflict major
|
|
|
93
|
|
|
|
94
|
### Conflict scenarios
|
|
|
95
|
|
|
|
96
|
**Scenario 1**: Same meter renamed on two devices simultaneously
|
|
|
97
|
```
|
|
|
98
|
Device A: "Kitchen Meter" → "Main Meter" (10:00:00)
|
|
|
99
|
Device B: "Kitchen Meter" → "Lab Meter" (10:00:05)
|
|
|
100
|
```
|
|
|
101
|
Resolution: Last timestamp wins → "Lab Meter" (Device B @ 10:00:05)
|
|
|
102
|
|
|
|
103
|
**Scenario 2**: Duplicate entries cu aceeași MAC
|
|
|
104
|
```
|
|
|
105
|
Device A: macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
|
|
|
106
|
Device B (after restore): macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
|
|
|
107
|
```
|
|
|
108
|
Resolution: Merge duplicate entries, keep one record (last write wins)
|
|
|
109
|
|
|
|
110
|
## Discovery Throttling
|
|
|
111
|
|
|
|
112
|
### Logică
|
|
|
113
|
|
|
|
114
|
```swift
|
|
|
115
|
func recordDiscovery(for macAddress: String) {
|
|
|
116
|
let lastSeen = discoveryMetadata[macAddress]?.lastSeen
|
|
|
117
|
let now = Date()
|
|
|
118
|
|
|
|
119
|
if now.timeIntervalSince(lastSeen ?? .distantPast) >= 120 {
|
|
|
120
|
// OK: permit sync
|
|
|
121
|
cloudStore.recordDiscovery(macAddress, discoveredAt: now)
|
|
|
122
|
}
|
|
|
123
|
// else: skip (under throttle window)
|
|
|
124
|
}
|
|
|
125
|
```
|
|
|
126
|
|
|
|
127
|
- **MUST**: Max 1 discovery record per device per 120s
|
|
|
128
|
- **SHOULD**: Throttle pe app level (nu pe CloudKit)
|
|
|
129
|
- **MUST**: Reseta timer dacă device disconnect-ează
|
|
|
130
|
- **REASON**: Prevent CloudKit thrashing din repeat BT advertisements
|
|
|
131
|
|
|
|
132
|
### Impact
|
|
|
133
|
|
|
|
134
|
- BT scan at 0s, 30s, 60s, 90s, 120s, ... (periodc)
|
|
|
135
|
- Doar descoperirile la 0s, 120s, 240s, ... sunt syncate la CloudKit
|
|
|
136
|
- Alte descoperiri sunt cached local
|
|
|
137
|
|
|
|
138
|
## Rebuild logic
|
|
|
139
|
|
|
|
140
|
### Background
|
|
|
141
|
|
|
|
142
|
Issue (March 2025):
|
|
|
143
|
- Old `uniquenessConstraint` pe `macAddress` = incompatibil cu CloudKit
|
|
|
144
|
- Old rebuild logic: delete ALL + recreate = delete storm în CloudKit
|
|
|
145
|
- Old naming: `meterName` = platform model ("iPad") = irelevant pe CloudKit
|
|
|
146
|
|
|
|
147
|
### Fix
|
|
|
148
|
|
|
|
149
|
**Schema migration**: USB_Meter 1 → USB_Meter 2
|
|
|
150
|
- Removed `uniquenessConstraint`
|
|
|
151
|
- Made `macAddress` optional
|
|
|
152
|
- Changed device name = hostname (all platforms)
|
|
|
153
|
|
|
|
154
|
**Rebuild refactoring**: `rebuildCanonicalStoreIfNeeded()`
|
|
|
155
|
```swift
|
|
|
156
|
func rebuildCanonicalStoreIfNeeded(newVersion: Int) {
|
|
|
157
|
if cloudStoreRebuildVersion >= newVersion { return }
|
|
|
158
|
|
|
|
159
|
// Update winner in-place, delete only duplicates
|
|
|
160
|
let groupedByMAC = groupEntries(by: \.macAddress)
|
|
|
161
|
for (mac, entries) in groupedByMAC {
|
|
|
162
|
guard entries.count > 1 else { continue }
|
|
|
163
|
let winner = entries.max(by: \.updatedAt)
|
|
|
164
|
let losers = entries.filter { $0.id != winner.id }
|
|
|
165
|
|
|
|
166
|
// Delete losers only, keep winner
|
|
|
167
|
for loser in losers {
|
|
|
168
|
context.delete(loser)
|
|
|
169
|
}
|
|
|
170
|
}
|
|
|
171
|
|
|
|
172
|
cloudStoreRebuildVersion = newVersion
|
|
|
173
|
try? context.save()
|
|
|
174
|
}
|
|
|
175
|
```
|
|
|
176
|
|
|
|
177
|
- **MUST**: Update winner în-place (nu delete+recreate)
|
|
|
178
|
- **MUST**: Delete doar duplicate entries (nu pe toti)
|
|
|
179
|
- **MUST**: Set `cloudStoreRebuildVersion = 3` după fix
|
|
|
180
|
- **REASON**: Prevent delete storms în CloudKit
|
|
|
181
|
|
|
|
182
|
### Version history
|
|
|
183
|
|
|
|
184
|
- **v1**: Original schema (cu uniqueness constraint)
|
|
|
185
|
- **v2**: First rebuild (delete all + recreate) — DEPRECATED
|
|
|
186
|
- **v3**: Correct rebuild (update in-place + delete duplicates only)
|
|
|
187
|
|
|
|
188
|
## Legacy data migration
|
|
|
189
|
|
|
|
190
|
### NSUbiquitousKeyValueStore (deprecated)
|
|
|
191
|
|
|
|
192
|
```swift
|
|
|
193
|
// Legacy stores
|
|
|
194
|
let meterNames = NSUbiquitousKeyValueStore.default.dictionary(forKey: "MeterNames")
|
|
|
195
|
// → {macAddress → meterName}
|
|
|
196
|
|
|
|
197
|
let tempUnits = NSUbiquitousKeyValueStore.default.dictionary(forKey: "TC66TemperatureUnits")
|
|
|
198
|
// → {macAddress → unit}
|
|
|
199
|
```
|
|
|
200
|
|
|
|
201
|
### Migration path
|
|
|
202
|
|
|
|
203
|
1. Read from KV store
|
|
|
204
|
2. For each MAC address:
|
|
|
205
|
```swift
|
|
|
206
|
cloudStore.upsertDeviceSettings(
|
|
|
207
|
macAddress: mac,
|
|
|
208
|
meterName: meterNames[mac],
|
|
|
209
|
tc66TemperatureUnit: tempUnits[mac]
|
|
|
210
|
)
|
|
|
211
|
```
|
|
|
212
|
3. Mark as migrated în defaults
|
|
|
213
|
|
|
|
214
|
- **MUST**: Menţine KV store pentru backward compat
|
|
|
215
|
- **SHOULD**: Migrate pe startup dacă not migrated
|
|
|
216
|
- **MAY**: Drop KV store la next major version
|
|
|
217
|
|
|
|
218
|
## Testing
|
|
|
219
|
|
|
|
220
|
### Unit tests
|
|
|
221
|
|
|
|
222
|
```swift
|
|
|
223
|
test_cloudStoreSaves_ToNSManagedObjectContext()
|
|
|
224
|
test_upsertDeviceSettings_CreatesOrUpdates()
|
|
|
225
|
test_discoveryThrottling_RespectsTiming()
|
|
|
226
|
test_conflictResolution_LastWriteWins()
|
|
|
227
|
test_rebuildCanonicalStore_UpdatesWinner_DeletesDuplicates()
|
|
|
228
|
test_macAddressNoDuplicates_AfterRebuild()
|
|
|
229
|
```
|
|
|
230
|
|
|
|
231
|
### Integration tests
|
|
|
232
|
|
|
|
233
|
- [ ] Multiple devices sync settings via CloudKit
|
|
|
234
|
- [ ] Conflict detected and resolved
|
|
|
235
|
- [ ] Rebuild v3 migrates old data correctly
|
|
|
236
|
- [ ] Discovery throttling prevents CloudKit thrashing
|
|
|
237
|
- [ ] Legacy KV store data migrates to Core Data
|
|
|
238
|
|
|
|
239
|
## Error handling
|
|
|
240
|
|
|
|
241
|
### Network errors
|
|
|
242
|
|
|
|
243
|
```
|
|
|
244
|
Error: Network unavailable
|
|
|
245
|
→ Fail fast (don't retry immediately)
|
|
|
246
|
→ Queue pending changes in Core Data
|
|
|
247
|
→ Retry at next network change (observe URLSession events)
|
|
|
248
|
```
|
|
|
249
|
|
|
|
250
|
### CloudKit quota errors
|
|
|
251
|
|
|
|
252
|
```
|
|
|
253
|
Error: Quote exceeded (too many records)
|
|
|
254
|
→ Log error
|
|
|
255
|
→ Notify user: "iCloud storage full"
|
|
|
256
|
→ Option: Archive old charge records
|
|
|
257
|
```
|
|
|
258
|
|
|
|
259
|
### Merge conflicts
|
|
|
260
|
|
|
|
261
|
```
|
|
|
262
|
Error: Conflict detected
|
|
|
263
|
→ Auto-resolve via "last write wins"
|
|
|
264
|
→ Log both versions for debugging
|
|
|
265
|
→ Notify user if significant loss
|
|
|
266
|
```
|
|
|
267
|
|
|
|
268
|
## Dependencies
|
|
|
269
|
|
|
|
270
|
- `NSPersistentCloudKitContainer`: from CloudKit framework
|
|
|
271
|
- `CloudDeviceSettingsStore`: wrapper in AppData.swift
|
|
|
272
|
- `CKModel.xcdatamodeld`: Core Data schema
|
|
|
273
|
- `NSUbiquitousKeyValueStore`: legacy (deprecated)
|
|
|
274
|
|
|
|
275
|
## References
|
|
|
276
|
|
|
|
277
|
- Commit: [Fix CloudKit sync (Mar 2025)](https://github.com/...)
|
|
|
278
|
- Documentaţie: [Project Memory - Fix CloudKit sync](../../MEMORY.md)
|
|
|
279
|
- Issue: [[001 Catalyst TabView Freeze]]
|