# CloudKit Sync

Mecanismul de replicare a datelor către iCloud.

## Arhitectură

### Components

1. **NSPersistentCloudKitContainer**: gestionează Core Data + CloudKit automat
2. **CloudDeviceSettingsStore**: wrapper pe NSManagedObjectContext
3. **Core Data Model**: `CKModel.xcdatamodeld` (curent: USB_Meter 2)
4. **CloudKit Container**: `iCloud.ro.xdev.USB-Meter`

### Model versioning

```
USB_Meter 1 (original)
  ↓
USB_Meter 2 (fix: removed uniqueness constraints)
  ↓
USB_Meter 3? (future)
```

**Curent**: USB_Meter 2 (schema v20)

## Data Schema

### DeviceSettings entity (Core Data)

| Field | Type | Notes |
|-------|------|-------|
| `id` | UUID | Primary key |
| `macAddress` | String | Optional (for migration) |
| `meterName` | String | Chosen by user |
| `tc66TemperatureUnit` | String | "celsius" / "fahrenheit" |
| `createdAt` | Date | Immutable |
| `updatedAt` | Date | Last sync timestamp |
| `connectionMetadata` | JSON blob | Device, timestamp, expiry |
| `discoveryMetadata` | JSON blob | Last seen, seen by |
| `cloudKitRecordID` | String | Reference toward CloudKit |

### Invarianţi

- **MUST**: `macAddress` nu are `uniquenessConstraint` (not CloudKit safe)
- **MUST**: Chiar dacă `macAddress` optional, duplicate entries ar trebui merged
- **MUST**: `meterName` e unic per meter (no machine-generated names)
- **MUST**: `updatedAt` se schimbă la fiecare sync
- **SHOULD**: `connectionMetadata.expiresAt` = now() + 24h

## Sync Lifecycle

### Push (local → CloudKit)

```
User changes meterName
  ↓
AppData calls cloudStore.upsertDeviceSettings(...)
  ↓
NSManagedObjectContext saves
  ↓
NSPersistentCloudKitContainer auto-uploads to CloudKit
  ↓
CloudKit replica updated
  ↓
Other devices see change via NSPersistentCloudKitContainerEventChangeNotification
```

