diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 581fd570..fb547596 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -261,6 +261,7 @@ 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */; }; 501F8C5529FF5EF6001AFC1D /* PersistedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C5429FF5EF6001AFC1D /* PersistedProfile.swift */; }; 501F8C5829FF5FC5001AFC1D /* Damus.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C5629FF5FC5001AFC1D /* Damus.xcdatamodeld */; }; + 501F8C5A29FF70F5001AFC1D /* ProfileDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */; }; 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; }; 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B5685229F97CB400A23243 /* CredentialHandler.swift */; }; 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; }; @@ -686,6 +687,7 @@ 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorageTests.swift; sourceTree = ""; }; 501F8C5429FF5EF6001AFC1D /* PersistedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProfile.swift; sourceTree = ""; }; 501F8C5729FF5FC5001AFC1D /* Damus.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Damus.xcdatamodel; sourceTree = ""; }; + 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDatabase.swift; sourceTree = ""; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = ""; }; 50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = ""; }; 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = ""; }; @@ -1000,6 +1002,7 @@ 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */, 4CEE2AEC2805B22500AB5EEF /* NostrRequest.swift */, 4CACA9DB280C38C000D9BBE8 /* Profiles.swift */, + 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */, 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */, 4C363A8F28247A1D006E126D /* NostrLink.swift */, 50088DA029E8271A008A1FDF /* WebSocket.swift */, @@ -1651,6 +1654,7 @@ 4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */, 4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */, 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */, + 501F8C5A29FF70F5001AFC1D /* ProfileDatabase.swift in Sources */, 4CF0ABE7298444FD00D66079 /* MutedEventView.swift in Sources */, 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */, 4CF0ABE12981A83900D66079 /* MutelistView.swift in Sources */, diff --git a/damus/Nostr/ProfileDatabase.swift b/damus/Nostr/ProfileDatabase.swift new file mode 100644 index 00000000..6da247bd --- /dev/null +++ b/damus/Nostr/ProfileDatabase.swift @@ -0,0 +1,110 @@ +// +// ProfileDatabase.swift +// damus +// +// Created by Bryan Montz on 4/30/23. +// + +import Foundation +import CoreData + +enum ProfileDatabaseError: Error { + case missing_context + case outdated_input +} + +final class ProfileDatabase { + + private let entity_name = "PersistedProfile" + private var persistent_container: NSPersistentContainer? + private var background_context: NSManagedObjectContext? + + init() { + set_up() + } + + private var profile_cache_url: URL { + (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("profiles"))! + } + + private var persistent_store_description: NSPersistentStoreDescription { + let description = NSPersistentStoreDescription(url: profile_cache_url) + description.type = NSSQLiteStoreType + description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) + description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) + description.setOption(true as NSNumber, forKey: NSSQLiteManualVacuumOption) + return description + } + + private var object_model: NSManagedObjectModel? { + guard let url = Bundle.main.url(forResource: "Damus", withExtension: "momd") else { + return nil + } + return NSManagedObjectModel(contentsOf: url) + } + + private func set_up() { + guard let object_model else { + print("⚠️ Warning: ProfileDatabase failed to load its object model") + return + } + + persistent_container = NSPersistentContainer(name: "Damus", managedObjectModel: object_model) + persistent_container?.persistentStoreDescriptions = [persistent_store_description] + persistent_container?.loadPersistentStores { _, error in + if let error { + print("WARNING: ProfileDatabase failed to load: \(error)") + } + } + + persistent_container?.viewContext.automaticallyMergesChangesFromParent = true + persistent_container?.viewContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) + + background_context = persistent_container?.newBackgroundContext() + background_context?.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) + } + + private func get_persisted(id: String) -> PersistedProfile? { + let request = NSFetchRequest(entityName: entity_name) + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + return try? persistent_container?.viewContext.fetch(request).first + } + + // MARK: - Public + + /// Updates or inserts a new Profile into the local database. Rejects profiles whose update date + /// is older than one we already have. Database writes occur on a background context for best performance. + /// - Parameters: + /// - id: Profile id (pubkey) + /// - profile: Profile object to be stored + /// - last_update: Date that the Profile was updated + func upsert(id: String, profile: Profile, last_update: Date) throws { + guard let context = background_context else { + throw ProfileDatabaseError.missing_context + } + + var persisted_profile: PersistedProfile? + if let profile = get_persisted(id: id) { + if let existing_last_update = profile.last_update, last_update < existing_last_update { + throw ProfileDatabaseError.outdated_input + } else { + persisted_profile = profile + } + } else { + persisted_profile = NSEntityDescription.insertNewObject(forEntityName: entity_name, into: context) as? PersistedProfile + persisted_profile?.id = id + } + persisted_profile?.copyValues(from: profile) + persisted_profile?.last_update = last_update + + try context.save() + } + + func get(id: String) -> Profile? { + guard let profile = get_persisted(id: id) else { + return nil + } + return Profile(persisted_profile: profile) + } +}