CloudKit Sync - Visual Reference
System Architecture Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ USB Meter App │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌─────────────────────────────────┐ │
│ │ Meter.swift │ │ BluetoothManager.swift │ │
│ │ (UI + State) │◄────────│ (BT Communication) │ │
│ └────────┬─────────┘ └─────────────────────────────────┘ │
│ │ │
│ │ operationalState changes │
│ │ (connect/disconnect) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ AppData │ │
│ │ (Global Sync Orchestrator) │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ • publishMeterConnection(mac, type) │ │
│ │ • clearMeterConnection(mac) │ │
│ │ • reloadSettingsFromCloudStore() │ │
│ │ • setupRemoteChangeNotificationObserver() │ │
│ └────────┬──────────────────────────┬──────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────────────┐ │
│ │ CloudDeviceSettings │ │ NSManagedObjectContext │ │
│ │ Store │ │ (viewContext) │ │
│ │ (CR operations) │ └────────────────────────┘ │
│ └────────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ NSPersistentCloudKitContainer │ │
│ │ (Automatic sync layer) │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ • Detects changes │ │
│ │ • Queues for CloudKit │ │
│ │ • Receives remote notifications │ │
│ │ • Applies merge policy │ │
│ └──────────────┬──────────────────────┬────────────────┘ │
│ │ │ │
└────────────────┼──────────────────────┼──────────────────────┘
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────────┐
│ Local SQLite DB │ │ Persistent History │
│ (DeviceSettings) │ │ Journal │
└───────────────────┘ └──────────────────────┘
│ │
└──────────┬───────────┘
│
┌──────────▼────────────┐
│ CloudKit Framework │
│ (Device background │
│ process) │
└──────────┬────────────┘
│
[Network Sync]
│
┌──────────▼────────────┐
│ iCloud Servers │
│ CloudKit Database │
└──────────┬────────────┘
│
┌──────────▼──────────────────┐
│ Other Devices (iPhone, │
│ iPad, Mac) on same │
│ iCloud account │
└─────────────────────────────┘
Local Write Flow (Sequence Diagram)
Device A AppData Core Data CloudKit
│ │ │ │
│─ User connects ───►│ │ │
│ meter │ │ │
│ │─ setConnection() ───►│ │
│ │ (foreground QoS) │ │
│ │ │ │
│◄─ UI Updates │◄─ @Published change │ │
│ (local) │ (immediate) │ │
│ │ │ │
│ │─ context.save() ────►│ │
│ │ │ │
│ │ │─ Detect change │
│ │ │─ Create CKRecord │
│ │ │─ Queue for sync ─►│
│ │ │ │─ Network upload
│ │ │ │ (1-2s)
│ │ │ │
┼────────────────────┼──────────────────────┼──────────────────┼───► [iCloud Server]
│ │ │ │
│ (Device B receives remote change notification after ~5s) │
Remote Change Reception (Sequence Diagram)
Device B System iOS Core Data AppData
│ │ │ │
│ [iCloud notifies] │ │
│ │ │ │
│ │─ Remote change ─────►│ │
│ │ notification posted │ │
│ │ │ │
│ │ │─ Merge changes │
│ │ │ (merge policy) │
│ │ │─ Save locally │
│ │ │ │
│ │─ NSPersistentStore ─►│─ remoteStoreDidChange()
│ │ RemoteChange │ │
│ │ notification │ │
│ │ │ │
│ │ │◄─ reloadSettings()
│ │ │ FromCloudStore()
│◄─ UI updates ◄───────────────────────────────────────────────
│ "Connected on │
│ Device A" │
Connection Lock Lifecycle
t=0s: Device A connects
┌─────────────────────────────────────┐
│ connectedByDeviceID = UUID_A │
│ connectedByDeviceName = "iPhone A" │
│ connectedAt = 2025-03-26 10:00:00 │
│ connectedExpiryAt = 10:02:00 │ ◄─ TTL = 120s
└─────────────────────────────────────┘
│
│ [CloudKit syncs]
▼
[Device B sees connection]
"Meter locked by iPhone A"
t=60s: Device A still connected
(TTL still valid)
t=120s: Connection expires
┌─────────────────────────────────────┐
│ connectedExpiryAt = 10:02:00 │
│ now() > connectedExpiryAt ✓ │
└─────────────────────────────────────┘
│
[Device B checks expiry]
│
if (now > connectedExpiryAt)
Device B can connect!
Merge Conflict Resolution
Device A Device B
t=0.5s: Sets meterName t=0.6s: Sets meterName
"Kitchen Meter" "Charger Meter"
│ │
▼ ▼
Save & Queue Save & Queue
│ │
└───────────┬────────────────────┘
▼
[Both pushed to CloudKit]
(Race condition)
CloudKit Merge Policy:
NSMergeByPropertyStoreTrumpMergePolicy
→ Latest write wins
Result on both devices:
✗ One value reverts (~1s after cloud update)
✓ No data loss, eventually consistent
Rebuild Flow (v2 → v3)
User launches app (v3)
│
▼
AppDelegate.didFinishLaunchingWithOptions
│
▼
rebuildCanonicalStoreIfNeeded(version: 3)
│
├─ Check UserDefaults["cloudStoreRebuildVersion.3"]
│
├─ if already done:
│ return (skip)
│
└─ if not done:
│
├─ Fetch all DeviceSettings records
│
├─ Group by normalized macAddress
│
├─ For each group:
│ ├─ Find "winner" (most data + most recent)
│ └─ Merge others INTO winner
│ └─ Delete duplicates
│
├─ context.save()
│ (CloudKit propagates deletes)
│
├─ UserDefaults["cloudStoreRebuildVersion.3"] = true
│
└─ ✓ Done (won't run again)
State Machine: Meter Connection
┌─────────────────┐
│ OFFLINE │ ◄──────────────────────────────┐
└────────┬────────┘ │
│ │
advertisment heard
│
▼
┌─────────────────────────────┐
│ PERIPHERAL_NOT_CONNECTED │
└────────┬────────────────────┘
│
user clicks "Connect"
Meter.connect() called
│
▼
┌─────────────────────────────┐
│ PERIPHERAL_CONNECTION_PD │
└────────┬────────────────────┘
│
BT services discovered
│
▼
┌─────────────────────────────┐
│ PERIPHERAL_CONNECTED │
│ (appData.publishMeterConn)│ ◄─────── CloudKit SyncStart
└────────┬────────────────────┘
│
peripheral ready for commands
│
▼
┌─────────────────────────────┐
│ PERIPHERAL_READY │
└────────┬────────────────────┘
│
data request sent
│
▼
┌─────────────────────────────┐
│ COMMUNICATING │
└────────┬────────────────────┘
│
data received
│
▼
┌─────────────────────────────┐
│ DATA_IS_AVAILABLE │ ──────── Cloud Polling Loop
└─────────────────────────────┘ (every 60s renew connection)
│
connect lost / timeout
│
▼ (Or clear in UI)
OFFLINE again
Connection Renewal (Heartbeat)
┌─────────────────────────────┐
│ Meter.connect() called │
│ operationalState changed │
│ → .peripheralConnected │
└──────────┬──────────────────┘
│
▼
startConnectionRenewal()
│
├─ Create timer: interval = 60s
│
└─ repeats every 60s:
│
├─ appData.publishMeterConnection()
│ (updates connectedAt + connectedExpiryAt)
│
└─ CloudKit syncs (keeps lock alive)
Disconnection:
┌─────────────────────────────┐
│ operationalState changed │
│ → .offline / .notConnected │
└──────────┬──────────────────┘
│
▼
stopConnectionRenewal()
│
├─ connectionRenewalTimer.invalidate()
│
└─ appData.clearMeterConnection()
(removes connectedByDeviceID)
│
└─ CloudKit syncs (lock released)
Error Recovery Paths
┌─────────────────────┐
│ Normal Operation │
└─────────────────────┘
│
▼ [Error: CloudKit unavailable]
┌──────────────────────────────┐
│ Queue changes locally │
│ (stored in SQLite) │
└──────────────────────────────┘
│
▼ [Network restored]
┌──────────────────────────────┐
│ NSPersistentCloudKit │
│ resumes sync automatically │
└──────────────────────────────┘
│
▼
┌─────────────────────┐
│ Back to normal │
└─────────────────────┘
┌─────────────────────┐
│ Collision detected │ (Device A & B both edited same field)
└─────────────────────┘
│
▼
┌──────────────────────────────┐
│ NSMergeByPropertyStoreTrump │
│ (CloudKit version wins) │
└──────────────────────────────┘
│
└─ User sees local change revert ~1s after
cloud update arrives (rare, acceptable)