diff --git a/DamusNotificationService/NotificationFormatter.swift b/DamusNotificationService/NotificationFormatter.swift index bfaebc67..d8354520 100644 --- a/DamusNotificationService/NotificationFormatter.swift +++ b/DamusNotificationService/NotificationFormatter.swift @@ -125,7 +125,7 @@ struct NotificationFormatter { let src = zap.request.ev let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey - let profile = profiles.lookup(id: pk) + let profile = try? profiles.lookup(id: pk) let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50) let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0)) diff --git a/DamusNotificationService/NotificationService.swift b/DamusNotificationService/NotificationService.swift index fbdd0eb8..ca286a49 100644 --- a/DamusNotificationService/NotificationService.swift +++ b/DamusNotificationService/NotificationService.swift @@ -58,7 +58,7 @@ class NotificationService: UNNotificationServiceExtension { } let sender_profile = { - let profile = state.profiles.lookup(id: nostr_event.pubkey) + let profile = try? state.profiles.lookup(id: nostr_event.pubkey) let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))! return ProfileBuf(picture: picture, name: profile?.name, @@ -185,7 +185,7 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str // gather recipients if let recipient_note_id = note.direct_replies() { - let replying_to_pk = ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in + let replying_to_pk = try? ndb.lookup_note(recipient_note_id, borrow: { replying_to_note -> Pubkey? in switch replying_to_note { case .none: return nil case .some(let note): return note.pubkey @@ -251,7 +251,7 @@ func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: Str } func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson { - let profile = ndb.lookup_profile(pubkey, borrow: { profileRecord in + let profile = try? ndb.lookup_profile(pubkey, borrow: { profileRecord in switch profileRecord { case .some(let pr): return pr.profile case .none: return nil diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index b82a7582..459d08c7 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1670,6 +1670,10 @@ D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */; }; + D75154BF2EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; + D75154C02EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; + D75154C12EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; + D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; }; D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; @@ -1711,6 +1715,7 @@ D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D795356B2EBD28A800AACF98 /* AppLifecycleHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */; }; D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; }; D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; @@ -2770,6 +2775,7 @@ D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = ""; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = ""; }; D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesManagerTests.swift; sourceTree = ""; }; + D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbUseLock.swift; sourceTree = ""; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = ""; }; D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = ""; }; D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = ""; }; @@ -2790,6 +2796,7 @@ D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = ""; }; + D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleHandlingTests.swift; sourceTree = ""; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = ""; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = ""; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = ""; }; @@ -3301,6 +3308,7 @@ 4C9054862A6AEB4500811EEC /* nostrdb */ = { isa = PBXGroup; children = ( + D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */, D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */, D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */, D7F5630F2DEE71BB008509DE /* NdbFilter.swift */, @@ -5281,6 +5289,7 @@ isa = PBXGroup; children = ( D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */, + D795356A2EBD289D00AACF98 /* AppLifecycleHandlingTests.swift */, D7EBF8BD2E594708004EAE29 /* test_notes.jsonl */, D7EBF8BA2E5901F7004EAE29 /* NostrNetworkManagerTests.swift */, D7EBF8BF2E5D39D1004EAE29 /* ThreadModelTests.swift */, @@ -5743,6 +5752,7 @@ 4CC6AA792CAB688500989CEF /* sha256.c in Sources */, 4CC6AA7B2CAB688500989CEF /* likely.c in Sources */, 4CC6AA7F2CAB688500989CEF /* htable.c in Sources */, + D75154C02EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */, 4CC6AA862CAB688500989CEF /* list.c in Sources */, 4CC6AA872CAB688500989CEF /* utf8.c in Sources */, 4CC6AA892CAB688500989CEF /* debug.c in Sources */, @@ -6266,6 +6276,7 @@ D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */, B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */, 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */, + D795356B2EBD28A800AACF98 /* AppLifecycleHandlingTests.swift in Sources */, D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */, B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */, D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */, @@ -6785,6 +6796,7 @@ 82D6FC3A2CD99F7900C925F4 /* WideEventView.swift in Sources */, 82D6FC3B2CD99F7900C925F4 /* LongformView.swift in Sources */, 82D6FC3C2CD99F7900C925F4 /* LongformPreview.swift in Sources */, + D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */, 82D6FC3D2CD99F7900C925F4 /* EventShell.swift in Sources */, 82D6FC3E2CD99F7900C925F4 /* MentionView.swift in Sources */, 82D6FC3F2CD99F7900C925F4 /* EventLoaderView.swift in Sources */, @@ -7210,6 +7222,7 @@ D73E5F302C6A97F4007EB227 /* EventProfile.swift in Sources */, D73E5F312C6A97F4007EB227 /* EventMenu.swift in Sources */, D73E5F322C6A97F4007EB227 /* EventMutingContainerView.swift in Sources */, + D75154C12EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */, D73E5F332C6A97F4007EB227 /* ZapEvent.swift in Sources */, 5C8F97362EB46145009399B1 /* LiveStreamView.swift in Sources */, D73E5F342C6A97F4007EB227 /* TextEvent.swift in Sources */, @@ -7474,6 +7487,7 @@ 4CC6AAC52CAB688500989CEF /* likely.c in Sources */, 4CC6AAC92CAB688500989CEF /* htable.c in Sources */, 4CC6AAD02CAB688500989CEF /* list.c in Sources */, + D75154BF2EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */, 4CC6AAD12CAB688500989CEF /* utf8.c in Sources */, 4CC6AAD32CAB688500989CEF /* debug.c in Sources */, 4CC6AAD42CAB688500989CEF /* str.c in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index b8516338..7fa06231 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -397,7 +397,7 @@ struct ContentView: View { guard let ds = self.damus_state, let lud16 = nwc.lud16, let keypair = ds.keypair.to_full(), - let profile = ds.profiles.lookup(id: ds.pubkey), + let profile = try? ds.profiles.lookup(id: ds.pubkey), lud16 != profile.lud16 else { return } @@ -560,7 +560,7 @@ struct ContentView: View { home.filter_events() guard let ds = damus_state, - let profile = ds.profiles.lookup(id: ds.pubkey), + let profile = try? ds.profiles.lookup(id: ds.pubkey), let keypair = ds.keypair.to_full() else { return @@ -578,7 +578,7 @@ struct ContentView: View { } }, message: { if case let .user(pubkey, _) = self.muting { - let profile = damus_state!.profiles.lookup(id: pubkey) + let profile = try? damus_state!.profiles.lookup(id: pubkey) let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50) Text("\(name) has been muted", comment: "Alert message that informs a user was muted.") } else { @@ -644,7 +644,7 @@ struct ContentView: View { } }, message: { if case let .user(pubkey, _) = muting { - let profile = damus_state?.profiles.lookup(id: pubkey) + let profile = try? damus_state?.profiles.lookup(id: pubkey) let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50) Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.") } else { diff --git a/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift b/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift index 86527914..41e73775 100644 --- a/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/ProfilesManager.swift @@ -92,11 +92,11 @@ extension NostrNetworkManager { private func publishProfileUpdates(metadataEvent: borrowing UnownedNdbNote) { let now = UInt64(Date.now.timeIntervalSince1970) - ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now) + try? ndb.write_profile_last_fetched(pubkey: metadataEvent.pubkey, fetched_at: now) if let relevantStreams = streams[metadataEvent.pubkey] { // If we have the user metadata event in ndb, then we should have the profile record as well. - guard let profile = ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return } + guard let profile = try? ndb.lookup_profile_and_copy(metadataEvent.pubkey) else { return } for relevantStream in relevantStreams.values { relevantStream.continuation.yield(profile) } @@ -107,7 +107,7 @@ extension NostrNetworkManager { /// This is useful for local profile changes (e.g., nip05 validation, donation percentage updates) func notifyProfileUpdate(pubkey: Pubkey) { if let relevantStreams = streams[pubkey] { - guard let profile = ndb.lookup_profile_and_copy(pubkey) else { return } + guard let profile = try? ndb.lookup_profile_and_copy(pubkey) else { return } for relevantStream in relevantStreams.values { relevantStream.continuation.yield(profile) } @@ -141,7 +141,7 @@ extension NostrNetworkManager { // Yield cached profile immediately so views don't flash placeholder content. // Callers that only need updates (not initial state) can opt out via yieldCached: false. - if yieldCached, let existingProfile = ndb.lookup_profile_and_copy(pubkey) { + if yieldCached, let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) { continuation.yield(existingProfile) } @@ -176,7 +176,7 @@ extension NostrNetworkManager { // Callers that only need updates (not initial state) can opt out via yieldCached: false. if yieldCached { for pubkey in pubkeys { - if let existingProfile = ndb.lookup_profile_and_copy(pubkey) { + if let existingProfile = try? ndb.lookup_profile_and_copy(pubkey) { continuation.yield(existingProfile) } } diff --git a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift index 6a200b80..63f5b804 100644 --- a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift @@ -360,7 +360,7 @@ extension NostrNetworkManager { let filter = NostrFilter(ids: [noteId], limit: 1) // Since note ids point to immutable objects, we can do a simple ndb lookup first - if let noteKey = self.ndb.lookup_note_key(noteId) { + if let noteKey = try? self.ndb.lookup_note_key(noteId) { return NdbNoteLender(ndb: self.ndb, noteKey: noteKey) } @@ -413,18 +413,18 @@ extension NostrNetworkManager { switch query { case .profile(let pubkey): - let profileNotNil = self.ndb.lookup_profile(pubkey, borrow: { pr in + let profileNotNil = try? self.ndb.lookup_profile(pubkey, borrow: { pr in switch pr { case .some(let pr): return pr.profile != nil case .none: return true } }) - if profileNotNil { + if profileNotNil ?? false { return .profile(pubkey) } filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey]) case .event(let evid): - if let event = self.ndb.lookup_note_and_copy(evid) { + if let event = try? self.ndb.lookup_note_and_copy(evid) { return .event(event) } filter = NostrFilter(ids: [evid], limit: 1) diff --git a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift index 05dd60e7..033c975e 100644 --- a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift @@ -87,7 +87,7 @@ extension NostrNetworkManager { private func getLatestNIP65RelayListEvent() -> NdbNote? { guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil } guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil } - return delegate.ndb.lookup_note_and_copy(latestRelayListEventId) + return try? delegate.ndb.lookup_note_and_copy(latestRelayListEventId) } /// Gets the latest `kind:3` relay list from NostrDB. diff --git a/damus/Core/Nostr/Profiles.swift b/damus/Core/Nostr/Profiles.swift index 484ebae6..016349a3 100644 --- a/damus/Core/Nostr/Profiles.swift +++ b/damus/Core/Nostr/Profiles.swift @@ -74,12 +74,12 @@ class Profiles { profile_data(pubkey).zapper } - func lookup_with_timestamp(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T { + func lookup_with_timestamp(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T { return try ndb.lookup_profile(pubkey, borrow: lendingFunction) } - func lookup_lnurl(_ pubkey: Pubkey) -> String? { - return lookup_with_timestamp(pubkey, borrow: { pr in + func lookup_lnurl(_ pubkey: Pubkey) throws -> String? { + return try lookup_with_timestamp(pubkey, borrow: { pr in switch pr { case .some(let pr): return pr.lnurl case .none: return nil @@ -87,16 +87,16 @@ class Profiles { }) } - func lookup_by_key(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T { + func lookup_by_key(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T { return try ndb.lookup_profile_by_key(key: key, borrow: lendingFunction) } - func search(_ query: String, limit: Int) -> [Pubkey] { - ndb.search_profile(query, limit: limit) + func search(_ query: String, limit: Int) throws -> [Pubkey] { + try ndb.search_profile(query, limit: limit) } - func lookup(id: Pubkey) -> Profile? { - return ndb.lookup_profile(id, borrow: { pr in + func lookup(id: Pubkey) throws -> Profile? { + return try ndb.lookup_profile(id, borrow: { pr in switch pr { case .none: return nil @@ -107,12 +107,12 @@ class Profiles { }) } - func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? { - ndb.lookup_profile_key(pubkey) + func lookup_key_by_pubkey(_ pubkey: Pubkey) throws -> ProfileKey? { + try ndb.lookup_profile_key(pubkey) } - func has_fresh_profile(id: Pubkey) -> Bool { - guard let fetched_at = ndb.read_profile_last_fetched(pubkey: id) + func has_fresh_profile(id: Pubkey) throws -> Bool { + guard let fetched_at = try ndb.read_profile_last_fetched(pubkey: id) else { return false } diff --git a/damus/Features/Actions/ActionBar/Views/EventActionBar.swift b/damus/Features/Actions/ActionBar/Views/EventActionBar.swift index 10c93037..94b667d5 100644 --- a/damus/Features/Actions/ActionBar/Views/EventActionBar.swift +++ b/damus/Features/Actions/ActionBar/Views/EventActionBar.swift @@ -41,7 +41,7 @@ struct EventActionBar: View { // Fetching an LNURL is expensive enough that it can cause a hitch. Use a special backgroundable function to fetch the value. // Fetch on `.onAppear` nonisolated func fetchLNURL() { - let lnurl = damus_state.profiles.lookup_lnurl(event.pubkey) + let lnurl = try? damus_state.profiles.lookup_lnurl(event.pubkey) DispatchQueue.main.async { self.lnurl = lnurl } diff --git a/damus/Features/Chat/ChatEventView.swift b/damus/Features/Chat/ChatEventView.swift index 1c9f8208..bf86a00a 100644 --- a/damus/Features/Chat/ChatEventView.swift +++ b/damus/Features/Chat/ChatEventView.swift @@ -115,7 +115,7 @@ struct ChatEventView: View { // MARK: Zapping properties var lnurl: String? { - damus_state.profiles.lookup_lnurl(event.pubkey) + try? damus_state.profiles.lookup_lnurl(event.pubkey) } var zap_target: ZapTarget { ZapTarget.note(id: event.id, author: event.pubkey) diff --git a/damus/Features/Events/Components/ReplyDescription.swift b/damus/Features/Events/Components/ReplyDescription.swift index 70ec3f06..1a5c6024 100644 --- a/damus/Features/Events/Components/ReplyDescription.swift +++ b/damus/Features/Events/Components/ReplyDescription.swift @@ -39,7 +39,7 @@ func reply_desc(ndb: Ndb, event: NostrEvent, replying_to: NostrEvent?, locale: L } let names: [String] = pubkeys.map { pk in - let profile = ndb.lookup_profile_and_copy(pk) + let profile = try? ndb.lookup_profile_and_copy(pk) return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50) } diff --git a/damus/Features/Events/Models/NoteContent.swift b/damus/Features/Events/Models/NoteContent.swift index e2892090..c766ab64 100644 --- a/damus/Features/Events/Models/NoteContent.swift +++ b/damus/Features/Events/Models/NoteContent.swift @@ -328,7 +328,7 @@ func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) } func getDisplayName(pk: Pubkey, profiles: Profiles) -> String { - let profile = profiles.lookup(id: pk) + let profile = try? profiles.lookup(id: pk) return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50) } diff --git a/damus/Features/FollowPack/Views/FollowPackPreview.swift b/damus/Features/FollowPack/Views/FollowPackPreview.swift index a16ecec6..9a3da4aa 100644 --- a/damus/Features/FollowPack/Views/FollowPackPreview.swift +++ b/damus/Features/FollowPack/Views/FollowPackPreview.swift @@ -157,7 +157,7 @@ struct FollowPackPreviewBody: View { .onTapGesture { state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey)) } - let profile = state.profiles.lookup(id: event.event.pubkey) + let profile = try? state.profiles.lookup(id: event.event.pubkey) let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey) switch displayName { case .one(let one): diff --git a/damus/Features/FollowPack/Views/FollowPackView.swift b/damus/Features/FollowPack/Views/FollowPackView.swift index bdba1ddb..8614364b 100644 --- a/damus/Features/FollowPack/Views/FollowPackView.swift +++ b/damus/Features/FollowPack/Views/FollowPackView.swift @@ -135,7 +135,7 @@ struct FollowPackView: View { .onTapGesture { state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey)) } - let profile = state.profiles.lookup(id: event.event.pubkey) + let profile = try? state.profiles.lookup(id: event.event.pubkey) let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey) switch displayName { case .one(let one): diff --git a/damus/Features/Follows/Models/FollowingModel.swift b/damus/Features/Follows/Models/FollowingModel.swift index 342d69d6..fe13e47c 100644 --- a/damus/Features/Follows/Models/FollowingModel.swift +++ b/damus/Features/Follows/Models/FollowingModel.swift @@ -26,7 +26,7 @@ class FollowingModel { var f = NostrFilter(kinds: [.metadata]) f.authors = self.contacts.reduce(into: Array()) { acc, pk in // don't fetch profiles we already have - if damus_state.profiles.has_fresh_profile(id: pk) { + if (try? damus_state.profiles.has_fresh_profile(id: pk)) ?? false { return } acc.append(pk) diff --git a/damus/Features/Highlight/Models/HighlightEvent.swift b/damus/Features/Highlight/Models/HighlightEvent.swift index 420ceef0..eb3a2aa1 100644 --- a/damus/Features/Highlight/Models/HighlightEvent.swift +++ b/damus/Features/Highlight/Models/HighlightEvent.swift @@ -101,7 +101,7 @@ struct HighlightEvent { } let names: [String] = pubkeys.map { pk in - let profile = ndb.lookup_profile_and_copy(pk) + let profile = try? ndb.lookup_profile_and_copy(pk) return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50) } diff --git a/damus/Features/Highlight/Views/HighlightEventRef.swift b/damus/Features/Highlight/Views/HighlightEventRef.swift index ff977ee7..af069c1f 100644 --- a/damus/Features/Highlight/Views/HighlightEventRef.swift +++ b/damus/Features/Highlight/Views/HighlightEventRef.swift @@ -63,7 +63,7 @@ struct HighlightEventRef: View { .font(.system(size: 14, weight: .bold)) .lineLimit(1) - let profile = damus_state.profiles.lookup(id: longform_event.event.pubkey) + let profile = try? damus_state.profiles.lookup(id: longform_event.event.pubkey) if let display_name = profile?.display_name { Text(display_name) diff --git a/damus/Features/Live/LiveStream/Views/Components/LiveStreamProfile.swift b/damus/Features/Live/LiveStream/Views/Components/LiveStreamProfile.swift index cd034162..943d1741 100644 --- a/damus/Features/Live/LiveStream/Views/Components/LiveStreamProfile.swift +++ b/damus/Features/Live/LiveStream/Views/Components/LiveStreamProfile.swift @@ -18,7 +18,7 @@ struct LiveStreamProfile: View { .onTapGesture { state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) } - let profile = state.profiles.lookup(id: pubkey) + let profile = try? state.profiles.lookup(id: pubkey) let displayName = Profile.displayName(profile: profile, pubkey: pubkey) switch displayName { case .one(let one): diff --git a/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift b/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift index d9f5b8c4..6b0bf2f3 100644 --- a/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift +++ b/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift @@ -47,7 +47,7 @@ class NIP05DomainEventsModel: ObservableObject { var authors = Set() for pubkey in state.contacts.get_friend_of_friends_list() { - guard let profile = state.profiles.lookup(id: pubkey), + guard let profile = try? state.profiles.lookup(id: pubkey), let nip05_str = profile.nip05, let nip05 = NIP05.parse(nip05_str), nip05.host.caseInsensitiveCompare(domain) == .orderedSame else { diff --git a/damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift b/damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift index 2665ba58..80381f32 100644 --- a/damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift +++ b/damus/Features/NIP05/Views/NIP05DomainTimelineHeaderView.swift @@ -82,7 +82,7 @@ struct NIP05DomainTimelineHeaderView: View { func friendsOfFriendsString(_ friendsOfFriends: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String { let bundle = bundleForLocale(locale: locale) let names: [String] = friendsOfFriends.prefix(3).map { pk in - let profile = ndb.lookup_profile(pk, borrow: { pr in + let profile = try? ndb.lookup_profile(pk, borrow: { pr in switch pr { case .some(let pr): return pr.profile case .none: return nil diff --git a/damus/Features/Notifications/Models/NotificationsManager.swift b/damus/Features/Notifications/Models/NotificationsManager.swift index 43a7a6d3..72f1d369 100644 --- a/damus/Features/Notifications/Models/NotificationsManager.swift +++ b/damus/Features/Notifications/Models/NotificationsManager.swift @@ -83,8 +83,8 @@ func generate_text_mention_notification(ndb: Ndb, from ev: NostrEvent, state: He return notification } - if ev.referenced_ids.contains(where: { note_id in - guard let note_author: Pubkey = state.ndb.lookup_note(note_id, borrow: { note in + if let contains = try? ev.referenced_ids.contains(where: { note_id in + guard let note_author: Pubkey = try state.ndb.lookup_note(note_id, borrow: { note in switch note { case .some(let note): return note.pubkey case .none: return nil @@ -92,7 +92,7 @@ func generate_text_mention_notification(ndb: Ndb, from ev: NostrEvent, state: He }) else { return false } guard note_author == state.keypair.pubkey else { return false } return true - }) { + }), contains { // This is a reply to one of our posts let content_preview = render_notification_content_preview(ndb: state.ndb, ev: ev, profiles: state.profiles, keypair: state.keypair) return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview) @@ -126,7 +126,7 @@ func generate_local_notification_object(ndb: Ndb, from ev: NostrEvent, state: He let content_preview = render_notification_content_preview(ndb: ndb, ev: inner_ev, profiles: state.profiles, keypair: state.keypair) return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview) } else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last { - return state.ndb.lookup_note(evid, borrow: { liked_event in + return try? state.ndb.lookup_note(evid, borrow: { liked_event in switch liked_event { case .none: return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "") @@ -191,7 +191,7 @@ func render_notification_content_preview(ndb: Ndb, ev: NostrEvent, profiles: Pro } func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String { - let profile = profiles.lookup(id: pubkey) + let profile = try? profiles.lookup(id: pubkey) return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50) } @@ -228,7 +228,7 @@ func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @e return } - guard let lnurl = state.profiles.lookup_with_timestamp(ptag, borrow: { record -> String? in + guard let lnurl = try? state.profiles.lookup_with_timestamp(ptag, borrow: { record -> String? in switch record { case .none: return nil case .some(let record): return record.lnurl @@ -280,7 +280,7 @@ func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? { } // we can't trust the p tag on note zaps because they can be faked - return ndb.lookup_note(etag, borrow: { note in + return try? ndb.lookup_note(etag, borrow: { note in switch note { case .none: // We don't have the event in cache so we can't check the pubkey. diff --git a/damus/Features/Onboarding/SuggestedUsersViewModel.swift b/damus/Features/Onboarding/SuggestedUsersViewModel.swift index 3eac32d1..3627f446 100644 --- a/damus/Features/Onboarding/SuggestedUsersViewModel.swift +++ b/damus/Features/Onboarding/SuggestedUsersViewModel.swift @@ -79,7 +79,7 @@ class SuggestedUsersViewModel: ObservableObject { /// Gets suggested user information from a provided pubkey func suggestedUser(pubkey: Pubkey) -> SuggestedUser? { - if let profile = damus_state.profiles.lookup(id: pubkey), + if let profile = try? damus_state.profiles.lookup(id: pubkey), let user = SuggestedUser(name: profile.name, about: profile.about, picture: profile.picture, pubkey: pubkey) { return user } diff --git a/damus/Features/Posting/Models/DraftsModel.swift b/damus/Features/Posting/Models/DraftsModel.swift index dc344bf8..3e10932c 100644 --- a/damus/Features/Posting/Models/DraftsModel.swift +++ b/damus/Features/Posting/Models/DraftsModel.swift @@ -116,7 +116,7 @@ class DraftArtifacts: Equatable { case .mention(let mention): if let pubkey = mention.ref.nip19.pubkey() { // A profile reference, format things properly. - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) let profile_name = DisplayName(profile: profile, pubkey: pubkey).username guard let url_address = URL(string: block.asString) else { rich_text_content.append(.init(string: block.asString)) @@ -173,7 +173,7 @@ class Drafts: ObservableObject { func load(from damus_state: DamusState) { guard let note_ids = damus_state.settings.draft_event_ids?.compactMap({ NoteId(hex: $0) }) else { return } for note_id in note_ids { - let note = damus_state.ndb.lookup_note(note_id, borrow: { event in + let note = try? damus_state.ndb.lookup_note(note_id, borrow: { event in return event?.toOwned() }) guard let note else { continue } @@ -234,13 +234,13 @@ class Drafts: ObservableObject { draft_events.append(wrapped_note) } for (replied_to_note_id, reply_artifacts) in self.replies { - guard let replied_to_note = damus_state.ndb.lookup_note_and_copy(replied_to_note_id) else { continue } + guard let replied_to_note = try? damus_state.ndb.lookup_note_and_copy(replied_to_note_id) else { continue } let nip37_draft = try? await reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { continue } draft_events.append(wrapped_note) } for (quoted_note_id, quote_note_artifacts) in self.quotes { - guard let quoted_note = damus_state.ndb.lookup_note_and_copy(quoted_note_id) else { continue } + guard let quoted_note = try? damus_state.ndb.lookup_note_and_copy(quoted_note_id) else { continue } let nip37_draft = try? await quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { continue } draft_events.append(wrapped_note) diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 70789fde..42357bdd 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -212,7 +212,7 @@ struct PostView: View { return .init(string: "") } - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) return user_tag_attr_string(profile: profile, pubkey: pubkey) } diff --git a/damus/Features/Posting/Views/ReplyView.swift b/damus/Features/Posting/Views/ReplyView.swift index 5d9ea6c6..42b7ef6b 100644 --- a/damus/Features/Posting/Views/ReplyView.swift +++ b/damus/Features/Posting/Views/ReplyView.swift @@ -27,7 +27,7 @@ struct ReplyView: View { let names = references .map { pubkey in let pk = pubkey - let prof = damus.profiles.lookup(id: pk) + let prof = try? damus.profiles.lookup(id: pk) return "@" + Profile.displayName(profile: prof, pubkey: pk).username.truncate(maxLength: 50) } .joined(separator: " ") diff --git a/damus/Features/Posting/Views/UserSearch.swift b/damus/Features/Posting/Views/UserSearch.swift index e7a04919..bb129b94 100644 --- a/damus/Features/Posting/Views/UserSearch.swift +++ b/damus/Features/Posting/Views/UserSearch.swift @@ -21,7 +21,7 @@ struct UserSearch: View { } func on_user_tapped(pk: Pubkey) { - let profile = damus_state.profiles.lookup(id: pk) + let profile = try? damus_state.profiles.lookup(id: pk) let user_tag = user_tag_attr_string(profile: profile, pubkey: pk) appendUserTag(withTag: user_tag) diff --git a/damus/Features/Profile/Views/EditMetadataView.swift b/damus/Features/Profile/Views/EditMetadataView.swift index 6f19e8e4..7da0111a 100644 --- a/damus/Features/Profile/Views/EditMetadataView.swift +++ b/damus/Features/Profile/Views/EditMetadataView.swift @@ -33,7 +33,7 @@ struct EditMetadataView: View { init(damus_state: DamusState) { self.damus_state = damus_state - let data = damus_state.profiles.lookup(id: damus_state.pubkey) + let data = try? damus_state.profiles.lookup(id: damus_state.pubkey) _name = State(initialValue: data?.name ?? "") _display_name = State(initialValue: data?.display_name ?? "") @@ -258,7 +258,7 @@ struct EditMetadataView: View { } func didChange() -> Bool { - let data = damus_state.profiles.lookup(id: damus_state.pubkey) + let data = try? damus_state.profiles.lookup(id: damus_state.pubkey) if data?.name ?? "" != name { return true diff --git a/damus/Features/Profile/Views/EventProfileName.swift b/damus/Features/Profile/Views/EventProfileName.swift index 645ad0c9..3df022cb 100644 --- a/damus/Features/Profile/Views/EventProfileName.swift +++ b/damus/Features/Profile/Views/EventProfileName.swift @@ -24,7 +24,7 @@ struct EventProfileName: View { self.damus_state = damus self.pubkey = pubkey self.size = size - let donation = damus.profiles.lookup(id: pubkey)?.damus_donation + let donation = try? damus.profiles.lookup(id: pubkey)?.damus_donation self._donation = State(wrappedValue: donation) self.purple_account = nil } @@ -59,7 +59,7 @@ struct EventProfileName: View { } var body: some View { - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) HStack(spacing: 2) { switch current_display_name(profile) { case .one(let one): diff --git a/damus/Features/Profile/Views/ProfileActionSheetView.swift b/damus/Features/Profile/Views/ProfileActionSheetView.swift index 48b9c452..24becd6f 100644 --- a/damus/Features/Profile/Views/ProfileActionSheetView.swift +++ b/damus/Features/Profile/Views/ProfileActionSheetView.swift @@ -33,16 +33,16 @@ struct ProfileActionSheetView: View { colorScheme == .light ? DamusColors.white : DamusColors.black } - func profile_data(borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T { + func profile_data(borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T { return try damus_state.profiles.lookup_with_timestamp(profile.pubkey, borrow: lendingFunction) } func get_profile() -> Profile? { - return damus_state.profiles.lookup(id: profile.pubkey) + return try? damus_state.profiles.lookup(id: profile.pubkey) } func get_lnurl() -> String? { - return damus_state.profiles.lookup_lnurl(profile.pubkey) + return try? damus_state.profiles.lookup_lnurl(profile.pubkey) } func navigate(route: Route) { diff --git a/damus/Features/Profile/Views/ProfileName.swift b/damus/Features/Profile/Views/ProfileName.swift index 788f307f..a83f8003 100644 --- a/damus/Features/Profile/Views/ProfileName.swift +++ b/damus/Features/Profile/Views/ProfileName.swift @@ -94,7 +94,7 @@ struct ProfileName: View { } var body: some View { - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) HStack(spacing: 2) { Text(verbatim: "\(prefix)\(name_choice(profile: profile))") diff --git a/damus/Features/Profile/Views/ProfileNameView.swift b/damus/Features/Profile/Views/ProfileNameView.swift index 61b6721b..2305939e 100644 --- a/damus/Features/Profile/Views/ProfileNameView.swift +++ b/damus/Features/Profile/Views/ProfileNameView.swift @@ -16,7 +16,7 @@ struct ProfileNameView: View { var body: some View { Group { VStack(alignment: .leading) { - let profile = self.damus.profiles.lookup(id: pubkey) + let profile = try? self.damus.profiles.lookup(id: pubkey) switch Profile.displayName(profile: profile, pubkey: pubkey) { case .one: diff --git a/damus/Features/Profile/Views/ProfilePicView.swift b/damus/Features/Profile/Views/ProfilePicView.swift index 04fe8080..2543b42d 100644 --- a/damus/Features/Profile/Views/ProfilePicView.swift +++ b/damus/Features/Profile/Views/ProfilePicView.swift @@ -98,7 +98,7 @@ struct ProfilePicView: View { } func get_lnurl() -> String? { - return profiles.lookup_with_timestamp(pubkey, borrow: { pr in + return try? profiles.lookup_with_timestamp(pubkey, borrow: { pr in switch pr { case .some(let pr): return pr.lnurl case .none: return nil @@ -134,7 +134,7 @@ struct ProfilePicView: View { } func get_profile_url(picture: String?, pubkey: Pubkey, profiles: Profiles) -> URL { - let pic = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey) + let pic = picture ?? (try? profiles.lookup(id: pubkey)?.picture) ?? robohash(pubkey) if let url = URL(string: pic) { return url } diff --git a/damus/Features/Profile/Views/ProfilePictureSelector.swift b/damus/Features/Profile/Views/ProfilePictureSelector.swift index 0bc4e502..fef9b33a 100644 --- a/damus/Features/Profile/Views/ProfilePictureSelector.swift +++ b/damus/Features/Profile/Views/ProfilePictureSelector.swift @@ -52,7 +52,7 @@ struct EditProfilePictureView: View { if let profile_url { return profile_url } else if let state = damus_state, - let picture = state.profiles.lookup(id: pubkey)?.picture { + let picture = try? state.profiles.lookup(id: pubkey)?.picture { return URL(string: picture) } else { return profile_url ?? URL(string: robohash(pubkey)) diff --git a/damus/Features/Profile/Views/ProfileView.swift b/damus/Features/Profile/Views/ProfileView.swift index df7ec41b..63596d2c 100644 --- a/damus/Features/Profile/Views/ProfileView.swift +++ b/damus/Features/Profile/Views/ProfileView.swift @@ -27,7 +27,7 @@ func follow_btn_txt(_ fs: FollowState, follows_you: Bool) -> String { func followedByString(_ friend_intersection: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String { let bundle = bundleForLocale(locale: locale) let names: [String] = friend_intersection.prefix(3).map { pk in - let profile = ndb.lookup_profile_and_copy(pk) + let profile = try? ndb.lookup_profile_and_copy(pk) return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20) } @@ -108,7 +108,7 @@ struct ProfileView: View { } func getProfileInfo() -> (String, String) { - let ndbprofile = self.damus_state.profiles.lookup(id: profile.pubkey) + let ndbprofile = try? self.damus_state.profiles.lookup(id: profile.pubkey) let displayName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).displayName.truncate(maxLength: 25) let userName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).username.truncate(maxLength: 25) return (displayName, "@\(userName)") @@ -358,8 +358,8 @@ struct ProfileView: View { var aboutSection: some View { VStack(alignment: .leading, spacing: 8.0) { - let lnurl = damus_state.profiles.lookup_lnurl(profile.pubkey) - let ndbprofile = damus_state.profiles.lookup(id: profile.pubkey) + let lnurl = try? damus_state.profiles.lookup_lnurl(profile.pubkey) + let ndbprofile = try? damus_state.profiles.lookup(id: profile.pubkey) nameSection(ndbprofile: ndbprofile, lnurl: lnurl) @@ -570,7 +570,7 @@ extension View { @MainActor func check_nip05_validity(pubkey: Pubkey, damus_state: DamusState) { let profiles = damus_state.profiles - let profile = profiles.lookup(id: pubkey) + let profile = try? profiles.lookup(id: pubkey) guard let nip05 = profile?.nip05, profiles.is_validated(pubkey) == nil diff --git a/damus/Features/Purple/Views/DamusPurpleAccountView.swift b/damus/Features/Purple/Views/DamusPurpleAccountView.swift index cafcd478..a89382ca 100644 --- a/damus/Features/Purple/Views/DamusPurpleAccountView.swift +++ b/damus/Features/Purple/Views/DamusPurpleAccountView.swift @@ -121,7 +121,7 @@ struct DamusPurpleAccountView: View { } func profile_display_name() -> String { - let profile = damus_state.profiles.lookup(id: account.pubkey) + let profile = try? damus_state.profiles.lookup(id: account.pubkey) let display_name = DisplayName(profile: profile, pubkey: account.pubkey).displayName return display_name } diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift index 4a9f8563..047187e6 100644 --- a/damus/Features/Search/Models/SearchHomeModel.swift +++ b/damus/Features/Search/Models/SearchHomeModel.swift @@ -120,7 +120,10 @@ func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: Even } func find_profiles_to_fetch_from_keys(profiles: Profiles, pks: [Pubkey]) -> [Pubkey] { - Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk) })) + Array(Set(pks.filter { pk in + let has_fresh_profile = (try? profiles.has_fresh_profile(id: pk)) ?? false + return !has_fresh_profile + })) } func find_profiles_to_fetch_from_events(profiles: Profiles, events: [NostrEvent], cache: EventCache) -> [Pubkey] { @@ -128,11 +131,14 @@ 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.has_fresh_profile(id: bev.pubkey) { + if ev.known_kind == .boost, + let bev = ev.get_inner_event(cache: cache), + let has_fresh_profiles = try? profiles.has_fresh_profile(id: bev.pubkey), + !has_fresh_profiles { pubkeys.insert(bev.pubkey) } - if !profiles.has_fresh_profile(id: ev.pubkey) { + if let has_fresh_profiles = try? profiles.has_fresh_profile(id: ev.pubkey), !has_fresh_profiles { pubkeys.insert(ev.pubkey) } } diff --git a/damus/Features/Search/Views/PullDownSearch.swift b/damus/Features/Search/Views/PullDownSearch.swift index 38d402e1..de61d05b 100644 --- a/damus/Features/Search/Views/PullDownSearch.swift +++ b/damus/Features/Search/Views/PullDownSearch.swift @@ -19,7 +19,7 @@ struct PullDownSearchView: View { func do_search(query: String) { let limit = 128 - let note_keys = state.ndb.text_search(query: query, limit: limit, order: .newest_first) + let note_keys = (try? state.ndb.text_search(query: query, limit: limit, order: .newest_first)) ?? [] var res = [NostrEvent]() // TODO: fix duplicate results from search var keyset = Set() @@ -32,7 +32,7 @@ struct PullDownSearchView: View { do { for note_key in note_keys { - state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in + try? state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in switch maybeUnownedNote { case .none: return // Skip this case .some(let unownedNote): diff --git a/damus/Features/Search/Views/SearchResultsView.swift b/damus/Features/Search/Views/SearchResultsView.swift index 7b564f50..a3d60723 100644 --- a/damus/Features/Search/Views/SearchResultsView.swift +++ b/damus/Features/Search/Views/SearchResultsView.swift @@ -142,7 +142,7 @@ struct SearchResultsView: View { func do_search(query: String) { let limit = 128 - var note_keys = damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first) + var note_keys = (try? damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)) ?? [] var res = [NostrEvent]() // TODO: fix duplicate results from search var keyset = Set() @@ -155,7 +155,7 @@ struct SearchResultsView: View { do { for note_key in note_keys { - damus_state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in + try? damus_state.ndb.lookup_note_by_key(note_key, borrow: { maybeUnownedNote in switch maybeUnownedNote { case .none: return case .some(let unownedNote): @@ -270,7 +270,7 @@ func make_hashtagable(_ str: String) -> String { func search_profiles(profiles: Profiles, contacts: Contacts, search: String) -> [Pubkey] { // Search by hex pubkey. if let pubkey = hex_decode_pubkey(search), - profiles.lookup_key_by_pubkey(pubkey) != nil + (try? profiles.lookup_key_by_pubkey(pubkey)) != nil { return [pubkey] } @@ -279,12 +279,12 @@ func search_profiles(profiles: Profiles, contacts: Contacts, search: String) -> if search.starts(with: "npub"), let bech32_key = decode_bech32_key(search), case Bech32Key.pub(let pk) = bech32_key, - profiles.lookup_key_by_pubkey(pk) != nil + (try? profiles.lookup_key_by_pubkey(pk)) != nil { return [pk] } - return profiles.search(search, limit: 128).sorted { a, b in + return (try? profiles.search(search, limit: 128).sorted { a, b in let aFriendTypePriority = get_friend_type(contacts: contacts, pubkey: a)?.priority ?? 0 let bFriendTypePriority = get_friend_type(contacts: contacts, pubkey: b)?.priority ?? 0 @@ -294,5 +294,5 @@ func search_profiles(profiles: Profiles, contacts: Contacts, search: String) -> } else { return false } - } + }) ?? [] } diff --git a/damus/Features/Search/Views/SearchingEventView.swift b/damus/Features/Search/Views/SearchingEventView.swift index b39a92bb..e5895200 100644 --- a/damus/Features/Search/Views/SearchingEventView.swift +++ b/damus/Features/Search/Views/SearchingEventView.swift @@ -47,7 +47,7 @@ struct SearchingEventView: View { switch search { case .nip05(let nip05): if let pk = state.profiles.nip05_pubkey[nip05] { - if state.profiles.lookup_key_by_pubkey(pk) != nil { + if (try? state.profiles.lookup_key_by_pubkey(pk)) != nil { self.search_state = .found_profile(pk) } } else { diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 482f075b..5133b8c8 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -124,7 +124,7 @@ class HomeModel: ContactsDelegate, ObservableObject { damus_state.contacts.delegate = self guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return } guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return } - guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note_and_copy(latest_contact_event_id) else { return } + guard let latest_contact_event: NdbNote = try? damus_state.ndb.lookup_note_and_copy(latest_contact_event_id) else { return } process_contact_event(state: damus_state, ev: latest_contact_event) } @@ -153,7 +153,7 @@ class HomeModel: ContactsDelegate, ObservableObject { var candidates: [NostrEvent] = [] for key in note_keys { - guard let note = damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue } + guard let note = try? damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue } candidates.append(note) } return candidates.max(by: { $0.created_at < $1.created_at }) @@ -166,7 +166,7 @@ class HomeModel: ContactsDelegate, ObservableObject { var candidates: [NostrEvent] = [] for key in note_keys { - guard let note = damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue } + guard let note = try? damus_state.ndb.lookup_note_by_key_and_copy(key) else { continue } if note.referenced_params.contains(where: { $0.param.matches_str("mute") }) { candidates.append(note) } diff --git a/damus/Features/Timeline/Views/SideMenuView.swift b/damus/Features/Timeline/Views/SideMenuView.swift index 6352a8ce..ca0d2f28 100644 --- a/damus/Features/Timeline/Views/SideMenuView.swift +++ b/damus/Features/Timeline/Views/SideMenuView.swift @@ -136,7 +136,7 @@ struct SideMenuView: View { var display_name: String? = nil do { - let profile = damus_state.profiles.lookup(id: damus_state.pubkey) + let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey) name = profile?.name display_name = profile?.display_name } diff --git a/damus/Features/Wallet/Views/NWCSettings.swift b/damus/Features/Wallet/Views/NWCSettings.swift index 12f82573..460fb7fe 100644 --- a/damus/Features/Wallet/Views/NWCSettings.swift +++ b/damus/Features/Wallet/Views/NWCSettings.swift @@ -244,7 +244,7 @@ struct NWCSettings: View { } } .onChange(of: model.settings.donation_percent) { p in - guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else { + guard let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey) else { return } @@ -257,7 +257,7 @@ struct NWCSettings: View { .onDisappear { guard let keypair = damus_state.keypair.to_full(), - let profile = damus_state.profiles.lookup(id: damus_state.pubkey), + let profile = try? damus_state.profiles.lookup(id: damus_state.pubkey), model.initial_percent != profile.damus_donation else { return diff --git a/damus/Features/Wallet/Views/TransactionsView.swift b/damus/Features/Wallet/Views/TransactionsView.swift index 1a637977..f8dff151 100644 --- a/damus/Features/Wallet/Views/TransactionsView.swift +++ b/damus/Features/Wallet/Views/TransactionsView.swift @@ -104,7 +104,7 @@ struct TransactionView: View { return NSLocalizedString("Unknown", comment: "A name label for an unknown user") } - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) return Profile.displayName(profile: profile, pubkey: pubkey).displayName } diff --git a/damus/Features/Zaps/Views/ProfileZapLinkView.swift b/damus/Features/Zaps/Views/ProfileZapLinkView.swift index 1866c63d..e75e1a83 100644 --- a/damus/Features/Zaps/Views/ProfileZapLinkView.swift +++ b/damus/Features/Zaps/Views/ProfileZapLinkView.swift @@ -33,8 +33,8 @@ struct ProfileZapLinkView: View { self.label = label self.action = action - let profile = damus_state.profiles.lookup(id: pubkey) - let lnurl = damus_state.profiles.lookup_with_timestamp(pubkey, borrow: { pr -> String? in + let profile = try? damus_state.profiles.lookup(id: pubkey) + let lnurl = try? damus_state.profiles.lookup_with_timestamp(pubkey, borrow: { pr -> String? in switch pr { case .some(let pr): return pr.lnurl case .none: return nil diff --git a/damus/Features/Zaps/Views/ZapTypePicker.swift b/damus/Features/Zaps/Views/ZapTypePicker.swift index dab93d33..96e626a6 100644 --- a/damus/Features/Zaps/Views/ZapTypePicker.swift +++ b/damus/Features/Zaps/Views/ZapTypePicker.swift @@ -97,7 +97,7 @@ func zap_type_desc(type: ZapType, profiles: Profiles, pubkey: Pubkey) -> String case .anon: return NSLocalizedString("No one will see that you zapped", comment: "Description of anonymous zap type where the zap is sent anonymously and does not identify the user who sent it.") case .priv: - let prof = profiles.lookup(id: pubkey) + let prof = try? profiles.lookup(id: pubkey) let name = Profile.displayName(profile: prof, pubkey: pubkey).username.truncate(maxLength: 50) return String.localizedStringWithFormat(NSLocalizedString("private_zap_description", value: "Only '%@' will see that you zapped them", comment: "Description of private zap type where the zap is sent privately and does not identify the user to the public."), name) case .non_zap: diff --git a/damus/Shared/Components/NIP05Badge.swift b/damus/Shared/Components/NIP05Badge.swift index b2f190cc..cac0aded 100644 --- a/damus/Shared/Components/NIP05Badge.swift +++ b/damus/Shared/Components/NIP05Badge.swift @@ -60,7 +60,7 @@ struct NIP05Badge: View { } var username_matches_nip05: Bool { - guard let name = damus_state.profiles.lookup(id: pubkey)?.name + guard let name = try? damus_state.profiles.lookup(id: pubkey)?.name else { return false } diff --git a/damus/Shared/Components/QRCodeView.swift b/damus/Shared/Components/QRCodeView.swift index 127bb316..1344389f 100644 --- a/damus/Shared/Components/QRCodeView.swift +++ b/damus/Shared/Components/QRCodeView.swift @@ -73,7 +73,7 @@ struct QRCodeView: View { var QRView: some View { VStack(alignment: .center) { - let profile = damus_state.profiles.lookup(id: pubkey) + let profile = try? damus_state.profiles.lookup(id: pubkey) ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state) .padding(.top, 20) diff --git a/damus/Shared/Media/Images/BannerImageView.swift b/damus/Shared/Media/Images/BannerImageView.swift index a85d4eaa..5a308380 100644 --- a/damus/Shared/Media/Images/BannerImageView.swift +++ b/damus/Shared/Media/Images/BannerImageView.swift @@ -115,7 +115,7 @@ struct BannerImageView: View { } func get_banner_url(banner: String?, pubkey: Pubkey, profiles: Profiles) -> URL? { - let bannerUrlString = banner ?? profiles.lookup(id: pubkey)?.banner ?? "" + let bannerUrlString = banner ?? (try? profiles.lookup(id: pubkey)?.banner) ?? "" if let url = URL(string: bannerUrlString) { return url } diff --git a/damus/Shared/Utilities/EventCache.swift b/damus/Shared/Utilities/EventCache.swift index 5ebb9799..be4ce6fd 100644 --- a/damus/Shared/Utilities/EventCache.swift +++ b/damus/Shared/Utilities/EventCache.swift @@ -222,7 +222,7 @@ class EventCache { return ev } - if let ev = self.ndb.lookup_note_and_copy(evid) { + if let ev = try? self.ndb.lookup_note_and_copy(evid) { events[ev.id] = ev return ev } diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift index 885e8df7..611bb280 100644 --- a/damusTests/Mocking/MockDamusState.swift +++ b/damusTests/Mocking/MockDamusState.swift @@ -10,6 +10,7 @@ import Foundation import EmojiPicker // Generates a test damus state with configurable mock parameters +@MainActor func generate_test_damus_state( mock_profile_info: [Pubkey: Profile]?, home: HomeModel? = nil, diff --git a/damusTests/NostrNetworkManagerTests/AppLifecycleHandlingTests.swift b/damusTests/NostrNetworkManagerTests/AppLifecycleHandlingTests.swift new file mode 100644 index 00000000..32fcdd4f --- /dev/null +++ b/damusTests/NostrNetworkManagerTests/AppLifecycleHandlingTests.swift @@ -0,0 +1,72 @@ +// +// AppLifecycleHandlingTests.swift +// damus +// +// Created by Daniel D’Aquino on 2025-11-06. +// + +import XCTest +@testable import damus + + +class AppLifecycleHandlingTests: XCTestCase { + + func getTestNotesJSONL() -> String { + // Get the path for the test_notes.jsonl file in the same folder as this test file + let testBundle = Bundle(for: type(of: self)) + let fileURL = testBundle.url(forResource: "test_notes", withExtension: "jsonl")! + + // Load the contents of the file + return try! String(contentsOf: fileURL, encoding: .utf8) + } + + /// Tests for some race conditions between the app closing down and streams opening throughout the app + /// See https://github.com/damus-io/damus/issues/3245 for more context. + /// + /// **Note:** Time delays are intentionally added because we actually want to provoke possible race conditions, + /// so using proper waiting mechanisms would defeat the purpose of the test. + func testAppLifecycleRaceConditions() async throws { + let damusState = await generate_test_damus_state(mock_profile_info: nil) + + let notesJSONL = getTestNotesJSONL() + for noteText in notesJSONL.split(separator: "\n") { + let _ = damusState.ndb.processEvent("[\"EVENT\",\"subid\",\(String(noteText))]") + } + + // Give some time ndb some time to fill up + try? await Task.sleep(for: .milliseconds(2000)) + + + + // Start measuring the time elapsed for debugging + let startTime = CFAbsoluteTimeGetCurrent() + func getElapsedTimeMiliseconds() -> String { + return "\((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms" + } + + + Task.detached { + for i in 0...10000 { + try await Task.sleep(for: .milliseconds(Int.random(in: 0...10))) + print("APP_LIFECYCLE_TEST \(i): About to close Ndb. Elapsed time: \(getElapsedTimeMiliseconds())") + damusState.ndb.close() + print("APP_LIFECYCLE_TEST \(i): Closed Ndb. Elapsed time: \(getElapsedTimeMiliseconds())") + print("APP_LIFECYCLE_TEST \(i): Reopening Ndb. Elapsed time: \(getElapsedTimeMiliseconds())") + _ = damusState.ndb.reopen() + print("APP_LIFECYCLE_TEST \(i): Reopened Ndb. Elapsed time: \(getElapsedTimeMiliseconds())") + + } + } + for i in 0...10000 { + do { + try await Task.sleep(for: .milliseconds(Int.random(in: 0...10))) + print("APP_LIFECYCLE_TEST \(i): Starting new query. Elapsed time: \(getElapsedTimeMiliseconds())") + _ = try damusState.ndb.query(filters: [try NdbFilter(from: NostrFilter(kinds: [.text], limit: 1000))], maxResults: 500) + } + catch { + print("APP_LIFECYCLE_TEST \(i): Query error: \(error). Elapsed time: \(getElapsedTimeMiliseconds())") + } + print("APP_LIFECYCLE_TEST \(i): Finished query. Elapsed time: \(getElapsedTimeMiliseconds())") + } + } +} diff --git a/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift b/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift index 64cff8b7..51478c51 100644 --- a/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift +++ b/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import damus +@MainActor class NostrNetworkManagerTests: XCTestCase { var damusState: DamusState? = nil @@ -137,7 +138,7 @@ class NostrNetworkManagerTests: XCTestCase { switch item { case .event(let noteKey): // Lookup the note to verify it exists - if let note = ndb.lookup_note_by_key_and_copy(noteKey) { + if let note = try? ndb.lookup_note_by_key_and_copy(noteKey) { count += 1 receivedIds.insert(note.id) } diff --git a/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift b/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift index 394ccf0d..040026fe 100644 --- a/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift +++ b/damusTests/NostrNetworkManagerTests/ProfilesManagerTests.swift @@ -42,7 +42,7 @@ class ProfilesManagerTests: XCTestCase { try await Task.sleep(for: .milliseconds(100)) // Verify profile is in NDB - let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey) + let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey) XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB") XCTAssertEqual(cachedProfile?.name, "testuser") @@ -109,7 +109,7 @@ class ProfilesManagerTests: XCTestCase { try await Task.sleep(for: .milliseconds(100)) // Verify profile is in NDB - let cachedProfile = ndb.lookup_profile_and_copy(profilePubkey) + let cachedProfile = try? ndb.lookup_profile_and_copy(profilePubkey) XCTAssertNotNil(cachedProfile, "Profile should be cached in NDB") // Create ProfilesManager diff --git a/damusTests/NostrNetworkManagerTests/ThreadModelTests.swift b/damusTests/NostrNetworkManagerTests/ThreadModelTests.swift index fa72d03e..2f6c3ebb 100644 --- a/damusTests/NostrNetworkManagerTests/ThreadModelTests.swift +++ b/damusTests/NostrNetworkManagerTests/ThreadModelTests.swift @@ -9,6 +9,7 @@ import XCTest @testable import damus +@MainActor final class ThreadModelTests: XCTestCase { var damusState: DamusState? = nil @@ -39,8 +40,12 @@ final class ThreadModelTests: XCTestCase { /// Tests loading up a thread and checking if the repost count loads as expected. func testActionBarModel() async throws { - try! await damusState?.nostrNetwork.userRelayList.set(userRelayList: NIP65.RelayList()) - await damusState?.nostrNetwork.connect() + guard let damusState else { + XCTFail("DamusState is nil, test is misconfigured") + return + } + try! await damusState.nostrNetwork.userRelayList.set(userRelayList: NIP65.RelayList()) + await damusState.nostrNetwork.connect() let testNoteJson = """ {"content":"https://smartflowsocial.s3.us-east-1.amazonaws.com/clients/cm7kdrwdk0000qyu6fwtd96ui/0cab65a9-0142-48e3-abd7-94d20e30d3b2.jpg\n\n","pubkey":"71ecabd8b6b33548e075ff01b31568ffda19d0ac2788067d99328c6de4885975","tags":[["t","meme"],["t","memes"],["t","memestr"],["t","plebchain"]],"created_at":1755694800,"id":"64b26d0a587f5f894470e1e4783756b4d8ba971226de975ee30ac1b69970d5a1","kind":1,"sig":"c000794da8c4f7549b546630b16ed17f6edc0af0269b8c46ce14f5b1937431e7575b78351bc152007ebab5720028e5fe4b738f99e8887f273d35dd2217d1cc3d"} @@ -48,12 +53,12 @@ final class ThreadModelTests: XCTestCase { let testShouldComplete = XCTestExpectation(description: "Test should complete") Task { let note = NostrEvent.owned_from_json(json: testNoteJson)! - let threadModel = await ThreadModel(event: note, damus_state: damusState!) - await threadModel.subscribe() - let actionBarModel = make_actionbar_model(ev: note.id, damus: damusState!) + let threadModel = ThreadModel(event: note, damus_state: damusState) + threadModel.subscribe() + let actionBarModel = make_actionbar_model(ev: note.id, damus: damusState) while true { try await Task.sleep(nanoseconds: 500_000_000) - await actionBarModel.update(damus: damusState!, evid: note.id) + await actionBarModel.update(damus: damusState, evid: note.id) if actionBarModel.boosts >= 5 { break } diff --git a/nostrdb/Ndb.swift b/nostrdb/Ndb.swift index 6b165f09..6a266ad5 100644 --- a/nostrdb/Ndb.swift +++ b/nostrdb/Ndb.swift @@ -7,6 +7,7 @@ import Foundation import OSLog +import Synchronization fileprivate let APPLICATION_GROUP_IDENTIFIER = "group.com.damus" @@ -34,6 +35,7 @@ class Ndb { var generation: Int private var closed: Bool private var callbackHandler: Ndb.CallbackHandler + private let ndbAccessLock: Ndb.UseLockProtocol = initLock() private static let DEFAULT_WRITER_SCRATCH_SIZE: Int32 = 2097152; // 2mb scratch size for the writer thread, it should match with the one specified in nostrdb.c @@ -158,6 +160,7 @@ class Ndb { self.ndb = db self.closed = false self.callbackHandler = callbackHandler + self.ndbAccessLock.markNdbOpen() } private static func migrate_db_location_if_needed() throws { @@ -206,7 +209,7 @@ class Ndb { // This simple initialization will cause subscriptions not to be ever called. Probably fine because this initializer is used only for empty example ndb instances. self.callbackHandler = Ndb.CallbackHandler() } - + /// Mark NostrDB as "closed" without actually closing it. /// Useful when shutting down tasks that use NostrDB while avoiding new tasks from using it. func markClosed() { @@ -216,10 +219,13 @@ class Ndb { func close() { guard !self.is_closed else { return } self.closed = true - print("txn: CLOSING NOSTRDB") - ndb_destroy(self.ndb.ndb) - self.generation += 1 - print("txn: NOSTRDB CLOSED") + try! self.ndbAccessLock.waitUntilNdbCanClose(thenClose: { + print("txn: CLOSING NOSTRDB") + ndb_destroy(self.ndb.ndb) + self.generation += 1 + print("txn: NOSTRDB CLOSED") + return false + }, maxTimeout: .milliseconds(2000)) } func reopen() -> Bool { @@ -229,12 +235,29 @@ class Ndb { } print("txn: NOSTRDB REOPENED (gen \(generation))") - + + self.ndb = db // Set the new DB before marking it as open to prevent access to the old DB self.closed = false - self.ndb = db + self.ndbAccessLock.markNdbOpen() return true } + + // MARK: Thread safety mechanisms + // Use these for all externally accessible methods that interact with the nostrdb database to prevent race conditions with app lifecycle events (i.e. NostrDB opening and closing) + + internal func withNdb(_ useFunction: () throws -> T, maxWaitTimeout: DispatchTimeInterval = .milliseconds(500)) throws -> T { + guard !self.is_closed else { throw NdbStreamError.ndbClosed } + return try self.ndbAccessLock.keepNdbOpen(during: { + // Double-check things to avoid TOCTOU race conditions + guard !self.is_closed else { throw NdbStreamError.ndbClosed } + return try useFunction() + }, maxWaitTimeout: maxWaitTimeout) + } + + + // MARK: Lookup and query functions + // GH_3245 TODO: This is a low level call, make it hidden from outside Ndb internal func lookup_blocks_by_key_with_txn(_ key: NoteKey, txn: RawNdbTxnAccessible) -> NdbBlockGroup.BlocksMetadata? { guard let blocks = ndb_get_blocks_by_key(self.ndb.ndb, &txn.txn, key) else { @@ -244,14 +267,16 @@ class Ndb { return NdbBlockGroup.BlocksMetadata(ptr: blocks) } - func lookup_blocks_by_key(_ key: NoteKey, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) rethrows -> T { - let txn = SafeNdbTxn.new(on: self) { txn in - lookup_blocks_by_key_with_txn(key, txn: txn) - } - guard let txn else { - return try lendingFunction(nil) - } - return try lendingFunction(txn.val) + func lookup_blocks_by_key(_ key: NoteKey, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) throws -> T { + return try withNdb({ + let txn = SafeNdbTxn.new(on: self) { txn in + lookup_blocks_by_key_with_txn(key, txn: txn) + } + guard let txn else { + return try lendingFunction(nil) + } + return try lendingFunction(txn.val) + }) } private func lookup_note_by_key_with_txn(_ key: NoteKey, txn: NdbTxn) -> NdbNote? { @@ -263,174 +288,180 @@ class Ndb { return NdbNote(note: ptr, size: size, owned: false, key: key) } - func text_search(query: String, limit: Int = 128, order: NdbSearchOrder = .newest_first) -> [NoteKey] { - guard let txn = NdbTxn(ndb: self) else { return [] } - var results = ndb_text_search_results() - let res = query.withCString { q in - let order = order == .newest_first ? NDB_ORDER_DESCENDING : NDB_ORDER_ASCENDING - var config = ndb_text_search_config(order: order, limit: Int32(limit)) - return ndb_text_search(&txn.txn, q, &results, &config) - } - - if res == 0 { - return [] - } - - var note_ids = [NoteKey]() - for i in 0.. [NoteKey] { + return try withNdb({ + guard let txn = NdbTxn(ndb: self) else { return [] } + var results = ndb_text_search_results() + let res = query.withCString { q in + let order = order == .newest_first ? NDB_ORDER_DESCENDING : NDB_ORDER_ASCENDING + var config = ndb_text_search_config(order: order, limit: Int32(limit)) + return ndb_text_search(&txn.txn, q, &results, &config) } - } - return note_ids + if res == 0 { + return [] + } + + var note_ids = [NoteKey]() + for i in 0..(_ key: NoteKey, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) rethrows -> T { - let txn = NdbTxn(ndb: self) { txn in - lookup_note_by_key_with_txn(key, txn: txn) - } - guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) } - let unownedNote = UnownedNdbNote(rawNote) - return try lendingFunction(.some(unownedNote)) + func lookup_note_by_key(_ key: NoteKey, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) throws -> T { + return try withNdb({ + let txn = NdbTxn(ndb: self) { txn in + lookup_note_by_key_with_txn(key, txn: txn) + } + guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) } + let unownedNote = UnownedNdbNote(rawNote) + return try lendingFunction(.some(unownedNote)) + }) } - func lookup_note_by_key_and_copy(_ key: NoteKey) -> NdbNote? { - return lookup_note_by_key(key, borrow: { maybeUnownedNote -> NdbNote? in - switch maybeUnownedNote { - case .none: return nil - case .some(let unownedNote): return unownedNote.toOwned() - } + func lookup_note_by_key_and_copy(_ key: NoteKey) throws -> NdbNote? { + return try withNdb({ + return try lookup_note_by_key(key, borrow: { maybeUnownedNote -> NdbNote? in + switch maybeUnownedNote { + case .none: return nil + case .some(let unownedNote): return unownedNote.toOwned() + } + }) }) } @@ -489,26 +520,30 @@ class Ndb { lookup_profile_by_key_inner(key, txn: txn) } - func lookup_profile_by_key(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T { - let txn = SafeNdbTxn.new(on: self) { txn in - return lookup_profile_by_key_inner(key, txn: txn) - } - guard let txn else { return try lendingFunction(nil) } - return try lendingFunction(txn.val) + func lookup_profile_by_key(key: ProfileKey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T { + return try withNdb({ + let txn = SafeNdbTxn.new(on: self) { txn in + return lookup_profile_by_key_inner(key, txn: txn) + } + guard let txn else { return try lendingFunction(nil) } + return try lendingFunction(txn.val) + }) } private func lookup_note_with_txn(id: NoteId, txn: NdbTxn) -> NdbNote? { lookup_note_with_txn_inner(id: id, txn: txn) } - func lookup_profile_key(_ pubkey: Pubkey) -> ProfileKey? { - guard let txn = NdbTxn(ndb: self, with: { txn in - lookup_profile_key_with_txn(pubkey, txn: txn) - }) else { - return nil - } + func lookup_profile_key(_ pubkey: Pubkey) throws -> ProfileKey? { + return try withNdb({ + guard let txn = NdbTxn(ndb: self, with: { txn in + lookup_profile_key_with_txn(pubkey, txn: txn) + }) else { + return nil + } - return txn.value + return txn.value + }) } private func lookup_profile_key_with_txn(_ pubkey: Pubkey, txn: NdbTxn) -> ProfileKey? { @@ -537,40 +572,46 @@ class Ndb { } } - func lookup_note_key(_ id: NoteId) -> NoteKey? { - guard let txn = NdbTxn(ndb: self, with: { txn in - lookup_note_key_with_txn(id, txn: txn) - }) else { - return nil - } + func lookup_note_key(_ id: NoteId) throws -> NoteKey? { + return try withNdb({ + guard let txn = NdbTxn(ndb: self, with: { txn in + lookup_note_key_with_txn(id, txn: txn) + }) else { + return nil + } - return txn.value + return txn.value + }) } - func lookup_note(_ id: NoteId, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) rethrows -> T { - let txn = NdbTxn(ndb: self) { txn in - lookup_note_with_txn_inner(id: id, txn: txn) - } - guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) } - return try lendingFunction(UnownedNdbNote(rawNote)) + func lookup_note(_ id: NoteId, borrow lendingFunction: (_: borrowing UnownedNdbNote?) throws -> T) throws -> T { + return try withNdb({ + let txn = NdbTxn(ndb: self) { txn in + lookup_note_with_txn_inner(id: id, txn: txn) + } + guard let rawNote = txn?.unsafeUnownedValue else { return try lendingFunction(nil) } + return try lendingFunction(UnownedNdbNote(rawNote)) + }) } - func lookup_note_and_copy(_ id: NoteId) -> NdbNote? { - return self.lookup_note(id, borrow: { unownedNote in + func lookup_note_and_copy(_ id: NoteId) throws -> NdbNote? { + return try self.lookup_note(id, borrow: { unownedNote in return unownedNote?.toOwned() }) } - func lookup_profile(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) rethrows -> T { - let txn = SafeNdbTxn.new(on: self) { txn in - lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn) - } - guard let txn else { return try lendingFunction(nil) } - return try lendingFunction(txn.val) + func lookup_profile(_ pubkey: Pubkey, borrow lendingFunction: (_: borrowing ProfileRecord?) throws -> T) throws -> T { + return try withNdb({ + let txn = SafeNdbTxn.new(on: self) { txn in + lookup_profile_with_txn_inner(pubkey: pubkey, txn: txn) + } + guard let txn else { return try lendingFunction(nil) } + return try lendingFunction(txn.val) + }) } - func lookup_profile_lnurl(_ pubkey: Pubkey) -> String? { - return lookup_profile(pubkey, borrow: { pr in + func lookup_profile_lnurl(_ pubkey: Pubkey) throws -> String? { + return try lookup_profile(pubkey, borrow: { pr in switch pr { case .none: return nil case .some(let pr): return pr.lnurl @@ -578,8 +619,8 @@ class Ndb { }) } - func lookup_profile_and_copy(_ pubkey: Pubkey) -> Profile? { - return self.lookup_profile(pubkey, borrow: { pr in + func lookup_profile_and_copy(_ pubkey: Pubkey) throws -> Profile? { + return try self.lookup_profile(pubkey, borrow: { pr in switch pr { case .some(let pr): return pr.profile case .none: return nil @@ -592,18 +633,20 @@ class Ndb { } func process_client_event(_ str: String) -> Bool { - guard !self.is_closed else { return false } - return str.withCString { cstr in - return ndb_process_client_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 - } + return (try? withNdb({ + return str.withCString { cstr in + return ndb_process_client_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 + } + })) ?? false } - func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) { - guard !closed else { return } - let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in - guard let p = ptr.baseAddress else { return } - ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at) - } + func write_profile_last_fetched(pubkey: Pubkey, fetched_at: UInt64) throws { + return try withNdb({ + let _ = pubkey.id.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) -> () in + guard let p = ptr.baseAddress else { return } + ndb_write_last_profile_fetch(ndb.ndb, p, fetched_at) + } + }) } private func read_profile_last_fetched(txn: NdbTxn, pubkey: Pubkey) -> UInt64? { @@ -619,41 +662,50 @@ class Ndb { } } - func read_profile_last_fetched(pubkey: Pubkey) -> UInt64? { - var last_fetched: UInt64? = nil - let _ = NdbTxn(ndb: self) { txn in - last_fetched = read_profile_last_fetched(txn: txn, pubkey: pubkey) - } - return last_fetched + func read_profile_last_fetched(pubkey: Pubkey) throws -> UInt64? { + return try withNdb({ + var last_fetched: UInt64? = nil + let _ = NdbTxn(ndb: self) { txn in + last_fetched = read_profile_last_fetched(txn: txn, pubkey: pubkey) + } + return last_fetched + }) } func process_event(_ str: String, originRelayURL: String? = nil) -> Bool { - guard !is_closed else { return false } - guard let originRelayURL else { + let response = try? withNdb({ + guard !is_closed else { return false } + guard let originRelayURL else { + return str.withCString { cstr in + return ndb_process_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 + } + } return str.withCString { cstr in - return ndb_process_event(ndb.ndb, cstr, Int32(str.utf8.count)) != 0 + return originRelayURL.withCString { originRelayCString in + let meta = UnsafeMutablePointer.allocate(capacity: 1) + defer { meta.deallocate() } + ndb_ingest_meta_init(meta, 0, originRelayCString) + return ndb_process_event_with(ndb.ndb, cstr, Int32(str.utf8.count), meta) != 0 + } } - } - return str.withCString { cstr in - return originRelayURL.withCString { originRelayCString in - let meta = UnsafeMutablePointer.allocate(capacity: 1) - defer { meta.deallocate() } - ndb_ingest_meta_init(meta, 0, originRelayCString) - return ndb_process_event_with(ndb.ndb, cstr, Int32(str.utf8.count), meta) != 0 - } - } + }) + return response ?? false } func process_events(_ str: String) -> Bool { - guard !is_closed else { return false } - return str.withCString { cstr in - return ndb_process_events(ndb.ndb, cstr, str.utf8.count) != 0 - } + let response = try? withNdb({ + return str.withCString { cstr in + return ndb_process_events(ndb.ndb, cstr, str.utf8.count) != 0 + } + }) + return response ?? false } - func search_profile(_ search: String, limit: Int) -> [Pubkey] { - guard let txn = NdbTxn<()>.init(ndb: self) else { return [] } - return search_profile(search, limit: limit, txn: txn) + func search_profile(_ search: String, limit: Int) throws -> [Pubkey] { + return try withNdb({ + guard let txn = NdbTxn<()>.init(ndb: self) else { return [] } + return search_profile(search, limit: limit, txn: txn) + }) } private func search_profile(_ search: String, limit: Int, txn: NdbTxn) -> [Pubkey] { @@ -684,9 +736,11 @@ class Ndb { // MARK: NdbFilter queries and subscriptions - func query(filters: [NdbFilter], maxResults: Int) throws(NdbStreamError) -> [NoteKey] { - guard let txn = NdbTxn(ndb: self) else { return [] } - return try query(with: txn, filters: filters, maxResults: maxResults) + func query(filters: [NdbFilter], maxResults: Int) throws -> [NoteKey] { + return try withNdb({ + guard let txn = NdbTxn(ndb: self) else { return [] } + return try query(with: txn, filters: filters, maxResults: maxResults) + }) } /// Safe wrapper around the `ndb_query` C function @@ -696,8 +750,7 @@ class Ndb { /// - maxResults: Maximum number of results to return /// - Returns: Array of note keys matching the filters /// - Throws: NdbStreamError if the query fails - private func query(with txn: NdbTxn, filters: [NdbFilter], maxResults: Int) throws(NdbStreamError) -> [NoteKey] { - guard !self.is_closed else { throw .ndbClosed } + private func query(with txn: NdbTxn, filters: [NdbFilter], maxResults: Int) throws -> [NoteKey] { let filtersPointer = UnsafeMutablePointer.allocate(capacity: filters.count) defer { filtersPointer.deallocate() } @@ -711,7 +764,6 @@ class Ndb { let results = UnsafeMutablePointer.allocate(capacity: maxResults) defer { results.deallocate() } - guard !self.is_closed else { throw .ndbClosed } guard ndb_query(&txn.txn, filtersPointer, Int32(filters.count), results, Int32(maxResults), count) == 1 else { throw NdbStreamError.initialQueryFailed } @@ -760,7 +812,7 @@ class Ndb { } // Set up subscription - subid = ndb_subscribe(self.ndb.ndb, filtersPointer, Int32(filters.count)) + guard let subid = try? withNdb({ ndb_subscribe(self.ndb.ndb, filtersPointer, Int32(filters.count)) }) else { return } // We are setting the continuation after issuing the subscription call. // This won't cause lost notes because if any notes get issued before registering @@ -789,17 +841,24 @@ class Ndb { do { try Task.checkCancellation() } catch { throw NdbStreamError.cancelled } - // CRITICAL: Create the subscription FIRST before querying to avoid race condition - // This ensures that any events indexed after subscription but before query won't be missed - let newEventsStream = ndbSubscribe(filters: filters) + var noteIds: [NoteKey] = [] - // Now fetch initial results after subscription is registered - guard let txn = NdbTxn(ndb: self) else { throw NdbStreamError.cannotOpenTransaction } - - // Use our safe wrapper instead of direct C function call - let noteIds = try query(with: txn, filters: filters, maxResults: maxSimultaneousResults) - - do { try Task.checkCancellation() } catch { throw NdbStreamError.cancelled } + let newEventsStream = try withNdb({ + + // CRITICAL: Create the subscription FIRST before querying to avoid race condition + // This ensures that any events indexed after subscription but before query won't be missed + let newEventsStream = ndbSubscribe(filters: filters) + + // Now fetch initial results after subscription is registered + guard let txn = NdbTxn(ndb: self) else { throw NdbStreamError.cannotOpenTransaction } + + // Use our safe wrapper instead of direct C function call + noteIds = try query(with: txn, filters: filters, maxResults: maxSimultaneousResults) + + do { try Task.checkCancellation() } catch { throw NdbStreamError.cancelled } + + return newEventsStream + }) // Create a cascading stream that combines initial results with new events return AsyncStream { continuation in @@ -861,7 +920,9 @@ class Ndb { } func was(noteKey: NoteKey, seenOn relayUrl: String) throws -> Bool { - return try self.was(noteKey: noteKey, seenOn: relayUrl, txn: nil) + return try withNdb({ + return try self.was(noteKey: noteKey, seenOn: relayUrl, txn: nil) + }) } /// Determines if a given note was seen on any of the listed relay URLs @@ -877,7 +938,9 @@ class Ndb { /// Determines if a given note was seen on any of the listed relay URLs func was(noteKey: NoteKey, seenOnAnyOf relayUrls: [String]) throws -> Bool { - return try self.was(noteKey: noteKey, seenOnAnyOf: relayUrls, txn: nil) + return try withNdb({ + return try self.was(noteKey: noteKey, seenOnAnyOf: relayUrls, txn: nil) + }) } // MARK: Internal ndb callback interfaces @@ -1035,4 +1098,3 @@ func getDebugCheckedRoot(byteBuffer: inout ByteBuffer) thro func remove_file_prefix(_ str: String) -> String { return str.replacingOccurrences(of: "file://", with: "") } - diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift index 2e22d9bd..52c4169c 100644 --- a/nostrdb/NdbNote.swift +++ b/nostrdb/NdbNote.swift @@ -478,8 +478,8 @@ extension NdbNote { return ThreadReply(tags: self.tags)?.reply.note_id } - func block_offsets(ndb: Ndb, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) rethrows -> T { - guard let key = ndb.lookup_note_key(self.id) else { return try lendingFunction(nil) } + func block_offsets(ndb: Ndb, borrow lendingFunction: (_: borrowing NdbBlockGroup.BlocksMetadata?) throws -> T) throws -> T { + guard let key = try ndb.lookup_note_key(self.id) else { return try lendingFunction(nil) } return try ndb.lookup_blocks_by_key(key, borrow: { blocks in return try lendingFunction(blocks) diff --git a/nostrdb/NdbTxn.swift b/nostrdb/NdbTxn.swift index 4e32b2e0..6141f111 100644 --- a/nostrdb/NdbTxn.swift +++ b/nostrdb/NdbTxn.swift @@ -24,6 +24,12 @@ class NdbTxn: RawNdbTxnAccessible { static func pure(ndb: Ndb, val: T) -> NdbTxn { .init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn") } + + /// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques + private struct R { + let txn: ndb_txn + let generation: Int + } init?(ndb: Ndb, with: (NdbTxn) -> T = { _ in () }, name: String? = nil) { guard !ndb.is_closed else { return nil } @@ -43,17 +49,18 @@ class NdbTxn: RawNdbTxnAccessible { let new_ref_count = ref_count + 1 Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count } else { - self.txn = ndb_txn() - guard !ndb.is_closed else { return nil } - self.generation = ndb.generation - #if TXNDEBUG - txn_count += 1 - #endif - let ok = ndb_begin_query(ndb.ndb.ndb, &self.txn) != 0 - if !ok { - return nil - } - self.generation = ndb.generation + let result: R? = try? ndb.withNdb({ + var txn = ndb_txn() + #if TXNDEBUG + txn_count += 1 + #endif + let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0 + guard ok else { return .none } + return .some(R(txn: txn, generation: ndb.generation)) + }, maxWaitTimeout: .milliseconds(200)) + guard let result else { return nil } + self.txn = result.txn + self.generation = result.generation Thread.current.threadDictionary["ndb_txn"] = self.txn Thread.current.threadDictionary["ndb_txn_ref_count"] = 1 Thread.current.threadDictionary["txn_generation"] = ndb.generation @@ -97,7 +104,9 @@ class NdbTxn: RawNdbTxnAccessible { Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero") if new_ref_count <= 0 { - ndb_end_query(&self.txn) + _ = try? ndb.withNdb({ + ndb_end_query(&self.txn) + }, maxWaitTimeout: .milliseconds(200)) Thread.current.threadDictionary.removeObject(forKey: "ndb_txn") Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count") } @@ -156,10 +165,16 @@ class SafeNdbTxn { .init(ndb: ndb, txn: ndb_txn(), val: val, generation: ndb.generation, inherited: true, name: "pure_txn") } + /// Simple helper struct for the init function to avoid compiler errors encountered by using other techniques + private struct R { + let txn: ndb_txn + let generation: Int + } + static func new(on ndb: Ndb, with valueGetter: (PlaceholderNdbTxn) -> T? = { _ in () }, name: String = "txn") -> SafeNdbTxn? { guard !ndb.is_closed else { return nil } - var generation = ndb.generation - var txn: ndb_txn + let generation: Int + let txn: ndb_txn let inherited: Bool if let active_txn = Thread.current.threadDictionary["ndb_txn"] as? ndb_txn, let txn_generation = Thread.current.threadDictionary["txn_generation"] as? Int, @@ -174,26 +189,26 @@ class SafeNdbTxn { let new_ref_count = ref_count + 1 Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count } else { - txn = ndb_txn() - guard !ndb.is_closed else { return nil } - generation = ndb.generation - #if TXNDEBUG - txn_count += 1 - #endif - let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0 - if !ok { - return nil - } - generation = ndb.generation + let result: R? = try? ndb.withNdb({ + var txn = ndb_txn() + #if TXNDEBUG + txn_count += 1 + #endif + let ok = ndb_begin_query(ndb.ndb.ndb, &txn) != 0 + guard ok else { return .none } + return .some(R(txn: txn, generation: ndb.generation)) + }, maxWaitTimeout: .milliseconds(200)) + guard let result else { return nil } + txn = result.txn + generation = result.generation Thread.current.threadDictionary["ndb_txn"] = txn Thread.current.threadDictionary["ndb_txn_ref_count"] = 1 Thread.current.threadDictionary["txn_generation"] = ndb.generation inherited = false } #if TXNDEBUG - print("txn: open gen\(self.generation) '\(self.name)' \(txn_count)") + print("txn: open gen\(generation) '\(name)' \(txn_count)") #endif - let moved = false let placeholderTxn = PlaceholderNdbTxn(txn: txn) guard let val = valueGetter(placeholderTxn) else { return nil } return SafeNdbTxn(ndb: ndb, txn: txn, val: val, generation: generation, inherited: inherited, name: name) @@ -223,7 +238,9 @@ class SafeNdbTxn { Thread.current.threadDictionary["ndb_txn_ref_count"] = new_ref_count assert(new_ref_count >= 0, "NdbTxn reference count should never be below zero") if new_ref_count <= 0 { - ndb_end_query(&self.txn) + _ = try? ndb.withNdb({ + ndb_end_query(&self.txn) + }, maxWaitTimeout: .milliseconds(200)) Thread.current.threadDictionary.removeObject(forKey: "ndb_txn") Thread.current.threadDictionary.removeObject(forKey: "ndb_txn_ref_count") } diff --git a/nostrdb/NdbUseLock.swift b/nostrdb/NdbUseLock.swift new file mode 100644 index 00000000..38c433e3 --- /dev/null +++ b/nostrdb/NdbUseLock.swift @@ -0,0 +1,197 @@ +// +// NdbUseLock.swift +// damus +// +// Created by Daniel D’Aquino on 2025-11-12. +// + +import Dispatch +import Synchronization + +extension Ndb { + /// Creates a `sync` mechanism for coordinating usages of ndb (read or write) with the app's ability to close ndb. + /// + /// This prevents race condition between threads reading from `ndb` and the app trying to close `ndb` + /// + /// Implementation notes: + /// - This was made as a synchronous mechanism because using `async` solutions (e.g. isolating `Ndb` into an `NdbActor`) + /// creates a necessity to change way too much code around the codebase, the interface becomes more cumbersome and difficult to use, + /// and might create unnecessary async delays (e.g. it would prevent two tasks from reading Ndb data at once) + @available(iOS 18.0, *) + class UseLock: UseLockProtocol { + /// Number of functions using the `ndb` object (for reading or writing data) + private let ndbUserCount = Mutex(0) + /// Semaphore for general access to `ndb`. A closing task requires exclusive access. Users of `ndb` (read/write tasks) share the access + private let ndbAccessSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + private let ndbIsOpen = Mutex(false) + /// How long a thread can block before throwing an error + private static let DEFAULT_TIMEOUT: DispatchTimeInterval = .milliseconds(500) + + /// Keeps the ndb open while performing some specified operation. + /// + /// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period. + /// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + /// + /// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible! + /// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up. + /// - Returns: The return result for the given operation + func keepNdbOpen(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws -> T { + try self.incrementUserCount(maxTimeout: maxWaitTimeout) + defer { self.decrementUserCount() } // Use defer to guarantee this will always be called no matter the outcome of the function + return try operation() + } + + /// Waits for ndb to be able to close, then closes it. + /// + /// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end + /// + /// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws { + try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout) + ndbIsOpen.withLock { ndbIsOpen in + ndbIsOpen = operation() + if ndbIsOpen { + ndbAccessSemaphore.signal() + } + } + } + + func markNdbOpen() { + ndbIsOpen.withLock { ndbIsOpen in + if !ndbIsOpen { + ndbIsOpen = true + ndbAccessSemaphore.signal() + } + } + } + + private func incrementUserCount(maxTimeout: DispatchTimeInterval = .seconds(2)) throws { + try ndbUserCount.withLock { currentCount in + // Signal that ndb cannot close while we have at least one user using ndb + if currentCount == 0 { + try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout) + } + currentCount += 1 + } + } + + private func decrementUserCount() { + ndbUserCount.withLock { currentCount in + currentCount -= 1 + // Signal that ndb can close if we have zero users using ndb + if currentCount == 0 { + ndbAccessSemaphore.signal() + } + } + } + + enum LockError: Error { + case timeout + } + } + + /// A fallback implementation for `UseLock` that works in iOS older than iOS 18, with reduced syncing mechanisms + class FallbackUseLock: UseLockProtocol { + /// Number of functions using the `ndb` object (for reading or writing data) + private var ndbUserCount: UInt = 0 + /// Semaphore for general access to `ndb`. A closing task requires exclusive access. Users of `ndb` (read/write tasks) share the access + private let ndbAccessSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + /// How long a thread can block before throwing an error + private static let DEFAULT_TIMEOUT: DispatchTimeInterval = .milliseconds(500) + + /// Keeps the ndb open while performing some specified operation. + /// + /// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period. + /// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + /// + /// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible! + /// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up. + /// - Returns: The return result for the given operation + func keepNdbOpen(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws -> T { + try self.incrementUserCount(maxTimeout: maxWaitTimeout) + defer { self.decrementUserCount() } // Use defer to guarantee this will always be called no matter the outcome of the function + return try operation() + } + + /// Waits for ndb to be able to close, then closes it. + /// + /// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end + /// + /// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval = DEFAULT_TIMEOUT) throws { + try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout) + let ndbIsOpen = operation() + if ndbIsOpen { + ndbAccessSemaphore.signal() + } + } + + /// Marks `ndb` as open to allow other users to use it. Do not call this more than once + func markNdbOpen() { + ndbAccessSemaphore.signal() + } + + private func incrementUserCount(maxTimeout: DispatchTimeInterval = .seconds(2)) throws { + if ndbUserCount == 0 { + try ndbAccessSemaphore.waitOrThrow(timeout: .now() + maxTimeout) + } + ndbUserCount += 1 + } + + private func decrementUserCount() { + ndbUserCount -= 1 + // Signal that ndb can close if we have zero users using ndb + if ndbUserCount == 0 { + ndbAccessSemaphore.signal() + } + } + + enum LockError: Error { + case timeout + } + } + + protocol UseLockProtocol { + /// Keeps the ndb open while performing some specified operation. + /// + /// **WARNING:** Ensure ndb is open _before_ calling this, otherwise the thread may block for the `maxTimeout` period. + /// **Implementation note:** NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + /// + /// - Parameter operation: The operation to perform while `ndb` is open. Keep this as short as safely possible! + /// - Parameter maxTimeout: The maximum amount of time the function will wait for the lock before giving up. + /// - Returns: The return result for the given operation + func keepNdbOpen(during operation: () throws -> T, maxWaitTimeout: DispatchTimeInterval) throws -> T + + /// Waits for ndb to be able to close, then closes it. + /// + /// - Parameter operation: The operation to close. Must return the final boolean value indicating if ndb was closed in the end + /// + /// Implementation note: NEVER change this to `async`! This is a blocking operation, so we want to minimize the time of the operation + func waitUntilNdbCanClose(thenClose operation: () -> Bool, maxTimeout: DispatchTimeInterval) throws + + /// Marks `ndb` as open to allow other users to use it. Do not call this more than once + func markNdbOpen() + } + + static func initLock() -> UseLockProtocol { + if #available(iOS 18.0, *) { + return UseLock() + } else { + return FallbackUseLock() + } + } +} + +fileprivate extension DispatchSemaphore { + func waitOrThrow(timeout: DispatchTime) throws(TimingError) { + let result = self.wait(timeout: timeout) + switch result { + case .success: return + case .timedOut: throw .timeout + } + } + + enum TimingError: Error { + case timeout + } +} diff --git a/nostrdb/Test/NdbTests.swift b/nostrdb/Test/NdbTests.swift index 87c17b68..47426b00 100644 --- a/nostrdb/Test/NdbTests.swift +++ b/nostrdb/Test/NdbTests.swift @@ -63,15 +63,14 @@ final class NdbTests: XCTestCase { do { let ndb = Ndb(path: db_dir)! let id = NoteId(hex: "d12c17bde3094ad32f4ab862a6cc6f5c289cfe7d5802270bdf34904df585f349")! - guard let txn = NdbTxn(ndb: ndb) else { return XCTAssert(false) } - let note = ndb.lookup_note_and_copy(id) + let note = try? ndb.lookup_note_and_copy(id) XCTAssertNotNil(note) guard let note else { return } let pk = Pubkey(hex: "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")! XCTAssertEqual(note.pubkey, pk) - let profile = ndb.lookup_profile_and_copy(pk) - let lnurl = ndb.lookup_profile_lnurl(pk) + let profile = try? ndb.lookup_profile_and_copy(pk) + let lnurl = try? ndb.lookup_profile_lnurl(pk) XCTAssertNotNil(profile) guard let profile else { return } @@ -91,14 +90,14 @@ final class NdbTests: XCTestCase { do { let ndb = Ndb(path: db_dir)! - let note_ids = ndb.text_search(query: "barked") + let note_ids = (try? ndb.text_search(query: "barked")) ?? [] XCTAssertEqual(note_ids.count, 1) let expected_note_id = NoteId(hex: "b17a540710fe8495b16bfbaf31c6962c4ba8387f3284a7973ad523988095417e")! guard note_ids.count > 0 else { XCTFail("Expected at least one note to be found") return } - let note_id = ndb.lookup_note_by_key(note_ids[0], borrow: { maybeUnownedNote -> NoteId? in + let note_id = try? ndb.lookup_note_by_key(note_ids[0], borrow: { maybeUnownedNote -> NoteId? in switch maybeUnownedNote { case .none: return nil case .some(let unownedNote): return unownedNote.id