Section 11/181 menit

11. CloudKit Sync: Strategi dan Conflict Resolution

11. CloudKit Sync: Strategi dan Conflict Resolution

Setup CloudKit dengan SwiftData

swift
// ModelContainer dengan CloudKit sync
let schema = Schema([User.self, Task.self, Note.self])

let modelConfiguration = ModelConfiguration(
    schema: schema,
    isStoredInMemoryOnly: false,
    cloudKitDatabase: .automatic  // Sync ke iCloud otomatis
)

let container = try ModelContainer(
    for: schema,
    configurations: modelConfiguration
)

// Requirement untuk CloudKit sync:
// 1. Semua property harus optional ATAU punya default value
// 2. Tidak ada @Attribute(.unique) — CloudKit tidak support constraint ini
// 3. App harus punya CloudKit capability di Xcode
// 4. Entitlements: com.apple.developer.icloud-container-identifiers

Constraint: Model CloudKit-Compatible

swift
// ❌ TIDAK kompatibel CloudKit: non-optional tanpa default, unique constraint
@Model
final class CloudTask {
    @Attribute(.unique) var remoteID: String     // ← CloudKit tidak support
    var title: String                             // ← Non-optional tanpa default
    var isCompleted: Bool                         // ← Non-optional tanpa default
}

// ✓ CloudKit-compatible: semua optional atau punya default
@Model
final class CloudTask {
    var remoteID: String?                         // ← Optional
    var title: String = ""                        // ← Default value
    var isCompleted: Bool = false                 // ← Default value
    var createdAt: Date = Date.now                // ← Default value
    var notes: String?                            // ← Optional
}

Monitoring Sync Status

swift
import CloudKit
import SwiftUI

class CloudSyncMonitor: ObservableObject {
    @Published var syncStatus: SyncStatus = .idle
    @Published var lastSyncDate: Date?
    @Published var syncError: Error?
    
    enum SyncStatus {
        case idle, syncing, success, error
        
        var description: String {
            switch self {
            case .idle: return "Menunggu..."
            case .syncing: return "Sinkronisasi..."
            case .success: return "Tersinkron"
            case .error: return "Gagal sinkronisasi"
            }
        }
    }
    
    init() {
        setupNotifications()
    }
    
    private func setupNotifications() {
        // Monitor perubahan iCloud account
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(cloudKitAccountChanged),
            name: .CKAccountChanged,
            object: nil
        )
        
        // Monitor import dari CloudKit
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(persistentStoreRemoteChange),
            name: .NSPersistentStoreRemoteChange,
            object: nil
        )
    }
    
    @objc private func cloudKitAccountChanged() {
        Task { @MainActor in
            let status = try? await CKContainer.default().accountStatus()
            syncStatus = status == .available ? .idle : .error
        }
    }
    
    @objc private func persistentStoreRemoteChange() {
        Task { @MainActor in
            syncStatus = .success
            lastSyncDate = Date.now
        }
    }
    
    func checkCloudKitAvailability() async -> Bool {
        do {
            let status = try await CKContainer.default().accountStatus()
            return status == .available
        } catch {
            syncError = error
            return false
        }
    }
}

Strategi Conflict Resolution

swift
// SwiftData dengan CloudKit menggunakan "last write wins" secara default
// Untuk conflict resolution yang lebih complex, tracking timestamps diperlukan

@Model
final class SyncableNote {
    var content: String = ""
    var updatedAt: Date = Date.now         // Track kapan diubah
    var deviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? ""
    
    // Versi counter untuk optimistic locking
    var version: Int = 0
    
    func update(content: String) {
        self.content = content
        self.updatedAt = Date.now
        self.version += 1
    }
}

// Saat fetch setelah sync, merge conflict secara manual jika diperlukan
@ModelActor
actor ConflictResolutionActor {
    func resolveConflicts(for noteID: PersistentIdentifier) async throws {
        guard let note = modelContext.model(for: noteID) as? SyncableNote else { return }
        
        // Fetch semua versi dengan ID yang sama (setelah sync CloudKit)
        let descriptor = FetchDescriptor<SyncableNote>(
            predicate: #Predicate { $0.version > 0 }
        )
        let allVersions = try modelContext.fetch(descriptor)
        
        // Keep versi terbaru berdasarkan updatedAt
        let latest = allVersions.max(by: { $0.updatedAt < $1.updatedAt })
        
        for version in allVersions where version.persistentModelID != latest?.persistentModelID {
            modelContext.delete(version)
        }
        
        try modelContext.save()
    }
}