Profile Caching
Changelog-Added: Add profile caching
This commit is contained in:
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
39
damus/Nostr/CoreData/PersistedProfile.swift
Normal file
39
damus/Nostr/CoreData/PersistedProfile.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
181
damus/Nostr/ProfileDatabase.swift
Normal file
181
damus/Nostr/ProfileDatabase.swift
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
124
damusTests/ProfileDatabaseTests.swift
Normal file
124
damusTests/ProfileDatabaseTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user