PersistenceController.swift (4890B)
1 import CoreData 2 import Foundation 3 4 private final class UpdatedAtMergePolicy: NSMergePolicy { 5 private let fallbackPolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) 6 7 init() { 8 super.init(merge: .mergeByPropertyStoreTrumpMergePolicyType) 9 } 10 11 override func resolve(mergeConflicts list: [Any]) throws { 12 var fallbackConflicts: [Any] = [] 13 14 for item in list { 15 guard 16 let conflict = item as? NSMergeConflict, 17 let item = conflict.sourceObject as? ItemEntity, 18 let objectSnapshot = conflict.objectSnapshot, 19 let persistedSnapshot = conflict.persistedSnapshot, 20 let storeUpdatedAt = persistedSnapshot["updatedAt"] as? Date 21 else { 22 fallbackConflicts.append(item) 23 continue 24 } 25 26 let localUpdatedAt = (objectSnapshot["updatedAt"] as? Date) ?? item.updatedAt 27 28 // Keep local in-memory values if they are newer or equal. 29 guard storeUpdatedAt > localUpdatedAt else { continue } 30 31 // Persisted values are newer; copy them onto the object to resolve conflict. 32 // Use setPrimitiveValue to bypass KVC change tracking so willSave() does not 33 // see these keys in changedValues() and overwrite updatedAt with Date(). 34 for (key, value) in persistedSnapshot { 35 if value is NSNull { 36 item.setPrimitiveValue(nil, forKey: key) 37 } else { 38 item.setPrimitiveValue(value, forKey: key) 39 } 40 } 41 } 42 43 if !fallbackConflicts.isEmpty { 44 try fallbackPolicy.resolve(mergeConflicts: fallbackConflicts) 45 } 46 } 47 } 48 49 @MainActor 50 final class PersistenceController { 51 static let shared = PersistenceController() 52 53 private static let model: NSManagedObjectModel = { 54 let bundle = Bundle(for: PersistenceController.self) 55 guard let url = bundle.url(forResource: "Listless", withExtension: "momd"), 56 let model = NSManagedObjectModel(contentsOf: url) 57 else { 58 fatalError("Failed to load Core Data model") 59 } 60 return model 61 }() 62 63 let container: NSPersistentContainer 64 let syncMonitor: CloudKitSyncMonitor 65 66 var viewContext: NSManagedObjectContext { 67 container.viewContext 68 } 69 70 init(inMemory: Bool = false) { 71 syncMonitor = CloudKitSyncMonitor() 72 73 if inMemory { 74 // Use a plain NSPersistentContainer (no CloudKit) with a unique 75 // temporary store so each launch is fully isolated. 76 let tempURL = FileManager.default.temporaryDirectory 77 .appendingPathComponent(UUID().uuidString) 78 .appendingPathExtension("sqlite") 79 container = NSPersistentContainer( 80 name: "Listless", managedObjectModel: Self.model) 81 container.persistentStoreDescriptions.first?.url = tempURL 82 } else { 83 container = NSPersistentCloudKitContainer( 84 name: "Listless", managedObjectModel: Self.model) 85 // Configure CloudKit sync 86 guard let description = container.persistentStoreDescriptions.first else { 87 fatalError("Failed to retrieve persistent store description") 88 } 89 90 #if DEBUG 91 if let storeURL = description.url { 92 // Keep debug builds isolated from TestFlight/App Store local Core Data files. 93 description.url = storeURL.deletingLastPathComponent().appendingPathComponent( 94 "Listless-Debug.sqlite" 95 ) 96 } 97 #endif 98 99 description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( 100 containerIdentifier: "iCloud.net.inqk.listless" 101 ) 102 103 description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 104 description.setOption( 105 true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 106 } 107 108 container.loadPersistentStores { storeDescription, error in 109 if let error = error as NSError? { 110 fatalError("Unresolved error \(error), \(error.userInfo)") 111 } 112 } 113 114 container.viewContext.automaticallyMergesChangesFromParent = true 115 container.viewContext.mergePolicy = UpdatedAtMergePolicy() 116 117 if !inMemory, let cloudContainer = container as? NSPersistentCloudKitContainer { 118 syncMonitor.startMonitoring(container: cloudContainer) 119 } 120 121 } 122 123 func save() throws { 124 let context = container.viewContext 125 126 guard context.hasChanges else { return } 127 128 do { 129 try context.save() 130 } catch { 131 throw ItemStoreError.saveFailed(error) 132 } 133 } 134 }