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()
}
)
}