damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }