Listless/Sync/PersistenceController.swift (4890B)
1import CoreData
2import Foundation
3
4private 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
29 guard storeUpdatedAt > localUpdatedAt else { continue }
30
31
32
33
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
50final 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
75
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
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
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}