damus

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

commit 05503024ccadd1e76be9be4cbeaad72a8a73f3d3
parent e4e477a2acbe7c790ff15ebca418159aa3ddbcfa
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 26 May 2023 12:11:43 -0700

Profile Caching

Changelog-Added: Add profile caching

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 37+++++++++++++++++++++++++++++++++++++
Mdamus/Models/FollowersModel.swift | 2+-
Mdamus/Models/FollowingModel.swift | 2+-
Mdamus/Models/SearchHomeModel.swift | 30+++---------------------------
Adamus/Nostr/CoreData/Damus.xcdatamodeld/Damus.xcdatamodel/contents | 19+++++++++++++++++++
Adamus/Nostr/CoreData/PersistedProfile.swift | 39+++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/Nostr.swift | 13+++++++++++++
Adamus/Nostr/ProfileDatabase.swift | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/Profiles.swift | 43++++++++++++++++++++++++++++++++++---------
AdamusTests/ProfileDatabaseTests.swift | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MdamusTests/ZapTests.swift | 3++-
11 files changed, 454 insertions(+), 39 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -267,6 +267,10 @@ 50088DA129E8271A008A1FDF /* WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50088DA029E8271A008A1FDF /* WebSocket.swift */; }; 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */; }; 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */; }; + 5019CADD2A0FB0A9000069E1 /* ProfileDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5019CADC2A0FB0A9000069E1 /* ProfileDatabaseTests.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 */; }; 50DA11262A16A23F00236234 /* Launch.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50DA11252A16A23F00236234 /* Launch.storyboard */; }; @@ -699,6 +703,10 @@ 50088DA029E8271A008A1FDF /* WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocket.swift; sourceTree = "<group>"; }; 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorage.swift; sourceTree = "<group>"; }; 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorageTests.swift; sourceTree = "<group>"; }; + 5019CADC2A0FB0A9000069E1 /* ProfileDatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDatabaseTests.swift; sourceTree = "<group>"; }; + 501F8C5429FF5EF6001AFC1D /* PersistedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProfile.swift; sourceTree = "<group>"; }; + 501F8C5729FF5FC5001AFC1D /* Damus.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Damus.xcdatamodel; sourceTree = "<group>"; }; + 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDatabase.swift; sourceTree = "<group>"; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; }; 50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = "<group>"; }; 50DA11252A16A23F00236234 /* Launch.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Launch.storyboard; sourceTree = "<group>"; }; @@ -1006,6 +1014,7 @@ 4C75EFAB28049CC80006080F /* Nostr */ = { isa = PBXGroup; children = ( + 501F8C5329FF5EE2001AFC1D /* CoreData */, 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */, 4C75EFA527FF87A20006080F /* Nostr.swift */, 4C75EFAE28049D340006080F /* NostrFilter.swift */, @@ -1016,6 +1025,7 @@ 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */, 4CEE2AEC2805B22500AB5EEF /* NostrRequest.swift */, 4CACA9DB280C38C000D9BBE8 /* Profiles.swift */, + 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */, 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */, 4C363A8F28247A1D006E126D /* NostrLink.swift */, 50088DA029E8271A008A1FDF /* WebSocket.swift */, @@ -1302,6 +1312,7 @@ 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */, 3A3040EC29A5CB86008A0F29 /* ReplyDescriptionTests.swift */, 3A3040EE29A8FEE9008A0F29 /* EventDetailBarTests.swift */, + 5019CADC2A0FB0A9000069E1 /* ProfileDatabaseTests.swift */, 3A3040F229A91366008A0F29 /* ProfileViewTests.swift */, 3A30410029AB12AA008A0F29 /* EventGroupViewTests.swift */, 4C8D00D329E3C5D40036AF10 /* NIP19Tests.swift */, @@ -1393,6 +1404,15 @@ path = Images; sourceTree = "<group>"; }; + 501F8C5329FF5EE2001AFC1D /* CoreData */ = { + isa = PBXGroup; + children = ( + 501F8C5429FF5EF6001AFC1D /* PersistedProfile.swift */, + 501F8C5629FF5FC5001AFC1D /* Damus.xcdatamodeld */, + ); + path = CoreData; + sourceTree = "<group>"; + }; 7C0F392D29B57C8F0039859C /* Extensions */ = { isa = PBXGroup; children = ( @@ -1674,6 +1694,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 */, @@ -1769,6 +1790,7 @@ 4C1A9A2729DDE31900516EAC /* TranslationSettingsView.swift in Sources */, 4CB8838629656C8B00DC99E7 /* NIP05.swift in Sources */, 4CF0ABD82981980C00D66079 /* Lists.swift in Sources */, + 501F8C5829FF5FC5001AFC1D /* Damus.xcdatamodeld in Sources */, 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */, 4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */, @@ -1823,6 +1845,7 @@ F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */, 4C3A1D332960DB0500558C0F /* Markdown.swift in Sources */, 4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */, + 501F8C5529FF5EF6001AFC1D /* PersistedProfile.swift in Sources */, 4CF0ABD42980996B00D66079 /* Report.swift in Sources */, 4C06670B28FDE64700038D2A /* damus.c in Sources */, 4C1A9A2529DDDF2600516EAC /* ZapSettingsView.swift in Sources */, @@ -1856,6 +1879,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5019CADD2A0FB0A9000069E1 /* ProfileDatabaseTests.swift in Sources */, 3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */, 4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */, 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */, @@ -2375,6 +2399,19 @@ productName = secp256k1; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 501F8C5629FF5FC5001AFC1D /* Damus.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 501F8C5729FF5FC5001AFC1D /* Damus.xcdatamodel */, + ); + currentVersion = 501F8C5729FF5FC5001AFC1D /* Damus.xcdatamodel */; + path = Damus.xcdatamodeld; + sourceTree = "<group>"; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 4CE6DEDB27F7A08100C66700 /* Project object */; } diff --git a/damus/Models/FollowersModel.swift b/damus/Models/FollowersModel.swift @@ -57,7 +57,7 @@ class FollowersModel: ObservableObject { func load_profiles(relay_id: String) { var filter = NostrFilter.filter_profiles - let authors = find_profiles_to_fetch_pk(profiles: damus_state.profiles, event_pubkeys: contacts ?? []) + let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? []) if authors.isEmpty { return } diff --git a/damus/Models/FollowingModel.swift b/damus/Models/FollowingModel.swift @@ -24,7 +24,7 @@ class FollowingModel { var f = NostrFilter.filter_kinds([NostrKind.metadata.rawValue]) f.authors = self.contacts.reduce(into: Array<String>()) { acc, pk in // don't fetch profiles we already have - if damus_state.profiles.lookup(id: pk) != nil { + if damus_state.profiles.has_fresh_profile(id: pk) { return } acc.append(pk) diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift @@ -90,20 +90,6 @@ class SearchHomeModel: ObservableObject { } } -func find_profiles_to_fetch_pk(profiles: Profiles, event_pubkeys: [String]) -> [String] { - var pubkeys = Set<String>() - - for pk in event_pubkeys { - if profiles.lookup(id: pk) != nil { - continue - } - - pubkeys.insert(pk) - } - - return Array(pubkeys) -} - func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache) -> [String] { switch load { case .from_events(let events): @@ -114,17 +100,7 @@ func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: Even } func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [String]) -> [String] { - var pubkeys = Set<String>() - - for pk in pks { - if profiles.lookup(id: pk) != nil { - continue - } - - pubkeys.insert(pk) - } - - return Array(pubkeys) + Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk) })) } func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [String] { @@ -132,11 +108,11 @@ func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent] for ev in events { // lookup profiles from boosted events - if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), profiles.lookup(id: bev.pubkey) == nil { + if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey) { pubkeys.insert(bev.pubkey) } - if profiles.lookup(id: ev.pubkey) == nil { + if !profiles.has_fresh_profile(id: ev.pubkey) { pubkeys.insert(ev.pubkey) } } diff --git a/damus/Nostr/CoreData/Damus.xcdatamodeld/Damus.xcdatamodel/contents b/damus/Nostr/CoreData/Damus.xcdatamodeld/Damus.xcdatamodel/contents @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22E261" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> + <entity name="PersistedProfile" representedClassName="PersistedProfile" syncable="YES"> + <attribute name="about" optional="YES" attributeType="String"/> + <attribute name="banner" optional="YES" attributeType="String"/> + <attribute name="damus_donation" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="display_name" optional="YES" attributeType="String"/> + <attribute name="id" optional="YES" attributeType="String"/> + <attribute name="last_update" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="lud06" optional="YES" attributeType="String"/> + <attribute name="lud16" optional="YES" attributeType="String"/> + <attribute name="name" optional="YES" attributeType="String"/> + <attribute name="network_pull_date" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="nip05" optional="YES" attributeType="String"/> + <attribute name="picture" optional="YES" attributeType="String"/> + <attribute name="website" optional="YES" attributeType="String"/> + </entity> +</model>+ \ No newline at end of file diff --git a/damus/Nostr/CoreData/PersistedProfile.swift b/damus/Nostr/CoreData/PersistedProfile.swift @@ -0,0 +1,39 @@ +// +// PersistedProfile.swift +// damus +// +// Created by Bryan Montz on 4/30/23. +// + +import Foundation +import CoreData + +@objc(PersistedProfile) +final class PersistedProfile: NSManagedObject { + @NSManaged var id: String? + @NSManaged var name: String? + @NSManaged var display_name: String? + @NSManaged var about: String? + @NSManaged var picture: String? + @NSManaged var banner: String? + @NSManaged var website: String? + @NSManaged var lud06: String? + @NSManaged var lud16: String? + @NSManaged var nip05: String? + @NSManaged var damus_donation: Int16 + @NSManaged var last_update: Date? // The date that the profile was last updated by the user + @NSManaged var network_pull_date: Date? // The date we got this profile from a relay (for staleness checking) + + func copyValues(from profile: Profile) { + name = profile.name + display_name = profile.display_name + about = profile.about + picture = profile.picture + banner = profile.banner + website = profile.website + lud06 = profile.lud06 + lud16 = profile.lud16 + nip05 = profile.nip05 + damus_donation = profile.damus_donation != nil ? Int16(profile.damus_donation!) : 0 + } +} diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift @@ -24,6 +24,19 @@ class Profile: Codable { self.damus_donation = damus_donation } + convenience init(persisted_profile: PersistedProfile) { + self.init(name: persisted_profile.name, + display_name: persisted_profile.display_name, + about: persisted_profile.about, + picture: persisted_profile.picture, + banner: persisted_profile.banner, + website: persisted_profile.website, + lud06: persisted_profile.lud06, + lud16: persisted_profile.lud16, + nip05: persisted_profile.nip05, + damus_donation: Int(persisted_profile.damus_donation)) + } + private func str(_ str: String) -> String? { return get_val(str) } diff --git a/damus/Nostr/ProfileDatabase.swift b/damus/Nostr/ProfileDatabase.swift @@ -0,0 +1,181 @@ +// +// 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? + private let cache_url: URL + + /// This queue is used to synchronize access to the network_pull_date_cache dictionary, which + /// prevents data races from crashing the app. + private var queue = DispatchQueue(label: "io.damus.profile_db", + qos: .userInteractive, + attributes: .concurrent) + private var network_pull_date_cache = [String: Date]() + + init(cache_url: URL = ProfileDatabase.profile_cache_url) { + self.cache_url = cache_url + set_up() + } + + private static 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: 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, context: NSManagedObjectContext) -> PersistedProfile? { + let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + + func get_network_pull_date(id: String) -> Date? { + var pull_date: Date? + queue.sync { + pull_date = network_pull_date_cache[id] + } + if let pull_date { + return pull_date + } + + let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) + request.predicate = NSPredicate(format: "id == %@", id) + request.fetchLimit = 1 + request.propertiesToFetch = ["network_pull_date"] + guard let profile = try? persistent_container?.viewContext.fetch(request).first else { + return nil + } + + queue.async(flags: .barrier) { + self.network_pull_date_cache[id] = profile.network_pull_date + } + return profile.network_pull_date + } + + // 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) async throws { + guard let context = background_context else { + throw ProfileDatabaseError.missing_context + } + + try await context.perform { + var persisted_profile: PersistedProfile? + if let profile = self.get_persisted(id: id, context: context) { + 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: self.entity_name, into: context) as? PersistedProfile + persisted_profile?.id = id + } + persisted_profile?.copyValues(from: profile) + persisted_profile?.last_update = last_update + + let pull_date = Date.now + persisted_profile?.network_pull_date = pull_date + self.queue.async(flags: .barrier) { + self.network_pull_date_cache[id] = pull_date + } + + try context.save() + } + } + + func get(id: String) -> Profile? { + guard let container = persistent_container, + let profile = get_persisted(id: id, context: container.viewContext) else { + return nil + } + return Profile(persisted_profile: profile) + } + + var count: Int { + let request = NSFetchRequest<PersistedProfile>(entityName: entity_name) + let count = try? persistent_container?.viewContext.count(for: request) + return count ?? 0 + } + + func remove_all_profiles() throws { + guard let context = background_context, let container = persistent_container else { + throw ProfileDatabaseError.missing_context + } + + queue.async(flags: .barrier) { + self.network_pull_date_cache.removeAll() + } + + let request = NSFetchRequest<NSFetchRequestResult>(entityName: entity_name) + let batch_delete_request = NSBatchDeleteRequest(fetchRequest: request) + batch_delete_request.resultType = .resultTypeObjectIDs + + let result = try container.persistentStoreCoordinator.execute(batch_delete_request, with: context) as! NSBatchDeleteResult + + // NSBatchDeleteRequest is an NSPersistentStoreRequest, which operates on disk. So now we'll manually update our in-memory context. + if let object_ids = result.result as? [NSManagedObjectID] { + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: object_ids + ] + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + } + } +} diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift @@ -6,11 +6,11 @@ // import Foundation -import UIKit - class Profiles { + static let db_freshness_threshold: TimeInterval = 24 * 60 * 60 + /// This queue is used to synchronize access to the profiles dictionary, which /// prevents data races from crashing the app. private var queue = DispatchQueue(label: "io.damus.profiles", @@ -22,8 +22,10 @@ class Profiles { var nip05_pubkey: [String: String] = [:] var zappers: [String: String] = [:] + private let database = ProfileDatabase() + func is_validated(_ pk: String) -> NIP05? { - return validated[pk] + validated[pk] } func enumerated() -> EnumeratedSequence<[String: TimestampedProfile]> { @@ -33,23 +35,29 @@ class Profiles { } func lookup_zapper(pubkey: String) -> String? { - if let zapper = zappers[pubkey] { - return zapper - } - - return nil + zappers[pubkey] } func add(id: String, profile: TimestampedProfile) { queue.async(flags: .barrier) { self.profiles[id] = profile } + + Task { + do { + try await database.upsert(id: id, profile: profile.profile, last_update: Date(timeIntervalSince1970: TimeInterval(profile.timestamp))) + } catch { + print("⚠️ Warning: Profiles failed to save a profile: \(error)") + } + } } func lookup(id: String) -> Profile? { + var profile: Profile? queue.sync { - return profiles[id]?.profile + profile = profiles[id]?.profile } + return profile ?? database.get(id: id) } func lookup_with_timestamp(id: String) -> TimestampedProfile? { @@ -57,6 +65,23 @@ class Profiles { return profiles[id] } } + + func has_fresh_profile(id: String) -> Bool { + // check memory first + var profile: Profile? + queue.sync { + profile = profiles[id]?.profile + } + if profile != nil { + return true + } + + // then disk + guard let pull_date = database.get_network_pull_date(id: id) else { + return false + } + return Date.now.timeIntervalSince(pull_date) < Profiles.db_freshness_threshold + } } diff --git a/damusTests/ProfileDatabaseTests.swift b/damusTests/ProfileDatabaseTests.swift @@ -0,0 +1,124 @@ +// +// ProfileDatabaseTests.swift +// damusTests +// +// Created by Bryan Montz on 5/13/23. +// + +import XCTest +@testable import damus + +class ProfileDatabaseTests: XCTestCase { + + static let cache_url = (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("test-profiles"))! + let database = ProfileDatabase(cache_url: ProfileDatabaseTests.cache_url) + + override func tearDownWithError() throws { + // This method is called after the invocation of each test method in the class. + try database.remove_all_profiles() + } + + var test_profile: Profile { + Profile(name: "test-name", + display_name: "test-display-name", + about: "test-about", + picture: "test-picture", + banner: "test-banner", + website: "test-website", + lud06: "test-lud06", + lud16: "test-lud16", + nip05: "test-nip05", + damus_donation: 100) + } + + func testStoreAndRetrieveProfile() async throws { + let id = "test-id" + + let profile = test_profile + + // make sure it's not there yet + XCTAssertNil(database.get(id: id)) + + // store the profile + try await database.upsert(id: id, profile: profile, last_update: .now) + + // read the profile out of the database + let retrievedProfile = try XCTUnwrap(database.get(id: id)) + + XCTAssertEqual(profile.name, retrievedProfile.name) + XCTAssertEqual(profile.display_name, retrievedProfile.display_name) + XCTAssertEqual(profile.about, retrievedProfile.about) + XCTAssertEqual(profile.picture, retrievedProfile.picture) + XCTAssertEqual(profile.banner, retrievedProfile.banner) + XCTAssertEqual(profile.website, retrievedProfile.website) + XCTAssertEqual(profile.lud06, retrievedProfile.lud06) + XCTAssertEqual(profile.lud16, retrievedProfile.lud16) + XCTAssertEqual(profile.nip05, retrievedProfile.nip05) + XCTAssertEqual(profile.damus_donation, retrievedProfile.damus_donation) + } + + func testRejectOutdatedProfile() async throws { + let id = "test-id" + + // store a profile + let profile = test_profile + let profile_last_updated = Date.now + try await database.upsert(id: id, profile: profile, last_update: profile_last_updated) + + // try to store a profile with the same id but the last_update date is older than the previously stored profile + let outdatedProfile = test_profile + let outdated_last_updated = profile_last_updated.addingTimeInterval(-60) + + do { + try await database.upsert(id: id, profile: outdatedProfile, last_update: outdated_last_updated) + XCTFail("expected to throw error") + } catch let error as ProfileDatabaseError { + XCTAssertEqual(error, ProfileDatabaseError.outdated_input) + } catch { + XCTFail("not the expected error") + } + } + + func testUpdateExistingProfile() async throws { + let id = "test-id" + + // store a profile + let profile = test_profile + let profile_last_update = Date.now + try await database.upsert(id: id, profile: profile, last_update: profile_last_update) + + // update the same profile + let updated_profile = test_profile + updated_profile.nip05 = "updated-nip05" + let updated_profile_last_update = profile_last_update.addingTimeInterval(60) + try await database.upsert(id: id, profile: updated_profile, last_update: updated_profile_last_update) + + // retrieve the profile and make sure it was updated + let retrieved_profile = database.get(id: id) + XCTAssertEqual(retrieved_profile?.nip05, "updated-nip05") + } + + func testStoreMultipleAndRemoveAllProfiles() async throws { + XCTAssertEqual(database.count, 0) + + // store a profile + let id = "test-id" + let profile = test_profile + let profile_last_update = Date.now + try await database.upsert(id: id, profile: profile, last_update: profile_last_update) + + XCTAssertEqual(database.count, 1) + + // store another profile + let id2 = "test-id-2" + let profile2 = test_profile + let profile_last_update2 = Date.now + try await database.upsert(id: id2, profile: profile2, last_update: profile_last_update2) + + XCTAssertEqual(database.count, 2) + + try database.remove_all_profiles() + + XCTAssertEqual(database.count, 0) + } +} diff --git a/damusTests/ZapTests.swift b/damusTests/ZapTests.swift @@ -11,7 +11,8 @@ import XCTest final class ZapTests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + let db = ProfileDatabase() + try db.remove_all_profiles() } override func tearDownWithError() throws {