Section 17/181 menit

17. Real Use Cases

17. Real Use Cases

Use Case 1: Sync Engine dengan Upsert dan Conflict Detection

App Notes yang sync dengan server, handle offline, dan detect conflict.

swift
import SwiftData
import Foundation

// Model yang CloudKit-compatible DAN support upsert
@Model
final class Note {
    @Attribute(.unique) var remoteID: String    // Upsert key
    var title: String = ""
    var content: String = ""
    var updatedAt: Date = Date.now
    var localUpdatedAt: Date = Date.now          // Perubahan lokal
    var isSynced: Bool = false                   // Apakah sudah sync ke server
    var isDeleted: Bool = false                  // Soft delete untuk sync
    
    init(remoteID: String, title: String, content: String) {
        self.remoteID = remoteID
        self.title = title
        self.content = content
    }
}

@ModelActor
actor NoteSyncEngine {
    
    struct SyncResult {
        let inserted: Int
        let updated: Int
        let conflicts: [String]  // remoteIDs yang conflict
    }
    
    // Pull dari server: upsert semua, detect conflict
    func pullFromServer(dtos: [NoteDTO]) async throws -> SyncResult {
        var inserted = 0
        var updated = 0
        var conflicts: [String] = []
        
        for dto in dtos {
            // Cek apakah sudah ada di lokal
            let descriptor = FetchDescriptor<Note>(
                predicate: #Predicate { $0.remoteID == dto.id }
            )
            
            if let existing = try modelContext.fetch(descriptor).first {
                // Conflict detection: lokal lebih baru dari server
                if existing.localUpdatedAt > dto.updatedAt && !existing.isSynced {
                    conflicts.append(dto.id)
                    // Strategi: server wins (last write wins)
                    // Atau: local wins — tergantung business logic
                }
                
                // Update dari server (server wins)
                existing.title = dto.title
                existing.content = dto.content
                existing.updatedAt = dto.updatedAt
                existing.isSynced = true
                updated += 1
            } else {
                // Insert baru dari server
                let note = Note(remoteID: dto.id, title: dto.title, content: dto.content)
                note.updatedAt = dto.updatedAt
                note.isSynced = true
                modelContext.insert(note)
                inserted += 1
            }
        }
        
        try modelContext.save()
        return SyncResult(inserted: inserted, updated: updated, conflicts: conflicts)
    }
    
    // Push ke server: ambil semua yang belum sync
    func getUnsyncedNotes() async throws -> [NoteDTO] {
        let descriptor = FetchDescriptor<Note>(
            predicate: #Predicate { !$0.isSynced && !$0.isDeleted }
        )
        let unsyncedNotes = try modelContext.fetch(descriptor)
        return unsyncedNotes.map { NoteDTO(id: $0.remoteID, title: $0.title,
                                          content: $0.content, updatedAt: $0.updatedAt) }
    }
    
    // Mark sebagai synced setelah server konfirmasi
    func markAsSynced(remoteIDs: [String]) async throws {
        for remoteID in remoteIDs {
            let descriptor = FetchDescriptor<Note>(
                predicate: #Predicate { $0.remoteID == remoteID }
            )
            if let note = try modelContext.fetch(descriptor).first {
                note.isSynced = true
            }
        }
        try modelContext.save()
    }
    
    // Soft delete
    func deleteNote(remoteID: String) async throws {
        let descriptor = FetchDescriptor<Note>(
            predicate: #Predicate { $0.remoteID == remoteID }
        )
        if let note = try modelContext.fetch(descriptor).first {
            note.isDeleted = true
            note.isSynced = false  // Perlu sync deletion ke server
            try modelContext.save()
        }
    }
}

struct NoteDTO: Codable {
    let id: String
    let title: String
    let content: String
    let updatedAt: Date
}

Use Case 2: Analytics Event Store dengan Auto-purge

Simpan analytics events secara lokal, batch upload ke server, auto-purge yang sudah lama.

swift
@Model
final class AnalyticsEvent {
    @Attribute(.index)
    var timestamp: Date = Date.now
    
    @Attribute(.index)
    var isUploaded: Bool = false
    
    var eventName: String = ""
    var propertiesJSON: String = "{}"  // Serialized JSON
    var sessionID: String = ""
    var userID: String?
    
    init(name: String, properties: [String: Any] = [:], sessionID: String) {
        self.eventName = name
        self.sessionID = sessionID
        self.timestamp = Date.now
        
        if let data = try? JSONSerialization.data(withJSONObject: properties),
           let json = String(data: data, encoding: .utf8) {
            self.propertiesJSON = json
        }
    }
    
    var properties: [String: Any] {
        guard let data = propertiesJSON.data(using: .utf8),
              let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            return [:]
        }
        return dict
    }
}

