notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

commit 1f54dc46b2d22252e171db48ea89524a7806e29d
parent b0d0bf5a2f17224ebfcbb4bedf7ee42e99254d4c
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 12:19:02 -0800

refactor: convert Notecrumbs methods to standalone functions for testability

Move fetch_if_missing, spawn_note_secondary_fetch, and
ensure_profile_feed from impl Notecrumbs methods to free functions
that take their dependencies as explicit parameters. This makes them
callable from tests without constructing the full Notecrumbs struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Msrc/main.rs | 298+++++++++++++++++++++++++++++++++++++++++++------------------------------------
1 file changed, 162 insertions(+), 136 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -204,160 +204,166 @@ where } } -impl Notecrumbs { - /// Fetch missing render data from relays, deduplicating concurrent requests - /// for the same nip19 so only one relay query fires at a time. - async fn fetch_if_missing(&self, render_data: &mut RenderData, nip19: &Nip19) { - let key = nip19_debounce_key(nip19); +/// Fetch missing render data from relays, deduplicating concurrent requests +/// for the same nip19 so only one relay query fires at a time. +async fn fetch_if_missing( + ndb: &Ndb, + relay_pool: &Arc<RelayPool>, + inflight: &Arc<DashMap<[u8; 32], Arc<tokio::sync::Notify>>>, + render_data: &mut RenderData, + nip19: &Nip19, +) { + let key = nip19_debounce_key(nip19); + + // Check if there's already an inflight fetch for this resource + let existing_notify = inflight.get(&key).map(|r| r.value().clone()); + + if let Some(notify) = existing_notify { + // Another request is already fetching — wait for it, then re-check ndb + notify.notified().await; + let txn = match Transaction::new(ndb) { + Ok(txn) => txn, + Err(err) => { + error!("failed to open transaction after inflight wait: {err}"); + return; + } + }; + if let Ok(new_rd) = render::get_render_data(ndb, &txn, nip19) { + *render_data = new_rd; + } + } else { + // We're the first — register inflight and do the fetch + let n = Arc::new(tokio::sync::Notify::new()); + inflight.insert(key, n.clone()); + + if let Err(err) = render_data + .complete(ndb.clone(), relay_pool.clone(), nip19.clone()) + .await + { + error!("Error fetching completion data: {err}"); + } - // Check if there's already an inflight fetch for this resource - let existing_notify = self.inflight.get(&key).map(|r| r.value().clone()); + // Signal waiters and remove inflight entry + inflight.remove(&key); + n.notify_waiters(); + } +} - if let Some(notify) = existing_notify { - // Another request is already fetching — wait for it, then re-check ndb - notify.notified().await; - let txn = match Transaction::new(&self.ndb) { - Ok(txn) => txn, - Err(err) => { - error!("failed to open transaction after inflight wait: {err}"); +/// Spawn a debounced background task to fetch secondary note data +/// (unknowns, stats, reply profiles). Skips if a fetch already ran +/// recently for this nip19 resource. +fn spawn_note_secondary_fetch( + ndb: &Ndb, + relay_pool: &Arc<RelayPool>, + note_refresh_state: &Arc<DashMap<[u8; 32], RefreshState>>, + nip19: &Nip19, + note_rd: &render::NoteAndProfileRenderData, +) { + let ndb = ndb.clone(); + let relay_pool = relay_pool.clone(); + let note_rd_bg = note_rd.note_rd.clone(); + let source_relays = note_rd.source_relays.clone(); + + try_spawn_debounced( + note_refresh_state, + nip19_debounce_key(nip19), + NOTE_REFRESH_INTERVAL, + |state_map, key| { + tokio::spawn(async move { + if let Err(err) = + fetch_note_secondary_data(&relay_pool, &ndb, &note_rd_bg, &source_relays).await + { + tracing::warn!("background note secondary fetch failed: {err}"); + state_map.remove(&key); return; } - }; - if let Ok(new_rd) = render::get_render_data(&self.ndb, &txn, nip19) { - *render_data = new_rd; - } - } else { - // We're the first — register inflight and do the fetch - let n = Arc::new(tokio::sync::Notify::new()); - self.inflight.insert(key, n.clone()); + state_map.insert(key, RefreshState::Completed(Instant::now())); + }) + }, + ); +} - if let Err(err) = render_data - .complete(self.ndb.clone(), self.relay_pool.clone(), nip19.clone()) - .await - { - error!("Error fetching completion data: {err}"); +/// Ensure profile feed data is available, fetching from relays if needed. +/// Uses debounced background refresh when cached data exists. +async fn ensure_profile_feed( + ndb: &Ndb, + relay_pool: &Arc<RelayPool>, + inflight: &Arc<DashMap<[u8; 32], Arc<tokio::sync::Notify>>>, + profile_refresh_state: &Arc<DashMap<[u8; 32], RefreshState>>, + profile_opt: &Option<ProfileRenderData>, +) -> Result<(), Error> { + let maybe_pubkey = { + let txn = Transaction::new(ndb)?; + match profile_opt { + Some(ProfileRenderData::Profile(profile_key)) => { + if let Ok(profile_rec) = ndb.get_profile_by_key(&txn, *profile_key) { + let note_key = NoteKey::new(profile_rec.record().note_key()); + ndb.get_note_by_key(&txn, note_key) + .ok() + .map(|note| *note.pubkey()) + } else { + None + } } - - // Signal waiters and remove inflight entry - self.inflight.remove(&key); - n.notify_waiters(); + Some(ProfileRenderData::Missing(pk)) => Some(*pk), + None => None, } - } + }; - /// Spawn a debounced background task to fetch secondary note data - /// (unknowns, stats, reply profiles). Skips if a fetch already ran - /// recently for this nip19 resource. - fn spawn_note_secondary_fetch( - &self, - nip19: &Nip19, - note_rd: &render::NoteAndProfileRenderData, - ) { - let ndb = self.ndb.clone(); - let relay_pool = self.relay_pool.clone(); - let note_rd_bg = note_rd.note_rd.clone(); - let source_relays = note_rd.source_relays.clone(); + let Some(pubkey) = maybe_pubkey else { + return Ok(()); + }; + let has_cached_notes = { + let txn = Transaction::new(ndb)?; + let notes_filter = Filter::new().authors([&pubkey]).kinds([1]).limit(1).build(); + ndb.query(&txn, &[notes_filter], 1) + .map(|results| !results.is_empty()) + .unwrap_or(false) + }; + + let pool = relay_pool.clone(); + let ndb = ndb.clone(); + + if has_cached_notes { try_spawn_debounced( - &self.note_refresh_state, - nip19_debounce_key(nip19), - NOTE_REFRESH_INTERVAL, + profile_refresh_state, + pubkey, + PROFILE_REFRESH_INTERVAL, |state_map, key| { tokio::spawn(async move { - if let Err(err) = - fetch_note_secondary_data(&relay_pool, &ndb, &note_rd_bg, &source_relays) - .await - { - tracing::warn!("background note secondary fetch failed: {err}"); - state_map.remove(&key); - return; + match render::fetch_profile_feed(pool, ndb, key).await { + Ok(()) => { + state_map.insert(key, RefreshState::Completed(Instant::now())); + } + Err(err) => { + error!("Background profile feed refresh failed: {err}"); + state_map.remove(&key); + } } - state_map.insert(key, RefreshState::Completed(Instant::now())); }) }, ); - } - - /// Ensure profile feed data is available, fetching from relays if needed. - /// Uses debounced background refresh when cached data exists. - async fn ensure_profile_feed( - &self, - profile_opt: &Option<ProfileRenderData>, - ) -> Result<(), Error> { - let maybe_pubkey = { - let txn = Transaction::new(&self.ndb)?; - match profile_opt { - Some(ProfileRenderData::Profile(profile_key)) => { - if let Ok(profile_rec) = self.ndb.get_profile_by_key(&txn, *profile_key) { - let note_key = NoteKey::new(profile_rec.record().note_key()); - self.ndb - .get_note_by_key(&txn, note_key) - .ok() - .map(|note| *note.pubkey()) - } else { - None - } - } - Some(ProfileRenderData::Missing(pk)) => Some(*pk), - None => None, - } - }; - - let Some(pubkey) = maybe_pubkey else { - return Ok(()); - }; - - let has_cached_notes = { - let txn = Transaction::new(&self.ndb)?; - let notes_filter = Filter::new().authors([&pubkey]).kinds([1]).limit(1).build(); - self.ndb - .query(&txn, &[notes_filter], 1) - .map(|results| !results.is_empty()) - .unwrap_or(false) - }; + } else { + // No cached data: must wait for relay fetch before rendering. + // Use inflight dedup so concurrent requests for the same profile + // don't each fire their own relay queries. + let existing_notify = inflight.get(&pubkey).map(|r| r.value().clone()); - let pool = self.relay_pool.clone(); - let ndb = self.ndb.clone(); - - if has_cached_notes { - try_spawn_debounced( - &self.profile_refresh_state, - pubkey, - PROFILE_REFRESH_INTERVAL, - |state_map, key| { - tokio::spawn(async move { - match render::fetch_profile_feed(pool, ndb, key).await { - Ok(()) => { - state_map.insert(key, RefreshState::Completed(Instant::now())); - } - Err(err) => { - error!("Background profile feed refresh failed: {err}"); - state_map.remove(&key); - } - } - }) - }, - ); + if let Some(notify) = existing_notify { + notify.notified().await; } else { - // No cached data: must wait for relay fetch before rendering. - // Use inflight dedup so concurrent requests for the same profile - // don't each fire their own relay queries. - let existing_notify = self.inflight.get(&pubkey).map(|r| r.value().clone()); - - if let Some(notify) = existing_notify { - notify.notified().await; - } else { - let n = Arc::new(tokio::sync::Notify::new()); - self.inflight.insert(pubkey, n.clone()); - if let Err(err) = render::fetch_profile_feed(pool, ndb, pubkey).await { - error!("Error fetching profile feed: {err}"); - } - self.inflight.remove(&pubkey); - n.notify_waiters(); + let n = Arc::new(tokio::sync::Notify::new()); + inflight.insert(pubkey, n.clone()); + if let Err(err) = render::fetch_profile_feed(pool, ndb, pubkey).await { + error!("Error fetching profile feed: {err}"); } + inflight.remove(&pubkey); + n.notify_waiters(); } - - Ok(()) } + + Ok(()) } /// Background task: fetch all secondary data for a note (unknowns, stats, reply profiles). @@ -496,17 +502,37 @@ async fn serve( // Fetch missing note/profile data from relays (deduplicated across concurrent requests) if !render_data.is_complete() { - app.fetch_if_missing(&mut render_data, &nip19).await; + fetch_if_missing( + &app.ndb, + &app.relay_pool, + &app.inflight, + &mut render_data, + &nip19, + ) + .await; } // Spawn debounced background fetch for secondary note data (unknowns, stats, replies) if let RenderData::Note(note_rd) = &render_data { - app.spawn_note_secondary_fetch(&nip19, note_rd); + spawn_note_secondary_fetch( + &app.ndb, + &app.relay_pool, + &app.note_refresh_state, + &nip19, + note_rd, + ); } // Ensure profile feed data is available (debounced background refresh or blocking fetch) if let RenderData::Profile(profile_opt) = &render_data { - app.ensure_profile_feed(profile_opt).await?; + ensure_profile_feed( + &app.ndb, + &app.relay_pool, + &app.inflight, + &app.profile_refresh_state, + profile_opt, + ) + .await?; } if is_png {