Newer Older
279 lines | 7.785kb
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]]