@ModelActor
actor AnalyticsStore {
    
    // Track event — sangat cepat, background
    func track(name: String, properties: [String: Any], sessionID: String) async {
        let event = AnalyticsEvent(name: name, properties: properties, sessionID: sessionID)
        modelContext.insert(event)
        try? modelContext.save()
    }
    
    // Ambil batch untuk upload (max 100 event)
    func getUploadBatch() async throws -> [AnalyticsEvent] {
        var descriptor = FetchDescriptor<AnalyticsEvent>(
            predicate: #Predicate { !$0.isUploaded },
            sortBy: [SortDescriptor(\AnalyticsEvent.timestamp)]
        )
        descriptor.fetchLimit = 100
        return try modelContext.fetch(descriptor)
    }
    
    // Mark sebagai uploaded
    func markUploaded(events: [AnalyticsEvent]) async throws {
        for event in events {
            event.isUploaded = true
        }
        try modelContext.save()
    }
    
    // Auto-purge: hapus event yang sudah diupload dan berumur >7 hari
    func purgeOldEvents() async throws -> Int {
        let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date.now)!
        let descriptor = FetchDescriptor<AnalyticsEvent>(
            predicate: #Predicate<AnalyticsEvent> { event in
                event.isUploaded && event.timestamp < cutoff
            }
        )
        let old = try modelContext.fetch(descriptor)
        for event in old {
            modelContext.delete(event)
        }
        if !old.isEmpty { try modelContext.save() }
        return old.count
    }
    
    // Stats
    func eventCount(uploaded: Bool? = nil) async throws -> Int {
        var descriptor = FetchDescriptor<AnalyticsEvent>()
        if let uploaded {
            descriptor.predicate = #Predicate { $0.isUploaded == uploaded }
        }
        return try modelContext.fetchCount(descriptor)
    }
}

// Background scheduler untuk upload dan purge
class AnalyticsUploadScheduler {
    private let store: AnalyticsStore
    private let apiClient: APIClient
    
    init(container: ModelContainer) {
        self.store = AnalyticsStore(modelContainer: container)
        self.apiClient = APIClient()
    }
    
    func runUploadCycle() async {
        do {
            let batch = try await store.getUploadBatch()
            guard !batch.isEmpty else { return }
            
            let payloads = batch.map { AnalyticsPayload(event: $0) }
            try await apiClient.upload(events: payloads)
            try await store.markUploaded(events: batch)
            
            let purged = try await store.purgeOldEvents()
            print("Uploaded \(batch.count) events, purged \(purged) old events")
        } catch {
            print("Analytics upload failed: \(error)")
        }
    }
}

struct AnalyticsPayload: Encodable {
    let name: String
    let timestamp: Date
    let properties: [String: String]
    
    init(event: AnalyticsEvent) {
        self.name = event.eventName
        self.timestamp = event.timestamp
        self.properties = event.properties.mapValues { "\($0)" }
    }
}

Use Case 3: Full Migration dengan Data Transformation

Migrasi schema yang memindahkan data dari struktur lama ke struktur baru dengan transformasi.

swift
// Skenario: App v1 punya satu model "Person" dengan field gabungan
// App v2 split menjadi "User" (akun) dan "Profile" (info personal)

enum PersonSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Person.self] }
    
    @Model final class Person {
        var username: String
        var email: String
        var fullName: String
        var bio: String?
        var avatarURL: String?
        var followerCount: Int = 0
        
        init(username: String, email: String, fullName: String) {
            self.username = username
            self.email = email
            self.fullName = fullName
        }
    }
}

enum PersonSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [User.self, Profile.self] }
    
    @Model final class User {
        @Attribute(.unique) var username: String
        var email: String
        var createdAt: Date = Date.now
        
        @Relationship(deleteRule: .cascade, inverse: \Profile.user)
        var profile: Profile?
        
        init(username: String, email: String) {
            self.username = username
            self.email = email
        }
    }
    
    @Model final class Profile {
        var fullName: String = ""
        var bio: String?
        var avatarURL: String?
        var followerCount: Int = 0
        var user: User?
        
        init(fullName: String) {
            self.fullName = fullName
        }
    }
}

enum PersonMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] { [PersonSchemaV1.self, PersonSchemaV2.self] }
    
    static var stages: [MigrationStage] { [migrateV1toV2] }
    
    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: PersonSchemaV1.self,
        toVersion: PersonSchemaV2.self,
        willMigrate: nil,
        didMigrate: { context in
            // Setelah schema di-migrate, isi relasi User ↔ Profile
            // Pada tahap ini, V2 models sudah tersedia tapi relasi mungkin kosong
            
            let users = try context.fetch(FetchDescriptor<PersonSchemaV2.User>())
            
            for user in users {
                if user.profile == nil {
                    // Buat Profile untuk setiap User yang belum punya
                    let profile = PersonSchemaV2.Profile(fullName: user.email.components(separatedBy: "@").first ?? "")
                    user.profile = profile
                    context.insert(profile)
                }
            }
            
            try context.save()
        }
    )
}