app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 0d50e6869502fa49dc8b56185aeb9d072df3db47
parent a72fad6fc8e66e078b91842c0a7e1bbf8a476aa2
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:26:04 +0000

core(profile): orchestrate cache-first refresh

- publish cached metadata before asynchronous relay loading
- persist verified fresh profiles before updating public state
- preserve cached data through offline and invalid-data failures
- discard stale refresh completion after session cancellation

Diffstat:
Mcore/crates/application/Cargo.toml | 2+-
Mcore/crates/application/src/lib.rs | 1+
Acore/crates/application/src/profile_refresh.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/application/src/state_machine.rs | 36++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
5 files changed, 407 insertions(+), 2 deletions(-)

diff --git a/core/crates/application/Cargo.toml b/core/crates/application/Cargo.toml @@ -15,7 +15,7 @@ secrecy.workspace = true [dev-dependencies] nostr-relay-builder.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } [lints] workspace = true diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -5,6 +5,7 @@ pub mod app_core; pub mod config; pub mod nostr_client; pub mod ports; +mod profile_refresh; pub mod recovery; pub mod secrets; pub mod session; diff --git a/core/crates/application/src/profile_refresh.rs b/core/crates/application/src/profile_refresh.rs @@ -0,0 +1,368 @@ +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode}; + +use crate::{ + ActiveAccountSnapshot, AppCore, AppSnapshot, CachedProfile, Clock, NostrClient, + ProfileLoadState, ProfileRefreshStatus, ProfileRepository, RelayConnectionState, + StateTransition, +}; + +impl AppCore { + /// Refreshes the current active account while retaining any cached profile. + /// + /// Stale results are discarded when the account is replaced or signed out. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error. Relay and invalid-data + /// failures are represented as nonfatal snapshot state. + pub async fn refresh_profile_for_active_account( + &self, + profiles: &(impl ProfileRepository + ?Sized), + client: &(impl NostrClient + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + let Some(active) = self.snapshot().active_account().cloned() else { + return Ok(self.snapshot()); + }; + let public_key = active.account().public_key(); + self.apply_transition(StateTransition::UpdateActiveAccount { + expected: public_key, + active_account: Box::new(ActiveAccountSnapshot::new( + active.account().clone(), + RelayConnectionState::Connecting, + ProfileLoadState::Loading, + active.profile().cloned(), + )), + problem: None, + })?; + + let result = client + .fetch_profile(public_key, self.snapshot().relay_configuration().relays()) + .await; + if !is_current_active(self, public_key) { + return Ok(self.snapshot()); + } + + match result { + Ok(Some(candidate)) => { + let cached = CachedProfile::new( + candidate.clone(), + clock.now(), + ProfileRefreshStatus::Success, + ); + profiles.save_profile(&cached)?; + self.apply_transition(StateTransition::UpdateActiveAccount { + expected: public_key, + active_account: Box::new(ActiveAccountSnapshot::new( + active.account().clone(), + RelayConnectionState::Connected, + ProfileLoadState::Fresh, + Some(candidate.metadata().clone()), + )), + problem: None, + }) + } + Ok(None) => self.apply_transition(StateTransition::UpdateActiveAccount { + expected: public_key, + active_account: Box::new(ActiveAccountSnapshot::new( + active.account().clone(), + RelayConnectionState::Connected, + if active.profile().is_some() { + ProfileLoadState::Cached + } else { + ProfileLoadState::Empty + }, + active.profile().cloned(), + )), + problem: None, + }), + Err(error) => { + let status = refresh_status(error); + profiles.record_refresh_status(public_key, clock.now(), status)?; + self.apply_transition(StateTransition::UpdateActiveAccount { + expected: public_key, + active_account: Box::new(ActiveAccountSnapshot::new( + active.account().clone(), + RelayConnectionState::Degraded, + ProfileLoadState::Error(error), + active.profile().cloned(), + )), + problem: Some(error), + }) + } + } + } +} + +fn is_current_active(core: &AppCore, public_key: PublicKey) -> bool { + core.snapshot() + .active_account() + .is_some_and(|active| active.account().public_key() == public_key) +} + +const fn refresh_status(error: SafeError) -> ProfileRefreshStatus { + match error.code() { + SafeErrorCode::InvalidProfileMetadata | SafeErrorCode::ProfileRefreshFailed => { + ProfileRefreshStatus::InvalidData + } + _ => ProfileRefreshStatus::Offline, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError, + SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, + }; + + use crate::{ + AppCore, AppObserver, BoxFuture, CachedProfile, Clock, InMemoryAccountRepository, + InMemoryOperationJournal, InMemorySecretStore, NostrClient, ProfileLoadState, + ProfileRefreshStatus, ProfileRepository, RelayConfiguration, RelayConnectionState, + }; + + #[derive(Default)] + struct MemoryProfiles(Mutex<Option<CachedProfile>>); + + impl ProfileRepository for MemoryProfiles { + fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + Ok(self.0.lock().expect("profiles").clone()) + } + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { + *self.0.lock().expect("profiles") = Some(profile.clone()); + Ok(()) + } + fn record_refresh_status( + &self, + _public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + if let Some(profile) = self.0.lock().expect("profiles").as_mut() { + *profile = CachedProfile::new(profile.candidate().clone(), refreshed_at, status); + } + Ok(()) + } + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + *self.0.lock().expect("profiles") = None; + Ok(()) + } + } + + struct FixedClock; + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(50).expect("time") + } + } + + struct FixedClient(Result<Option<Kind0ProfileCandidate>, SafeError>); + impl NostrClient for FixedClient { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + let result = self.0.clone(); + Box::pin(async move { result }) + } + } + + struct BlockingClient { + started: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, + result: Result<Option<Kind0ProfileCandidate>, SafeError>, + } + + impl BlockingClient { + fn new(result: Result<Option<Kind0ProfileCandidate>, SafeError>) -> Self { + Self { + started: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + result, + } + } + } + + impl NostrClient for BlockingClient { + fn fetch_profile<'a>( + &'a self, + _public_key: PublicKey, + _relays: &'a [RelayUrl], + ) -> BoxFuture<'a, Result<Option<Kind0ProfileCandidate>, SafeError>> { + Box::pin(async move { + self.started.add_permits(1); + let permit = self.release.acquire().await.expect("release open"); + permit.forget(); + self.result.clone() + }) + } + } + + #[derive(Default)] + struct States(Mutex<Vec<(ProfileLoadState, RelayConnectionState)>>); + impl AppObserver for States { + fn on_snapshot_changed(&self, snapshot: crate::AppSnapshot) { + if let Some(active) = snapshot.active_account() { + self.0 + .lock() + .expect("states") + .push((active.profile_state(), active.relay_state())); + } + } + } + + fn profile(public_key: PublicKey, name: &str, timestamp: i64) -> Kind0ProfileCandidate { + Kind0ProfileCandidate::new( + EventId::from_bytes([u8::try_from(timestamp).expect("small timestamp"); 32]), + public_key, + UnixTimestamp::from_seconds(timestamp).expect("time"), + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("profile"), + ) + } + + fn active_core(profiles: &MemoryProfiles, cached_name: Option<&str>) -> (AppCore, PublicKey) { + let relays = + RelayConfiguration::new(vec![RelayUrl::parse("ws://localhost:8080").expect("relay")]); + let core = AppCore::in_memory(relays); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let public_key = core + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("import") + .account() + .public_key(); + if let Some(name) = cached_name { + profiles + .save_profile(&CachedProfile::new( + profile(public_key, name, 10), + UnixTimestamp::from_seconds(11).expect("time"), + ProfileRefreshStatus::Success, + )) + .expect("cache"); + } + core.activate_account( + public_key, + &accounts, + &accounts, + profiles, + &secrets, + &FixedClock, + ) + .expect("activate"); + (core, public_key) + } + + #[tokio::test] + async fn refresh_emits_cache_before_loading_and_fresh_profile() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let states = Arc::new(States::default()); + core.subscribe(states.clone()).expect("observer"); + core.refresh_profile_for_active_account( + &profiles, + &FixedClient(Ok(Some(profile(public_key, "Fresh", 20)))), + &FixedClock, + ) + .await + .expect("refresh"); + + let observed = states.0.lock().expect("states"); + assert_eq!( + observed.first().map(|state| state.0), + Some(ProfileLoadState::Cached) + ); + assert!(observed.contains(&(ProfileLoadState::Loading, RelayConnectionState::Connecting))); + assert_eq!( + observed.last(), + Some(&(ProfileLoadState::Fresh, RelayConnectionState::Connected)) + ); + assert_eq!( + core.snapshot() + .active_account() + .and_then(|active| active.profile()) + .and_then(ProfileMetadata::name), + Some("Fresh") + ); + } + + #[tokio::test] + async fn refresh_failure_preserves_cached_profile_as_nonfatal_state() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let cached = profile(public_key, "Cached", 10); + let error = SafeError::new( + SafeErrorCode::RelayConnectionFailed, + SafeMessage::new("The relay is offline."), + ); + + let snapshot = core + .refresh_profile_for_active_account(&profiles, &FixedClient(Err(error)), &FixedClock) + .await + .expect("nonfatal refresh"); + + assert_eq!(snapshot.recoverable_problem(), Some(error)); + assert_eq!( + snapshot + .active_account() + .map(crate::ActiveAccountSnapshot::relay_state), + Some(RelayConnectionState::Degraded) + ); + assert_eq!( + profiles + .load_profile(public_key) + .expect("load") + .expect("cache") + .candidate(), + &cached + ); + } + + #[tokio::test] + async fn refresh_discards_stale_completion_after_sign_out() { + let profiles = MemoryProfiles::default(); + let (core, public_key) = active_core(&profiles, Some("Cached")); + let client = BlockingClient::new(Ok(Some(profile(public_key, "Stale", 20)))); + + let refresh = core.refresh_profile_for_active_account(&profiles, &client, &FixedClock); + let sign_out = async { + let permit = client.started.acquire().await.expect("refresh starts"); + permit.forget(); + core.sign_out().expect("sign out"); + client.release.add_permits(1); + }; + let (result, ()) = tokio::join!(refresh, sign_out); + + assert!( + result + .expect("stale result is harmless") + .active_account() + .is_none() + ); + assert_eq!( + profiles + .load_profile(public_key) + .expect("load") + .expect("cached") + .candidate() + .metadata() + .name(), + Some("Cached") + ); + } +} diff --git a/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs @@ -22,6 +22,11 @@ pub enum StateTransition { BeginActivation(PublicKey), ActivationSucceeded(Box<ActiveAccountSnapshot>), ActivationFailed(SafeError), + UpdateActiveAccount { + expected: PublicKey, + active_account: Box<ActiveAccountSnapshot>, + problem: Option<SafeError>, + }, SignOut, SetProblem(Option<SafeError>), } @@ -92,6 +97,11 @@ impl StateMachine { StateTransition::ActivationFailed(problem) => { self.activation_failed(next_revision, problem)? } + StateTransition::UpdateActiveAccount { + expected, + active_account, + problem, + } => self.update_active_account(next_revision, expected, *active_account, problem)?, StateTransition::SignOut => self.sign_out(next_revision)?, StateTransition::SetProblem(problem) => self.copy_ready( next_revision, @@ -271,6 +281,32 @@ impl StateMachine { ) } + fn update_active_account( + &self, + revision: crate::SnapshotRevision, + expected: PublicKey, + active_account: ActiveAccountSnapshot, + problem: Option<SafeError>, + ) -> Result<AppSnapshot, SafeError> { + if !matches!(self.snapshot.session(), SessionState::Active) + || self + .snapshot + .active_account() + .map(|active| active.account().public_key()) + != Some(expected) + || active_account.account().public_key() != expected + { + return Err(invalid_application_state()); + } + self.copy_ready( + revision, + self.snapshot.selected_account(), + SessionState::Active, + Some(active_account), + problem, + ) + } + fn require_account(&self, public_key: PublicKey) -> Result<(), SafeError> { if self .snapshot diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -516,7 +516,7 @@ handoff commit sequence. - [x] 36. Add relay configuration source and environment parser. - [x] 37. Implement Nostr event verification and kind-0 parsing adapter. - [x] 38. Add Nostr client port implementation and local relay fixture. -- [ ] 39. Implement cache-first active profile refresh orchestration. +- [x] 39. Implement cache-first active profile refresh orchestration. - [ ] 40. Expose manual refreshActiveProfile command. ### RCLD-09