app

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

commit 9ab128dbc2040c969553ca47a89b24b9725c2ab0
parent ab73bde6138b0dbe53dfa9fb05f1941762b914f6
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 07:19:29 +0000

app: refresh shared local events on demand

- add a desktop runtime action for shared local event imports
- refresh app farmer and product projections from shared local records
- prefer saved farm setup state when resolving the active farm context
- cover cli-authored farm and listing records in runtime tests

Diffstat:
MCargo.lock | 2++
Mcrates/launchers/desktop/Cargo.toml | 2++
Mcrates/launchers/desktop/src/runtime.rs | 289+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 271 insertions(+), 22 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5069,10 +5069,12 @@ dependencies = [ "radroots_app_sync", "radroots_app_ui", "radroots_identity", + "radroots_local_events", "radroots_nostr", "radroots_nostr_accounts", "radroots_protected_store", "radroots_secret_vault", + "radroots_sql_core", "serde_json", "thiserror 2.0.18", "tracing", diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -31,6 +31,8 @@ tracing.workspace = true uuid.workspace = true [dev-dependencies] +radroots_local_events.workspace = true +radroots_sql_core.workspace = true tracing-subscriber.workspace = true [lints] diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -30,8 +30,9 @@ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; use radroots_app_sqlite::{ - derive_farm_rules_readiness, AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, - DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict, APP_ACTIVITY_CONTEXT_LIMIT, + APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSqliteError, AppSqliteStore, + BuyerRepeatDemandApplyOutcome, DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict, + derive_farm_rules_readiness, }; use radroots_app_state::{ derive_sync_projection, AppShellProjection, AppStateCommand, AppStatePersistenceRepository, @@ -606,8 +607,7 @@ impl DesktopAppRuntime { } pub fn sync_on_foreground_resume(&self) -> Result<bool, AppSqliteError> { - self.lock_state_mut() - .attempt_sync(SyncTrigger::ForegroundResume) + self.lock_state_mut().sync_on_foreground_resume() } pub fn sync_on_manual_refresh(&self) -> Result<bool, AppSqliteError> { @@ -615,6 +615,15 @@ impl DesktopAppRuntime { .attempt_sync(SyncTrigger::ManualRefresh) } + pub fn refresh_shared_local_events( + &self, + ) -> Result<AppLocalInteropImportReport, AppSqliteError> { + let mut state = self.lock_state_mut(); + let report = state.import_shared_local_events()?; + let _ = state.refresh_selected_account_context_after_local_events()?; + Ok(report) + } + pub fn resolve_sync_conflict( &self, conflict_id: &str, @@ -2460,6 +2469,7 @@ impl DesktopAppRuntimeState { projection: AppIdentityProjection, ) -> Result<bool, DesktopAppRuntimeCommandError> { let projection = self.decorate_identity_projection(projection)?; + let _ = self.import_shared_local_events()?; let continuity_state = self.continuity_state(); let selected_account_context = load_selected_account_context(self.sqlite_store()?, &projection, &continuity_state)?; @@ -2478,6 +2488,7 @@ impl DesktopAppRuntimeState { fn refresh_selected_account_context( &self, ) -> Result<DesktopSelectedAccountContext, DesktopAppRuntimeFarmSetupError> { + let _ = self.import_shared_local_events()?; let continuity_state = self.continuity_state(); Ok(load_selected_account_context( self.sqlite_store_for_farm_setup()?, @@ -3169,17 +3180,10 @@ impl DesktopAppRuntimeState { } fn selected_farm_id(&self) -> Option<FarmId> { - self.state_store - .identity_projection() - .selected_account - .as_ref() - .and_then(|account| account.farmer_activation.farm_id) - .or(self - .state_store - .farm_setup_projection() - .saved_farm - .as_ref() - .map(|farm| farm.farm_id)) + selected_farm_id_from_context( + self.state_store.identity_projection(), + self.state_store.farm_setup_projection(), + ) } fn has_saved_farm(&self) -> bool { @@ -3247,6 +3251,7 @@ impl DesktopAppRuntimeState { &self, query: &ProductsScreenQueryState, ) -> Result<ProductsListProjection, AppSqliteError> { + let _ = self.import_shared_local_events()?; let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(ProductsListProjection::default()); }; @@ -3257,6 +3262,44 @@ impl DesktopAppRuntimeState { sqlite_store.load_products(farm_id, &query.search_query, query.filter, query.sort) } + fn import_shared_local_events(&self) -> Result<AppLocalInteropImportReport, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(AppLocalInteropImportReport::default()); + }; + let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { + return Ok(AppLocalInteropImportReport::default()); + }; + let Some(database_path) = + shared_local_events_database_path_from_shared_accounts(shared_accounts_paths) + else { + return Ok(AppLocalInteropImportReport::default()); + }; + sqlite_store.import_shared_local_events_from_path(database_path.as_path()) + } + + fn refresh_selected_account_context_after_local_events( + &mut self, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let continuity_state = self.continuity_state(); + let identity_projection = self.state_store.identity_projection().clone(); + let selected_account_context = + load_selected_account_context(sqlite_store, &identity_projection, &continuity_state)?; + + Ok(self.apply_selected_account_context(&selected_account_context)) + } + + fn sync_on_foreground_resume(&mut self) -> Result<bool, AppSqliteError> { + let report = self.import_shared_local_events()?; + let local_changed = report.imported_records > 0 || report.skipped_records > 0; + let context_changed = self.refresh_selected_account_context_after_local_events()?; + let sync_changed = self.attempt_sync(SyncTrigger::ForegroundResume)?; + + Ok(local_changed || context_changed || sync_changed) + } + fn replace_orders_query( &mut self, query: OrdersScreenQueryState, @@ -3569,6 +3612,18 @@ fn shared_local_events_database_path( .join(SHARED_LOCAL_EVENTS_DB_FILE_NAME)) } +fn shared_local_events_database_path_from_shared_accounts( + paths: &AppSharedAccountsPaths, +) -> Option<PathBuf> { + Some( + paths + .data_root + .parent()? + .join(SHARED_LOCAL_EVENTS_DIR) + .join(SHARED_LOCAL_EVENTS_DB_FILE_NAME), + ) +} + fn load_selected_account_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, @@ -3647,13 +3702,7 @@ fn load_selected_account_context_with_options( }; let farm_setup_projection = sqlite_store.load_farm_setup(&selected_account.account.account_id)?; - let today_farm_id = selected_account - .farmer_activation - .farm_id - .or(farm_setup_projection - .saved_farm - .as_ref() - .map(|farm| farm.farm_id)); + let today_farm_id = selected_farm_id_from_context(identity_projection, &farm_setup_projection); let ( farm_rules_projection, mut today_projection, @@ -4631,6 +4680,22 @@ fn refresh_buyer_cart_totals(cart: &mut BuyerCartProjection) -> Result<(), AppSq Ok(()) } +fn selected_farm_id_from_context( + identity_projection: &AppIdentityProjection, + farm_setup_projection: &FarmSetupProjection, +) -> Option<FarmId> { + farm_setup_projection + .saved_farm + .as_ref() + .map(|farm| farm.farm_id) + .or_else(|| { + identity_projection + .selected_account + .as_ref() + .and_then(|account| account.farmer_activation.farm_id) + }) +} + fn fallback_farm_profile_for_projection( farm_id: FarmId, farm_setup_projection: &FarmSetupProjection, @@ -4882,10 +4947,16 @@ mod tests { SyncConflictSeverity, SyncOperationKind, SyncTrigger, }; use radroots_identity::RadrootsIdentity; + use radroots_local_events::{ + LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, + PublishOutboxStatus, SourceRuntime, + }; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, }; + use radroots_sql_core::SqliteExecutor; + use serde_json::json; use crate::accounts::DesktopLocalIdentityImportRequest; @@ -5300,6 +5371,65 @@ mod tests { } #[test] + fn runtime_shared_local_events_refresh_reports_and_reloads_products() { + let (runtime, paths) = bootstrapped_runtime("shared_local_events_refresh"); + assert!(runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate")); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + append_cli_local_listing_records(&paths, account_id.as_str()); + + let report = runtime + .refresh_shared_local_events() + .expect("shared local events should refresh"); + let summary = runtime.summary(); + + assert_eq!(report.scanned_records, 2); + assert_eq!(report.imported_records, 2); + assert_eq!(report.skipped_records, 0); + assert_eq!(summary.farm_setup_projection.draft.farm_name, "Green Farm"); + let saved_farm_id = summary + .farm_setup_projection + .saved_farm + .as_ref() + .expect("saved farm should import") + .farm_id; + let direct_products = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_products( + saved_farm_id, + "", + ProductsFilter::Drafts, + ProductsSort::default(), + ) + .expect("imported products should load directly"); + assert_eq!(direct_products.rows.len(), 1); + assert!(runtime + .select_products_filter(ProductsFilter::Drafts) + .expect("draft products filter should reload")); + let summary = runtime.summary(); + assert_eq!(summary.products_projection.list.rows.len(), 1); + assert_eq!(summary.products_projection.list.rows[0].title, "Eggs"); + assert_eq!( + summary.products_projection.list.rows[0].status, + ProductStatus::Draft + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_manual_refresh_marks_failed_checkpoint_when_transport_is_unavailable() { let runtime = memory_runtime(); let (account_id, _) = provision_ready_farmer_account(&runtime); @@ -9788,6 +9918,121 @@ mod tests { } } + fn append_cli_local_listing_records(paths: &AppDesktopRuntimePaths, account_id: &str) { + let database_path = + super::shared_local_events_database_path(paths).expect("shared local events path"); + if let Some(parent) = database_path.parent() { + fs::create_dir_all(parent).expect("shared local events directory should create"); + } + let executor = + SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); + let store = LocalEventsStore::new(executor); + store.migrate_up().expect("migrate shared local events"); + let farm_key = "AAAAAAAAAAAAAAAAAAAAAA"; + let listing_key = "BBBBBBBBBBBBBBBBBBBBBB"; + store + .append_record(&local_work_record( + "cli:local_work:farm", + account_id, + farm_key, + None, + json!({ + "record_kind": "farm_config_v1", + "document": { + "selection": { + "account": account_id, + "farm_d_tag": farm_key + }, + "profile": { + "name": "Green Farm", + "display_name": "Green Farm" + }, + "farm": { + "d_tag": farm_key, + "name": "Green Farm", + "location": { + "primary": "farmstand" + } + }, + "listing_defaults": { + "delivery_method": "pickup", + "location": { + "primary": "farmstand" + } + } + } + }), + )) + .expect("append farm local work"); + store + .append_record(&local_work_record( + "cli:local_work:listing", + account_id, + farm_key, + Some(format!("30402:seller-pubkey:{listing_key}")), + json!({ + "record_kind": "listing_draft_v1", + "document": { + "listing": { + "d_tag": listing_key, + "farm_d_tag": farm_key + }, + "seller_actor": { + "account_id": account_id, + "pubkey": "seller-pubkey" + }, + "product": { + "key": "eggs", + "title": "Eggs", + "summary": "Fresh eggs" + }, + "primary_bin": { + "quantity_unit": "each", + "price_amount": "6", + "price_currency": "USD" + }, + "inventory": { + "available": "10" + } + } + }), + )) + .expect("append listing local work"); + } + + fn local_work_record( + record_id: &str, + account_id: &str, + farm_key: &str, + listing_addr: Option<String>, + payload: serde_json::Value, + ) -> LocalEventRecordInput { + LocalEventRecordInput { + record_id: record_id.to_owned(), + family: LocalRecordFamily::LocalWork, + status: LocalRecordStatus::LocalSaved, + source_runtime: SourceRuntime::Cli, + created_at_ms: 1000, + inserted_at_ms: 1001, + owner_account_id: Some(account_id.to_owned()), + owner_pubkey: Some("seller-pubkey".to_owned()), + farm_id: Some(farm_key.to_owned()), + listing_addr, + local_work_json: Some(payload), + event_id: None, + event_kind: None, + event_pubkey: None, + event_created_at: None, + event_tags_json: None, + event_content: None, + event_sig: None, + raw_event_json: None, + outbox_status: PublishOutboxStatus::None, + relay_set_fingerprint: None, + relay_delivery_json: None, + } + } + fn fixture_pending_session() -> RadrootsAppRemoteSignerPendingSession { let signer_identity = RadrootsIdentity::from_secret_key_str( "1111111111111111111111111111111111111111111111111111111111111111",