- **MUST**: Core Data save trebuie să se afle pe main thread
- **MUST**: CloudKit sync e asincronă (fire-and-forget)
- **SHOULD**: Notify UI după local save (don't wait for CloudKit)
- **MAY**: Log upload errors

### Pull (CloudKit → local)

```
iCloud change appears
  ↓
NSPersistentCloudKitContainer notifies
  ↓
AppData observes NSPersistentCloudKitContainerEventChangeNotification
  ↓
Merge remote change with local state
  ↓
Core Data context updated
  ↓
UI refreshes
```

- **MUST**: Merge strategy = "last write wins" (basate pe timestamp)
- **MUST**: Conflict resolution e automat (NSMergeByPropertyObjectTrumpMergePolicy)
- **SHOULD**: Loghează merge pentru debugging
- **MAY**: Notifică user dacă conflict major

### Conflict scenarios

**Scenario 1**: Same meter renamed on two devices simultaneously
```
Device A: "Kitchen Meter" → "Main Meter"  (10:00:00)
Device B: "Kitchen Meter" → "Lab Meter"   (10:00:05)
```
Resolution: Last timestamp wins → "Lab Meter" (Device B @ 10:00:05)

**Scenario 2**: Duplicate entries cu aceeași MAC
```
Device A: macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
Device B (after restore): macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1"
```
Resolution: Merge duplicate entries, keep one record (last write wins)

## Discovery Throttling

### Logică

```swift
func recordDiscovery(for macAddress: String) {
    let lastSeen = discoveryMetadata[macAddress]?.lastSeen
    let now = Date()
    
    if now.timeIntervalSince(lastSeen ?? .distantPast) >= 120 {
        // OK: permit sync
        cloudStore.recordDiscovery(macAddress, discoveredAt: now)
    }
    // else: skip (under throttle window)
}
```

- **MUST**: Max 1 discovery record per device per 120s
- **SHOULD**: Throttle pe app level (nu pe CloudKit)
- **MUST**: Reseta timer dacă device disconnect-ează
- **REASON**: Prevent CloudKit thrashing din repeat BT advertisements

### Impact

- BT scan at 0s, 30s, 60s, 90s, 120s, ... (periodc)
- Doar descoperirile la 0s, 120s, 240s, ... sunt syncate la CloudKit
- Alte descoperiri sunt cached local

## Rebuild logic

### Background

Issue (March 2025):
- Old `uniquenessConstraint` pe `macAddress` = incompatibil cu CloudKit
- Old rebuild logic: delete ALL + recreate = delete storm în CloudKit
- Old naming: `meterName` = platform model ("iPad") = irelevant pe CloudKit

### Fix

**Schema migration**: USB_Meter 1 → USB_Meter 2
- Removed `uniquenessConstraint`
- Made `macAddress` optional
- Changed device name = hostname (all platforms)

**Rebuild refactoring**: `rebuildCanonicalStoreIfNeeded()`
```swift
func rebuildCanonicalStoreIfNeeded(newVersion: Int) {
    if cloudStoreRebuildVersion >= newVersion { return }
    
    // Update winner in-place, delete only duplicates
    let groupedByMAC = groupEntries(by: \.macAddress)
    for (mac, entries) in groupedByMAC {
        guard entries.count > 1 else { continue }
        let winner = entries.max(by: \.updatedAt)
        let losers = entries.filter { $0.id != winner.id }
        
        // Delete losers only, keep winner
        for loser in losers {
            context.delete(loser)
        }
    }
    
    cloudStoreRebuildVersion = newVersion
    try? context.save()
}
```

- **MUST**: Update winner în-place (nu delete+recreate)
- **MUST**: Delete doar duplicate entries (nu pe toti)
- **MUST**: Set `cloudStoreRebuildVersion = 3` după fix
- **REASON**: Prevent delete storms în CloudKit

### Version history

- **v1**: Original schema (cu uniqueness constraint)
- **v2**: First rebuild (delete all + recreate) — DEPRECATED
- **v3**: Correct rebuild (update in-place + delete duplicates only)

## Legacy data migration

### NSUbiquitousKeyValueStore (deprecated)

```swift
// Legacy stores
let meterNames = NSUbiquitousKeyValueStore.default.dictionary(forKey: "MeterNames")
// → {macAddress → meterName}

let tempUnits = NSUbiquitousKeyValueStore.default.dictionary(forKey: "TC66TemperatureUnits")
// → {macAddress → unit}
```

### Migration path

1. Read from KV store
2. For each MAC address:
   ```swift
   cloudStore.upsertDeviceSettings(
       macAddress: mac,
       meterName: meterNames[mac],
       tc66TemperatureUnit: tempUnits[mac]
   )
   ```
3. Mark as migrated în defaults

- **MUST**: Menţine KV store pentru backward compat
- **SHOULD**: Migrate pe startup dacă not migrated
- **MAY**: Drop KV store la next major version

## Testing

### Unit tests

```swift
test_cloudStoreSaves_ToNSManagedObjectContext()
test_upsertDeviceSettings_CreatesOrUpdates()
test_discoveryThrottling_RespectsTiming()
test_conflictResolution_LastWriteWins()
test_rebuildCanonicalStore_UpdatesWinner_DeletesDuplicates()
test_macAddressNoDuplicates_AfterRebuild()
```

### Integration tests

- [ ] Multiple devices sync settings via CloudKit
- [ ] Conflict detected and resolved
- [ ] Rebuild v3 migrates old data correctly
- [ ] Discovery throttling prevents CloudKit thrashing
- [ ] Legacy KV store data migrates to Core Data

## Error handling

### Network errors

```
Error: Network unavailable
→ Fail fast (don't retry immediately)
→ Queue pending changes in Core Data
→ Retry at next network change (observe URLSession events)
```

### CloudKit quota errors

```
Error: Quote exceeded (too many records)
→ Log error
→ Notify user: "iCloud storage full"
→ Option: Archive old charge records
```

### Merge conflicts

```
Error: Conflict detected
→ Auto-resolve via "last write wins"
→ Log both versions for debugging
→ Notify user if significant loss
```

## Dependencies

- `NSPersistentCloudKitContainer`: from CloudKit framework
- `CloudDeviceSettingsStore`: wrapper in AppData.swift
- `CKModel.xcdatamodeld`: Core Data schema
- `NSUbiquitousKeyValueStore`: legacy (deprecated)

## References

- Commit: [Fix CloudKit sync (Mar 2025)](https://github.com/...)
- Documentaţie: [Project Memory - Fix CloudKit sync](../../MEMORY.md)
- Issue: [[001 Catalyst TabView Freeze]]
