ProfileDatabase.swift (7323B)
1 // 2 // ProfileDatabase.swift 3 // damus 4 // 5 // Created by Bryan Montz on 4/30/23. 6 // 7 8 import Foundation 9 import CoreData 10 11 enum ProfileDatabaseError: Error { 12 case missing_context 13 case outdated_input 14 } 15 16 final class ProfileDatabase { 17 18 private let entity_name = "PersistedProfile" 19 private var persistent_container: NSPersistentContainer? 20 private var background_context: NSManagedObjectContext? 21 private let cache_url: URL 22 23 /// This queue is used to synchronize access to the network_pull_date_cache dictionary, which 24 /// prevents data races from crashing the app. 25 private var queue = DispatchQueue(label: "io.damus.profile_db", 26 qos: .userInteractive, 27 attributes: .concurrent) 28 private var network_pull_date_cache = [Pubkey: Date]() 29 30 init(cache_url: URL = ProfileDatabase.profile_cache_url) { 31 self.cache_url = cache_url 32 set_up() 33 } 34 35 private static var profile_cache_url: URL { 36 (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("profiles"))! 37 } 38 39 private var persistent_store_description: NSPersistentStoreDescription { 40 let description = NSPersistentStoreDescription(url: cache_url) 41 description.type = NSSQLiteStoreType 42 description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) 43 description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) 44 description.setOption(true as NSNumber, forKey: NSSQLiteManualVacuumOption) 45 return description 46 } 47 48 private var object_model: NSManagedObjectModel? { 49 guard let url = Bundle.main.url(forResource: "Damus", withExtension: "momd") else { 50 return nil 51 } 52 return NSManagedObjectModel(contentsOf: url) 53 } 54 55 private func set_up() { 56 guard let object_model else { 57 print("⚠️ Warning: ProfileDatabase failed to load its object model") 58 return 59 } 60 61 persistent_container = NSPersistentContainer(name: "Damus", managedObjectModel: object_model) 62 persistent_container?.persistentStoreDescriptions = [persistent_store_description] 63 persistent_container?.loadPersistentStores { _, error in 64 if let error { 65 print("WARNING: ProfileDatabase failed to load: \(error)") 66 } 67 } 68 69 persistent_container?.viewContext.automaticallyMergesChangesFromParent = true 70 persistent_container?.viewContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) 71 72 background_context = persistent_container?.newBackgroundContext() 73 background_context?.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) 74 } 75 76 private func get_persisted(id: Pubkey, context: NSManagedObjectContext) -> PersistedProfile? { 77 let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) 78 request.predicate = NSPredicate(format: "id == %@", id.hex()) 79 request.fetchLimit = 1 80 return try? context.fetch(request).first 81 } 82 83 func get_network_pull_date(id: Pubkey) -> Date? { 84 var pull_date: Date? 85 queue.sync { 86 pull_date = network_pull_date_cache[id] 87 } 88 if let pull_date { 89 return pull_date 90 } 91 92 let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) 93 request.predicate = NSPredicate(format: "id == %@", id.hex()) 94 request.fetchLimit = 1 95 request.propertiesToFetch = ["network_pull_date"] 96 guard let profile = try? persistent_container?.viewContext.fetch(request).first else { 97 return nil 98 } 99 100 queue.async(flags: .barrier) { 101 self.network_pull_date_cache[id] = profile.network_pull_date 102 } 103 return profile.network_pull_date 104 } 105 106 // MARK: - Public 107 108 /// Updates or inserts a new Profile into the local database. Rejects profiles whose update date 109 /// is older than one we already have. Database writes occur on a background context for best performance. 110 /// - Parameters: 111 /// - id: Profile id (pubkey) 112 /// - profile: Profile object to be stored 113 /// - last_update: Date that the Profile was updated 114 func upsert(id: Pubkey, profile: Profile, last_update: Date) async throws { 115 guard let context = background_context else { 116 throw ProfileDatabaseError.missing_context 117 } 118 119 try await context.perform { 120 var persisted_profile: PersistedProfile? 121 if let profile = self.get_persisted(id: id, context: context) { 122 if let existing_last_update = profile.last_update, last_update < existing_last_update { 123 throw ProfileDatabaseError.outdated_input 124 } else { 125 persisted_profile = profile 126 } 127 } else { 128 persisted_profile = NSEntityDescription.insertNewObject(forEntityName: self.entity_name, into: context) as? PersistedProfile 129 persisted_profile?.id = id.hex() 130 } 131 persisted_profile?.copyValues(from: profile) 132 persisted_profile?.last_update = last_update 133 134 let pull_date = Date.now 135 persisted_profile?.network_pull_date = pull_date 136 self.queue.async(flags: .barrier) { 137 self.network_pull_date_cache[id] = pull_date 138 } 139 140 try context.save() 141 } 142 } 143 144 func get(id: Pubkey) -> Profile? { 145 guard let container = persistent_container, 146 let profile = get_persisted(id: id, context: container.viewContext) else { 147 return nil 148 } 149 return Profile(persisted_profile: profile) 150 } 151 152 var count: Int { 153 let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) 154 let count = try? persistent_container?.viewContext.count(for: request) 155 return count ?? 0 156 } 157 158 func remove_all_profiles() throws { 159 guard let context = background_context, let container = persistent_container else { 160 throw ProfileDatabaseError.missing_context 161 } 162 163 queue.async(flags: .barrier) { 164 self.network_pull_date_cache.removeAll() 165 } 166 167 let request = NSFetchRequest<NSFetchRequestResult>(entityName: entity_name) 168 let batch_delete_request = NSBatchDeleteRequest(fetchRequest: request) 169 batch_delete_request.resultType = .resultTypeObjectIDs 170 171 let result = try container.persistentStoreCoordinator.execute(batch_delete_request, with: context) as! NSBatchDeleteResult 172 173 // NSBatchDeleteRequest is an NSPersistentStoreRequest, which operates on disk. So now we'll manually update our in-memory context. 174 if let object_ids = result.result as? [NSManagedObjectID] { 175 let changes: [AnyHashable: Any] = [ 176 NSDeletedObjectsKey: object_ids 177 ] 178 NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) 179 } 180 } 181 }