app

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

commit c551ff5c6c674bf806ab49c0fde3c935f2191e2a
parent c0cdfa953f162f20c2f971c28073afe774981ef6
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:10:05 +0000

runtime: preserve local startup without relays

- open the local account store before relay availability
- project invalid packaged relay configuration as degraded
- keep actor snapshots and observer changes lifecycle-consistent
- cover empty local-first relay fallback without network access

Diffstat:
Mcore/crates/ffi/src/commands.rs | 46+++++++++++++++++++++++++++++++++++++++++-----
Mcore/crates/ffi/src/observer.rs | 3++-
2 files changed, 43 insertions(+), 6 deletions(-)

diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -8,7 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use radroots_studio_application::{ - Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayRuntimeMode, + Clock, DurableRequestId, GeneratedKeyRecoveryHandle, RelayConfiguration, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; @@ -185,18 +185,29 @@ pub(crate) struct RuntimeCore { BTreeMap<radroots_studio_application::ChangeSubscriptionId, tokio::task::JoinHandle<()>>, >, pub(crate) closed: AtomicBool, + pub(crate) startup_relay_problem: Option<SafeError>, } impl RuntimeCore { pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto { - AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.actor.lifecycle()) + AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.effective_lifecycle()) } pub(crate) fn dto_for( &self, snapshot: &radroots_studio_application::AppSnapshot, ) -> AppSnapshotDto { - AppSnapshotDto::from_runtime(snapshot, self.actor.lifecycle()) + AppSnapshotDto::from_runtime(snapshot, self.effective_lifecycle()) + } + + pub(crate) fn effective_lifecycle(&self) -> radroots_studio_application::RuntimeLifecycle { + let lifecycle = self.actor.lifecycle(); + match (lifecycle, self.startup_relay_problem) { + (radroots_studio_application::RuntimeLifecycle::Ready, Some(problem)) => { + radroots_studio_application::RuntimeLifecycle::Degraded(problem) + } + _ => lifecycle, + } } } @@ -484,7 +495,8 @@ impl StudioAppCore { } else { RelayRuntimeMode::Packaged }; - let relays = relay_configuration_from_environment(mode)?; + let (relays, startup_relay_problem) = + local_first_relay_configuration(relay_configuration_from_environment(mode)); let actor = RuntimeActorHandle::open( path, relays, @@ -499,11 +511,21 @@ impl StudioAppCore { actor, observers: Mutex::new(BTreeMap::new()), closed: AtomicBool::new(false), + startup_relay_problem, }), })) } } +fn local_first_relay_configuration( + configured: Result<RelayConfiguration, SafeError>, +) -> (RelayConfiguration, Option<SafeError>) { + match configured { + Ok(relays) => (relays, None), + Err(problem) => (RelayConfiguration::default(), Some(problem)), + } +} + #[derive(Clone, Copy)] pub(crate) struct SystemClock; @@ -596,6 +618,7 @@ mod tests { use std::sync::Arc; use radroots_studio_application::{InMemorySecretStore, RelayConfiguration, SdkNostrClient}; + use radroots_studio_domain::SafeError; use radroots_studio_storage::RuntimeActorHandle; use radroots_studio_storage::{CREDENTIAL_SERVICE, CURRENT_SCHEMA_VERSION}; @@ -604,7 +627,7 @@ mod tests { ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, SystemClock, - compatibility_descriptor, runtime, verify_compatibility, + compatibility_descriptor, local_first_relay_configuration, runtime, verify_compatibility, }; fn in_memory_core() -> Arc<StudioAppCore> { @@ -622,6 +645,7 @@ mod tests { actor, observers: std::sync::Mutex::new(std::collections::BTreeMap::new()), closed: std::sync::atomic::AtomicBool::new(false), + startup_relay_problem: None, }), }) } @@ -778,4 +802,16 @@ mod tests { assert!(!observer.contains(&forbidden)); } } + + #[test] + fn invalid_relay_configuration_preserves_local_startup_as_degraded() { + let problem = SafeError::new( + radroots_studio_domain::SafeErrorCode::InvalidRelayConfiguration, + radroots_studio_domain::SafeMessage::new("The Nostr relay configuration is invalid."), + ); + let (relays, degraded) = local_first_relay_configuration(Err(problem)); + + assert!(relays.relays().is_empty()); + assert_eq!(degraded, Some(problem)); + } } diff --git a/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs @@ -95,7 +95,7 @@ impl StudioAppCore { observer.on_change(SnapshotChangeDto { snapshot: AppSnapshotDto::from_runtime( change.snapshot(), - runtime_core.actor.lifecycle(), + runtime_core.effective_lifecycle(), ), previous_revision: change .previous_revision() @@ -207,6 +207,7 @@ mod tests { actor, observers: Mutex::new(std::collections::BTreeMap::new()), closed: std::sync::atomic::AtomicBool::new(false), + startup_relay_problem: None, }), }) }