app

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

commit 0c8c5faae96f808d30f9d666c7b4dfd63c9a8e75
parent 14b6142ef53677ae1f035200faecf653bea892f2
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 04:50:48 +0000

persist restart continuity across shipped shell

Diffstat:
MCargo.lock | 3+++
Mcrates/launchers/desktop/src/runtime.rs | 837++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/shared/state/Cargo.toml | 3+++
Mcrates/shared/state/src/lib.rs | 556+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
4 files changed, 1056 insertions(+), 343 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5119,7 +5119,10 @@ version = "0.1.0" dependencies = [ "radroots_app_models", "radroots_app_sync", + "serde", + "serde_json", "thiserror 2.0.18", + "tracing", ] [[package]] diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -28,15 +28,14 @@ use radroots_app_remote_signer::{ }; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, - DatabaseTarget, - StoredPendingSyncOperation, StoredSyncConflict, derive_farm_rules_readiness, + DatabaseTarget, StoredPendingSyncOperation, StoredSyncConflict, derive_farm_rules_readiness, }; use radroots_app_state::{ - AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, - BuyerBrowseScreenProjection, BuyerCartScreenProjection, BuyerOrdersScreenProjection, - BuyerSearchScreenProjection, BuyerSearchScreenQueryState, FarmSetupFlowStage, - FarmWorkspaceReadinessProjection, HomeRoute, InMemoryAppStateRepository, - OrdersScreenProjection, PackDayScreenProjection, PersonalWorkspaceProjection, + APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository, + AppStateStore, AppStateStoreError, BuyerBrowseScreenProjection, BuyerCartScreenProjection, + BuyerOrdersScreenProjection, BuyerSearchScreenProjection, BuyerSearchScreenQueryState, + FarmSetupFlowStage, FarmWorkspaceReadinessProjection, HomeRoute, OrdersScreenProjection, + PackDayScreenProjection, PersistedAppState, PersonalWorkspaceProjection, ProductsScreenProjection, ProductsScreenQueryState, derive_sync_projection, }; use radroots_app_sync::{ @@ -726,12 +725,16 @@ struct DesktopSelectedAccountContext { farm_setup_projection: FarmSetupProjection, farm_rules_projection: FarmRulesProjection, today_projection: TodayAgendaProjection, + products_query: ProductsScreenQueryState, products_list: ProductsListProjection, + orders_query: OrdersScreenQueryState, orders_list: OrdersListProjection, orders_reminders: ReminderFeedProjection, recovery_queue: RecoveryQueueProjection, order_detail: Option<OrderDetailProjection>, + pack_day_query: PackDayScreenQueryState, pack_day_projection: PackDayProjection, + product_editor_draft: Option<(ProductId, ProductEditorDraft)>, reminder_log: ReminderLogProjection, } @@ -771,7 +774,7 @@ struct DesktopPreparedSyncRequest { } struct DesktopAppRuntimeState { - state_store: AppStateStore<InMemoryAppStateRepository>, + state_store: AppStateStore<AppStatePersistenceRepository>, default_nostr_relay_url: String, shared_accounts_paths: Option<AppSharedAccountsPaths>, remote_signer_paths: Option<DesktopRemoteSignerPaths>, @@ -837,7 +840,10 @@ impl DesktopAppRuntimeState { let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; let database_schema_version = sqlite_store.schema_version()?; - let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; + let mut state_store = AppStateStore::load(AppStatePersistenceRepository::file_backed( + paths.app.data.join(APP_STATE_FILE_NAME), + ))?; + let continuity_state = state_store.persisted_state().clone(); let remote_signer_paths = DesktopRemoteSignerPaths::from_runtime_paths(&paths); let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?; if let Some(accounts_manager) = accounts_bootstrap.accounts_manager.as_ref() { @@ -853,18 +859,8 @@ impl DesktopAppRuntimeState { )?, &remote_signer_paths, )?; - let selected_account_context = load_selected_account_context( - &sqlite_store, - &identity_projection, - state_store.products_projection().query.clone(), - state_store.orders_projection().query.clone(), - state_store - .orders_projection() - .detail - .as_ref() - .map(|detail| detail.order_id), - state_store.pack_day_projection().query.clone(), - )?; + let selected_account_context = + load_selected_account_context(&sqlite_store, &identity_projection, &continuity_state)?; let selected_account_sync_context = load_selected_account_sync_context(&sqlite_store, &identity_projection)?; let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( @@ -875,46 +871,12 @@ impl DesktopAppRuntimeState { { let _ = state_store.apply_in_memory(AppStateCommand::show_startup_signer_entry()); } - let _ = state_store.apply_in_memory(AppStateCommand::replace_personal_projection( - selected_account_context.personal_projection.clone(), - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_rules_projection( - selected_account_context.farm_rules_projection, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_setup_projection( - selected_account_context.farm_setup_projection, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_today_agenda( - selected_account_context.today_projection, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_products_list( - selected_account_context.products_list, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_list( - selected_account_context.orders_list, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_reminders( - selected_account_context.orders_reminders, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_orders_recovery_queue( - selected_account_context.recovery_queue, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_reminder_log( - selected_account_context.reminder_log, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_order_detail( - selected_account_context.order_detail, - )); - let _ = state_store.apply_in_memory(AppStateCommand::replace_pack_day_projection( - selected_account_context.pack_day_projection, - )); let pending_sync_write_count = selected_account_sync_context.pending_write_count; let selected_account_sync_conflicts = selected_account_sync_context.conflicts; let _ = state_store.apply_in_memory(AppStateCommand::replace_sync_projection( selected_account_sync_context.projection, )); - - Ok(Self { + let mut state = Self { state_store, default_nostr_relay_url, shared_accounts_paths: Some(paths.shared_accounts.clone()), @@ -931,7 +893,10 @@ impl DesktopAppRuntimeState { selected_account_pending_sync_write_count: pending_sync_write_count, selected_account_sync_conflicts, startup_issue: None, - }) + }; + let _ = state.apply_selected_account_context(&selected_account_context); + + Ok(state) } fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self { @@ -943,7 +908,8 @@ impl DesktopAppRuntimeState { runtime_snapshot: AppRuntimeSnapshot, ) -> Self { Self { - state_store: AppStateStore::in_memory(AppShellProjection::default()), + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) + .expect("in-memory state store should load"), default_nostr_relay_url: String::new(), shared_accounts_paths: None, remote_signer_paths: None, @@ -1393,8 +1359,8 @@ impl DesktopAppRuntimeState { Ok(personal_changed || section_changed) } - BuyerRepeatDemandApplyOutcome::ConfirmationRequired(replace_confirmation) => { - Ok(self.mutate_personal_projection(|projection| { + BuyerRepeatDemandApplyOutcome::ConfirmationRequired(replace_confirmation) => Ok(self + .mutate_personal_projection(|projection| { let cart = &mut projection.cart.cart; if cart.replace_confirmation.as_ref() == Some(&replace_confirmation) { return false; @@ -1402,8 +1368,7 @@ impl DesktopAppRuntimeState { cart.replace_confirmation = Some(replace_confirmation); true - })) - } + })), BuyerRepeatDemandApplyOutcome::Unavailable => Ok(false), } } @@ -1549,13 +1514,11 @@ impl DesktopAppRuntimeState { let Some(_) = sqlite_store.load_order_detail(farm_id, order_id)? else { return Ok(false); }; + let continuity_state = self.continuity_state_with_order_detail(Some(order_id)); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - Some(order_id), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let detail_changed = self.apply_selected_account_context(&selected_account_context); let section_changed = self @@ -1581,13 +1544,12 @@ impl DesktopAppRuntimeState { return Ok(false); } + let continuity_state = + self.continuity_state_with_order_detail(self.selected_order_detail_id()); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let pending_changed = @@ -1612,13 +1574,12 @@ impl DesktopAppRuntimeState { return Ok(false); } + let continuity_state = + self.continuity_state_with_order_detail(self.selected_order_detail_id()); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let pending_changed = @@ -1723,13 +1684,12 @@ impl DesktopAppRuntimeState { record.last_updated_at = last_updated_at; sqlite_store.save_recovery_record(account_id, farm_id, &record)?; + let continuity_state = self + .continuity_state_with_order_detail(self.selected_order_detail_id().or(Some(order_id))); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id().or(Some(order_id)), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let pending_changed = @@ -1791,13 +1751,12 @@ impl DesktopAppRuntimeState { return Ok(false); } + let continuity_state = + self.continuity_state_with_order_detail(self.selected_order_detail_id()); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let pending_changed = @@ -1828,13 +1787,11 @@ impl DesktopAppRuntimeState { let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { return Ok(false); }; + let continuity_state = self.continuity_state(); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); let section_changed = self.select_farmer_section(FarmerSection::Products); @@ -1902,13 +1859,11 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; + let continuity_state = self.continuity_state(); load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )? }; let reloaded_draft = { @@ -2070,13 +2025,11 @@ impl DesktopAppRuntimeState { let selected_account_context = { let sqlite_store = self.sqlite_store_for_farm_rules()?; + let continuity_state = self.continuity_state(); load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )? }; self.apply_selected_account_context(&selected_account_context); @@ -2130,14 +2083,9 @@ impl DesktopAppRuntimeState { projection: AppIdentityProjection, ) -> Result<bool, DesktopAppRuntimeCommandError> { let projection = self.decorate_identity_projection(projection)?; - let selected_account_context = load_selected_account_context( - self.sqlite_store()?, - &projection, - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), - )?; + let continuity_state = self.continuity_state(); + let selected_account_context = + load_selected_account_context(self.sqlite_store()?, &projection, &continuity_state)?; let selected_account_sync_context = load_selected_account_sync_context(self.sqlite_store()?, &projection)?; let identity_changed = self @@ -2153,13 +2101,11 @@ impl DesktopAppRuntimeState { fn refresh_selected_account_context( &self, ) -> Result<DesktopSelectedAccountContext, DesktopAppRuntimeFarmSetupError> { + let continuity_state = self.continuity_state(); Ok(load_selected_account_context( self.sqlite_store_for_farm_setup()?, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?) } @@ -2202,11 +2148,36 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_today_agenda( context.today_projection.clone(), )); + let products_query_changed = + self.state_store + .apply_in_memory(AppStateCommand::set_products_search_query( + context.products_query.search_query.clone(), + )) + || self + .state_store + .apply_in_memory(AppStateCommand::select_products_filter( + context.products_query.filter, + )) + || self + .state_store + .apply_in_memory(AppStateCommand::select_products_sort( + context.products_query.sort, + )); let products_changed = self.state_store .apply_in_memory(AppStateCommand::replace_products_list( context.products_list.clone(), )); + let orders_query_changed = + self.state_store + .apply_in_memory(AppStateCommand::select_orders_filter( + context.orders_query.filter, + )) + || self.state_store.apply_in_memory( + AppStateCommand::select_orders_fulfillment_window( + context.orders_query.fulfillment_window_id, + ), + ); let orders_changed = self.state_store .apply_in_memory(AppStateCommand::replace_orders_list( @@ -2237,23 +2208,36 @@ impl DesktopAppRuntimeState { .apply_in_memory(AppStateCommand::replace_pack_day_projection( context.pack_day_projection.clone(), )); - let editor_changed = if context.farm_setup_projection.has_saved_farm() { - false - } else { - self.close_product_editor() - }; + let pack_day_query_changed = + self.state_store + .apply_in_memory(AppStateCommand::set_pack_day_fulfillment_window( + context.pack_day_query.fulfillment_window_id, + )); + let editor_changed = + if let Some((product_id, draft)) = context.product_editor_draft.as_ref() { + self.state_store + .apply_in_memory(AppStateCommand::open_existing_product_editor( + *product_id, + draft.clone(), + )) + } else { + self.close_product_editor() + }; let shell_changed = self.sync_truthful_farmer_section(); personal_changed || farm_setup_changed || farm_rules_changed || today_changed + || products_query_changed || products_changed + || orders_query_changed || orders_changed || orders_reminders_changed || recovery_queue_changed || reminder_log_changed || order_detail_changed + || pack_day_query_changed || pack_day_changed || editor_changed || shell_changed @@ -2293,13 +2277,11 @@ impl DesktopAppRuntimeState { let sync_changed = self.apply_selected_account_sync_context(&context); let selected_account_changed = match self.sqlite_store.as_ref() { Some(sqlite_store) => { + let continuity_state = self.continuity_state(); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; self.apply_selected_account_seller_context(&selected_account_context) } @@ -2399,13 +2381,11 @@ impl DesktopAppRuntimeState { &[reminder_log_entry], )?; + let continuity_state = self.continuity_state(); let selected_account_context = load_selected_account_context_with_options( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - self.state_store.pack_day_projection().query.clone(), + &continuity_state, false, )?; @@ -2847,6 +2827,36 @@ impl DesktopAppRuntimeState { .map(|detail| detail.order_id) } + fn continuity_state(&self) -> PersistedAppState { + self.state_store.persisted_state().clone() + } + + fn continuity_state_with_order_detail(&self, order_id: Option<OrderId>) -> PersistedAppState { + let mut state = self.continuity_state(); + state.seller.order_detail_order_id = order_id; + state + } + + fn continuity_state_with_orders_query( + &self, + query: OrdersScreenQueryState, + order_id: Option<OrderId>, + ) -> PersistedAppState { + let mut state = self.continuity_state(); + state.seller.orders_query = query; + state.seller.order_detail_order_id = order_id; + state + } + + fn continuity_state_with_pack_day_query( + &self, + query: PackDayScreenQueryState, + ) -> PersistedAppState { + let mut state = self.continuity_state(); + state.seller.pack_day_query = query; + state + } + fn fallback_farm_profile(&self, farm_id: FarmId) -> FarmProfileRecord { fallback_farm_profile_for_projection(farm_id, self.state_store.farm_setup_projection()) } @@ -2880,13 +2890,11 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(filter_changed || fulfillment_window_changed); }; + let continuity_state = self.continuity_state_with_orders_query(query, None); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - query, - None, - self.state_store.pack_day_projection().query.clone(), + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); @@ -2905,13 +2913,11 @@ impl DesktopAppRuntimeState { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(fulfillment_window_changed); }; + let continuity_state = self.continuity_state_with_pack_day_query(query); let selected_account_context = load_selected_account_context( sqlite_store, self.state_store.identity_projection(), - self.state_store.products_projection().query.clone(), - self.state_store.orders_projection().query.clone(), - self.selected_order_detail_id(), - query, + &continuity_state, )?; let context_changed = self.apply_selected_account_context(&selected_account_context); @@ -3079,18 +3085,12 @@ enum DesktopAppRuntimeBootstrapError { fn load_selected_account_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, - products_query: ProductsScreenQueryState, - orders_query: OrdersScreenQueryState, - selected_order_id: Option<OrderId>, - pack_day_query: PackDayScreenQueryState, + continuity_state: &PersistedAppState, ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { load_selected_account_context_with_options( sqlite_store, identity_projection, - products_query, - orders_query, - selected_order_id, - pack_day_query, + continuity_state, true, ) } @@ -3098,27 +3098,41 @@ fn load_selected_account_context( fn load_selected_account_context_with_options( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, - products_query: ProductsScreenQueryState, - orders_query: OrdersScreenQueryState, - selected_order_id: Option<OrderId>, - pack_day_query: PackDayScreenQueryState, + continuity_state: &PersistedAppState, allow_auto_present: bool, ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { let buyer_context = identity_projection.buyer_context(); - let buyer_fulfillment_methods = BTreeSet::new(); - let buyer_listings = sqlite_store.load_buyer_listings("", &buyer_fulfillment_methods)?; + let browse_fulfillment_methods = BTreeSet::new(); + let browse_listings = sqlite_store.load_buyer_listings("", &browse_fulfillment_methods)?; + let search_query = continuity_state.buyer.search_query.clone(); + let search_listings = sqlite_store.load_buyer_listings( + &search_query.search_query, + &search_query.fulfillment_methods, + )?; + let browse_detail = match continuity_state.buyer.browse_detail_product_id { + Some(product_id) => sqlite_store.load_buyer_product_detail(product_id)?, + None => None, + }; + let search_detail = match continuity_state.buyer.search_detail_product_id { + Some(product_id) => sqlite_store.load_buyer_product_detail(product_id)?, + None => None, + }; let buyer_cart = sqlite_store.load_buyer_cart(&buyer_context)?; let buyer_checkout = sqlite_store.load_buyer_checkout(&buyer_context)?; let buyer_orders = sqlite_store.load_buyer_orders(&buyer_context)?; + let buyer_order_detail = match continuity_state.buyer.orders_detail_order_id { + Some(order_id) => sqlite_store.load_buyer_order_detail(&buyer_context, order_id)?, + None => None, + }; let personal_projection = PersonalWorkspaceProjection { browse: BuyerBrowseScreenProjection { - listings: buyer_listings.clone(), - detail: None, + listings: browse_listings, + detail: browse_detail, }, search: BuyerSearchScreenProjection { - query: BuyerSearchScreenQueryState::default(), - listings: buyer_listings, - detail: None, + query: search_query, + listings: search_listings, + detail: search_detail, }, cart: BuyerCartScreenProjection { cart: buyer_cart, @@ -3126,16 +3140,20 @@ fn load_selected_account_context_with_options( }, orders: BuyerOrdersScreenProjection { list: buyer_orders, - detail: None, + detail: buyer_order_detail, }, ..PersonalWorkspaceProjection::default() }; let Some(selected_account) = identity_projection.selected_account.as_ref() else { return Ok(DesktopSelectedAccountContext { personal_projection, + products_query: ProductsScreenQueryState::default(), orders_list: OrdersListProjection::default(), + orders_query: OrdersScreenQueryState::default(), orders_reminders: ReminderFeedProjection::default(), recovery_queue: RecoveryQueueProjection::default(), + pack_day_query: PackDayScreenQueryState::default(), + product_editor_draft: None, reminder_log: ReminderLogProjection::default(), ..DesktopSelectedAccountContext::default() }); @@ -3149,46 +3167,92 @@ fn load_selected_account_context_with_options( .saved_farm .as_ref() .map(|farm| farm.farm_id)); - let farm_rules_projection = match today_farm_id { + let ( + farm_rules_projection, + mut today_projection, + products_query, + products_list, + orders_query, + orders_list, + canonical_orders_list, + mut order_detail, + pack_day_query, + mut pack_day_projection, + product_editor_draft, + ) = match today_farm_id { Some(farm_id) => { let fallback_profile = fallback_farm_profile_for_projection(farm_id, &farm_setup_projection); - sqlite_store.load_farm_rules(farm_id).map(|projection| { - prepare_loaded_farm_rules_projection(projection, &fallback_profile) - })? - } - None => FarmRulesProjection::default(), - }; - let mut today_projection = match today_farm_id { - Some(farm_id) => sqlite_store.load_today_agenda(Some(farm_id))?, - None => TodayAgendaProjection::default(), - }; - let products_list = match today_farm_id { - Some(farm_id) => sqlite_store.load_products( - farm_id, - &products_query.search_query, - products_query.filter, - products_query.sort, - )?, - None => ProductsListProjection::default(), - }; - let orders_list = match today_farm_id { - Some(farm_id) => sqlite_store.load_orders_list(farm_id, &orders_query)?, - None => OrdersListProjection::default(), - }; - let canonical_orders_list = match today_farm_id { - Some(farm_id) => { - sqlite_store.load_orders_list(farm_id, &OrdersScreenQueryState::default())? + let farm_rules_projection = + sqlite_store.load_farm_rules(farm_id).map(|projection| { + prepare_loaded_farm_rules_projection(projection, &fallback_profile) + })?; + let today_projection = sqlite_store.load_today_agenda(Some(farm_id))?; + let products_query = continuity_state.seller.products_query.clone(); + let products_list = sqlite_store.load_products( + farm_id, + &products_query.search_query, + products_query.filter, + products_query.sort, + )?; + let orders_query = sanitize_orders_query( + sqlite_store, + farm_id, + continuity_state.seller.orders_query.clone(), + )?; + let orders_list = sqlite_store.load_orders_list(farm_id, &orders_query)?; + let canonical_orders_list = + sqlite_store.load_orders_list(farm_id, &OrdersScreenQueryState::default())?; + let order_detail = match continuity_state.seller.order_detail_order_id { + Some(order_id) => sqlite_store.load_order_detail(farm_id, order_id)?, + None => None, + }; + let (pack_day_query, pack_day_projection) = sanitize_pack_day_query( + sqlite_store, + farm_id, + continuity_state.seller.pack_day_query.clone(), + )?; + let product_editor_draft = if matches!( + continuity_state.shell.selected_section, + ShellSection::Farmer(FarmerSection::Products) + ) { + match continuity_state.seller.product_editor_product_id { + Some(product_id) => sqlite_store + .load_product_editor_draft(product_id)? + .map(|draft| (product_id, draft)), + None => None, + } + } else { + None + }; + + ( + farm_rules_projection, + today_projection, + products_query, + products_list, + orders_query, + orders_list, + canonical_orders_list, + order_detail, + pack_day_query, + pack_day_projection, + product_editor_draft, + ) } - None => OrdersListProjection::default(), - }; - let mut order_detail = match today_farm_id.zip(selected_order_id) { - Some((farm_id, order_id)) => sqlite_store.load_order_detail(farm_id, order_id)?, - None => None, - }; - let mut pack_day_projection = match today_farm_id { - Some(farm_id) => sqlite_store.load_pack_day(farm_id, &pack_day_query)?, - None => PackDayProjection::default(), + None => ( + FarmRulesProjection::default(), + TodayAgendaProjection::default(), + ProductsScreenQueryState::default(), + ProductsListProjection::default(), + OrdersScreenQueryState::default(), + OrdersListProjection::default(), + OrdersListProjection::default(), + None, + PackDayScreenQueryState::default(), + PackDayProjection::default(), + None, + ), }; let (orders_reminders, recovery_queue, reminder_log) = match today_farm_id { Some(farm_id) => { @@ -3230,16 +3294,71 @@ fn load_selected_account_context_with_options( farm_setup_projection, farm_rules_projection, today_projection, + products_query, products_list, + orders_query, orders_list, orders_reminders, recovery_queue, reminder_log, order_detail, + pack_day_query, pack_day_projection, + product_editor_draft, }) } +fn sanitize_orders_query( + sqlite_store: &AppSqliteStore, + farm_id: FarmId, + query: OrdersScreenQueryState, +) -> Result<OrdersScreenQueryState, AppSqliteError> { + let Some(fulfillment_window_id) = query.fulfillment_window_id else { + return Ok(query); + }; + let pack_day = sqlite_store.load_pack_day( + farm_id, + &PackDayScreenQueryState { + fulfillment_window_id: Some(fulfillment_window_id), + }, + )?; + if pack_day + .fulfillment_window + .as_ref() + .map(|window| window.fulfillment_window_id) + == Some(fulfillment_window_id) + { + Ok(query) + } else { + Ok(OrdersScreenQueryState { + filter: query.filter, + fulfillment_window_id: None, + }) + } +} + +fn sanitize_pack_day_query( + sqlite_store: &AppSqliteStore, + farm_id: FarmId, + query: PackDayScreenQueryState, +) -> Result<(PackDayScreenQueryState, PackDayProjection), AppSqliteError> { + let projection = sqlite_store.load_pack_day(farm_id, &query)?; + if query.fulfillment_window_id.is_none() + || projection + .fulfillment_window + .as_ref() + .map(|window| window.fulfillment_window_id) + == query.fulfillment_window_id + { + return Ok((query, projection)); + } + + let default_query = PackDayScreenQueryState::default(); + let default_projection = sqlite_store.load_pack_day(farm_id, &default_query)?; + + Ok((default_query, default_projection)) +} + fn load_selected_account_reminder_context( sqlite_store: &AppSqliteStore, account_id: &str, @@ -4261,8 +4380,9 @@ mod tests { }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget, latest_schema_version}; use radroots_app_state::{ - AppStateRepositoryError, AppStateStore, AppStateStoreError, HomeRoute, - InMemoryAppStateRepository, + APP_STATE_FILE_NAME, AppStatePersistenceRepository, AppStateRepository, + AppStateRepositoryError, AppStateStore, AppStateStoreError, FileBackedAppStateRepository, + HomeRoute, }; use radroots_app_sync::{ AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, @@ -4356,7 +4476,7 @@ mod tests { #[test] fn cloned_runtime_handles_shared_settings_state() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -4410,7 +4530,7 @@ mod tests { #[test] fn cloned_runtime_handles_shared_startup_identity_choice_state() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -4994,7 +5114,7 @@ mod tests { fn clearing_startup_pending_remote_signer_session_is_idempotent_without_record() { let paths = temp_remote_signer_paths("clear_pending_none"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5025,7 +5145,7 @@ mod tests { fn clean_startup_cleanup_allows_generate_key_phase_transition() { let paths = temp_remote_signer_paths("generate_key_after_clean_cleanup"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5123,9 +5243,318 @@ mod tests { } #[test] + fn startup_signer_entry_source_input_recovers_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_startup_signer_entry"); + + assert!(runtime.show_startup_identity_choice()); + assert!(runtime.show_startup_signer_entry()); + assert!(runtime.set_startup_signer_source_input( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" + )); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.logged_out_startup.phase, + radroots_app_models::LoggedOutStartupPhase::SignerEntry + ); + assert_eq!( + summary.logged_out_startup.signer_entry.source_input, + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn generate_key_startup_phase_fails_closed_to_identity_choice_after_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_generate_key_sanitize"); + + assert!(runtime.show_startup_identity_choice()); + assert!(runtime.begin_generate_key_startup()); + + let restarted = restart_runtime(paths.clone()); + + assert_eq!( + restarted.summary().logged_out_startup.phase, + radroots_app_models::LoggedOutStartupPhase::IdentityChoice + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn buyer_search_query_and_detail_recover_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_buyer_search_detail"); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + + assert!( + runtime + .select_active_surface(ActiveSurface::Personal) + .expect("surface should switch into marketplace") + ); + let fulfillment_window_id = seed_buyer_marketplace_support( + &runtime, + account_id.as_str(), + farm_id, + "North field farm", + "Friday pickup", + ); + let product_id = seed_product( + &runtime, + farm_id, + "Salad mix", + "Spring blend", + "published", + Some(8), + "2026-04-20T09:00:00Z", + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&format!( + "update products + set availability_window_id = '{fulfillment_window_id}' + where id = '{product_id}'" + )) + .expect("buyer detail product should attach a fulfillment window"); + + assert!( + runtime + .set_personal_search_query("salad") + .expect("buyer search query should update") + ); + assert!( + runtime + .open_personal_product_detail(PersonalSection::Search, product_id) + .expect("buyer search detail should open") + ); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Personal(PersonalSection::Search) + ); + assert_eq!( + summary.personal_projection.search.query.search_query, + "salad" + ); + assert_eq!( + summary + .personal_projection + .search + .detail + .as_ref() + .map(|detail| detail.listing.product_id), + Some(product_id) + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn products_query_and_editor_recover_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_products_editor"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let product_id = seed_product( + &runtime, + farm_id, + "Pea shoots", + "Tray", + "draft", + Some(4), + "2026-04-20T09:30:00Z", + ); + + assert!( + runtime + .set_products_search_query("pea") + .expect("products query should update") + ); + assert!( + runtime + .select_products_filter(ProductsFilter::Drafts) + .expect("products filter should update") + ); + assert!( + runtime + .select_products_sort(ProductsSort::Name) + .expect("products sort should update") + ); + assert!( + runtime + .open_existing_product_editor(product_id) + .expect("product editor should open") + ); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Products) + ); + assert_eq!(summary.products_projection.query.search_query, "pea"); + assert_eq!( + summary.products_projection.query.filter, + ProductsFilter::Drafts + ); + assert_eq!(summary.products_projection.query.sort, ProductsSort::Name); + match &summary.products_projection.editor { + radroots_app_state::ProductEditorState::Open(session) => { + assert_eq!(session.selected_product_id, Some(product_id)); + } + radroots_app_state::ProductEditorState::Closed => { + panic!("product editor should recover after restart") + } + } + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn orders_query_and_detail_recover_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_orders_detail"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, order_id) = seed_order_workspace(&runtime, farm_id); + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&format!( + "update orders + set status = 'packed', updated_at = '2026-04-20T09:45:00Z' + where id = '{order_id}' and farm_id = '{farm_id}'" + )) + .expect("order should update to packed"); + + assert!( + runtime + .select_orders_filter(OrdersFilter::Packed) + .expect("orders filter should update") + ); + assert!( + runtime + .open_order_detail(order_id) + .expect("order detail should open") + ); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Orders) + ); + assert_eq!(summary.orders_projection.query.filter, OrdersFilter::Packed); + assert_eq!( + summary + .orders_projection + .detail + .as_ref() + .map(|detail| detail.order_id), + Some(order_id) + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn stale_orders_selection_clears_invalid_window_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_stale_orders"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (fulfillment_window_id, _) = seed_order_workspace(&runtime, farm_id); + + assert!( + runtime + .open_orders_fulfillment_window(fulfillment_window_id) + .expect("orders window should open") + ); + let mut persisted_state = runtime.lock_state().state_store.persisted_state().clone(); + persisted_state.seller.orders_query.fulfillment_window_id = + Some(FulfillmentWindowId::new()); + let mut repository = + FileBackedAppStateRepository::new(paths.app.data.join(APP_STATE_FILE_NAME)); + repository + .save_persisted_state(&persisted_state) + .expect("stale orders selection should persist"); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Orders) + ); + assert_eq!(summary.orders_projection.query.fulfillment_window_id, None); + assert!( + summary + .orders_projection + .list + .rows + .iter() + .any(|row| { row.fulfillment_window_id == Some(fulfillment_window_id) }) + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn stale_pack_day_selection_clears_invalid_window_after_runtime_restart() { + let (runtime, paths) = bootstrapped_runtime("restart_stale_pack_day"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, _) = seed_order_workspace(&runtime, farm_id); + + assert!(runtime.open_pack_day(None).expect("pack day should open")); + let mut persisted_state = runtime.lock_state().state_store.persisted_state().clone(); + let stale_fulfillment_window_id = FulfillmentWindowId::new(); + persisted_state.seller.pack_day_query.fulfillment_window_id = + Some(stale_fulfillment_window_id); + let mut repository = + FileBackedAppStateRepository::new(paths.app.data.join(APP_STATE_FILE_NAME)); + repository + .save_persisted_state(&persisted_state) + .expect("stale pack day selection should persist"); + + let restarted = restart_runtime(paths.clone()); + let summary = restarted.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::PackDay) + ); + assert_eq!( + summary.pack_day_projection.query.fulfillment_window_id, + None + ); + assert!( + summary + .pack_day_projection + .projection + .fulfillment_window + .is_some() + ); + assert_ne!( + summary.pack_day_projection.query.fulfillment_window_id, + Some(stale_fulfillment_window_id) + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn replacing_today_agenda_is_shared_without_clobbering_home_shell() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5227,7 +5656,7 @@ mod tests { #[test] fn runtime_records_activity_context_for_user_visible_actions() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5280,7 +5709,7 @@ mod tests { #[test] fn activity_context_distinguishes_empty_history_from_runtime_unavailable() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5317,7 +5746,7 @@ mod tests { #[test] fn activity_context_surfaces_store_load_failure() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -5352,7 +5781,7 @@ mod tests { #[test] fn selecting_farmer_section_requires_farmer_identity_gate() { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -6279,12 +6708,14 @@ mod tests { available_product_id ); assert_eq!(summary.personal_projection.cart.cart.lines[0].quantity, 1); - assert!(summary - .personal_projection - .cart - .cart - .replace_confirmation - .is_none()); + assert!( + summary + .personal_projection + .cart + .cart + .replace_confirmation + .is_none() + ); } #[test] @@ -8007,7 +8438,7 @@ mod tests { fn runtime_account_commands_fail_closed_without_accounts_manager() { let paths = temp_shared_accounts_paths("blocked"); let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths), @@ -8036,7 +8467,7 @@ mod tests { fn memory_runtime() -> DesktopAppRuntime { DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, @@ -8067,7 +8498,7 @@ mod tests { ( DesktopAppRuntime::from_state(DesktopAppRuntimeState { - state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths.clone()), diff --git a/crates/shared/state/Cargo.toml b/crates/shared/state/Cargo.toml @@ -10,7 +10,10 @@ publish = false [dependencies] radroots_app_models.workspace = true radroots_app_sync.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true +tracing.workspace = true [lints] workspace = true diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1,6 +1,11 @@ #![forbid(unsafe_code)] -use std::collections::BTreeSet; +use std::{ + collections::BTreeSet, + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; use radroots_app_models::{ ActiveSurface, AppIdentityProjection, AppStartupGate, BuyerCartProjection, @@ -8,7 +13,7 @@ use radroots_app_models::{ BuyerOrdersProjection, BuyerProductDetailProjection, FarmOrderMethod, FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupProjection, FarmSetupReadiness, FarmTimingConflict, FulfillmentWindowId, LoggedOutStartupPhase, - LoggedOutStartupProjection, OrderDetailProjection, OrdersFilter, OrdersListProjection, + LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalEntryProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, RecoveryQueueProjection, ReminderFeedProjection, ReminderLogProjection, @@ -19,7 +24,9 @@ use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, SyncConflict, SyncConflictStatus, }; +use serde::{Deserialize, Serialize}; use thiserror::Error; +use tracing::error; #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneralSettingsProjection { @@ -80,7 +87,7 @@ impl SettingsShellProjection { } } -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct BuyerSearchScreenQueryState { pub search_query: String, pub fulfillment_methods: BTreeSet<FarmOrderMethod>, @@ -132,7 +139,7 @@ pub struct PersonalWorkspaceProjection { pub orders: BuyerOrdersScreenProjection, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ProductsScreenQueryState { pub search_query: String, pub filter: ProductsFilter, @@ -524,6 +531,197 @@ impl Default for AppProjection { } } +pub const APP_STATE_FILE_NAME: &str = "state.json"; +const APP_STATE_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersistedShellProjection { + pub selected_section: ShellSection, + pub settings_section: SettingsSection, +} + +impl Default for PersistedShellProjection { + fn default() -> Self { + Self { + selected_section: ShellSection::Home, + settings_section: SettingsSection::default(), + } + } +} + +impl PersistedShellProjection { + fn from_shell(shell: &AppShellProjection) -> Self { + Self { + selected_section: shell.selected_section, + settings_section: shell.settings.selected_section, + } + } + + fn to_shell_projection(&self) -> AppShellProjection { + let mut shell = AppShellProjection::new(ActiveSurface::Personal, self.selected_section); + shell.settings.selected_section = self.settings_section; + if matches!(shell.selected_section, ShellSection::Settings(_)) { + shell.selected_section = ShellSection::Settings(self.settings_section); + } + + shell + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersistedBuyerProjection { + pub search_query: BuyerSearchScreenQueryState, + pub browse_detail_product_id: Option<ProductId>, + pub search_detail_product_id: Option<ProductId>, + pub orders_detail_order_id: Option<OrderId>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersistedSellerProjection { + pub products_query: ProductsScreenQueryState, + pub product_editor_product_id: Option<ProductId>, + pub orders_query: OrdersScreenQueryState, + pub order_detail_order_id: Option<OrderId>, + pub pack_day_query: PackDayScreenQueryState, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct PersistedAppState { + pub shell: PersistedShellProjection, + pub logged_out_startup: LoggedOutStartupProjection, + pub buyer: PersistedBuyerProjection, + pub seller: PersistedSellerProjection, +} + +impl PersistedAppState { + pub fn from_projection(projection: &AppProjection) -> Self { + Self { + shell: PersistedShellProjection::from_shell(&projection.shell), + logged_out_startup: projection.logged_out_startup.clone(), + buyer: PersistedBuyerProjection { + search_query: projection.personal.search.query.clone(), + browse_detail_product_id: projection + .personal + .browse + .detail + .as_ref() + .map(|detail| detail.listing.product_id), + search_detail_product_id: projection + .personal + .search + .detail + .as_ref() + .map(|detail| detail.listing.product_id), + orders_detail_order_id: projection + .personal + .orders + .detail + .as_ref() + .map(|detail| detail.order_id), + }, + seller: PersistedSellerProjection { + products_query: projection.products.query.clone(), + product_editor_product_id: match &projection.products.editor { + ProductEditorState::Open(session) => session.selected_product_id, + ProductEditorState::Closed => None, + }, + orders_query: projection.orders.query.clone(), + order_detail_order_id: projection + .orders + .detail + .as_ref() + .map(|detail| detail.order_id), + pack_day_query: projection.pack_day.query.clone(), + }, + } + } + + fn sanitized_for_restart(&self) -> Self { + let mut state = self.clone(); + + if state.logged_out_startup.phase == LoggedOutStartupPhase::GenerateKeyStarting { + state.logged_out_startup.phase = LoggedOutStartupPhase::IdentityChoice; + } + + state + } + + fn to_projection(&self) -> AppProjection { + let mut projection = AppProjection { + shell: self.shell.to_shell_projection(), + identity: AppIdentityProjection::default(), + startup_gate: AppStartupGate::SetupRequired, + sync: AppSyncProjection::default(), + logged_out_startup: self.logged_out_startup.clone(), + personal: PersonalWorkspaceProjection { + entry: AppIdentityProjection::default().personal_entry(), + search: BuyerSearchScreenProjection { + query: self.buyer.search_query.clone(), + ..BuyerSearchScreenProjection::default() + }, + ..PersonalWorkspaceProjection::default() + }, + today: TodayAgendaProjection::default(), + products: ProductsScreenProjection { + query: self.seller.products_query.clone(), + ..ProductsScreenProjection::default() + }, + orders: OrdersScreenProjection { + query: self.seller.orders_query.clone(), + ..OrdersScreenProjection::default() + }, + pack_day: PackDayScreenProjection { + query: self.seller.pack_day_query.clone(), + ..PackDayScreenProjection::default() + }, + reminder_log: ReminderLogProjection::default(), + farm_setup: FarmSetupProjection::default(), + farm_rules: FarmRulesProjection::default(), + farm_readiness: FarmWorkspaceReadinessProjection::default(), + farm_setup_flow_stage: FarmSetupFlowStage::default(), + }; + sync_farm_setup_to_today(&mut projection.farm_setup, &projection.today); + projection.farm_readiness = + derive_farm_workspace_readiness(&projection.farm_setup, &projection.farm_rules); + sync_coarse_farm_readiness( + &mut projection.farm_setup, + &mut projection.today, + &projection.farm_readiness, + ); + projection.today.setup_checklist = + derive_today_setup_checklist(&projection.farm_readiness, &projection.products.list); + sync_product_editor_publish_blockers( + &mut projection.products.editor, + &projection.farm_readiness, + ); + projection.startup_gate = projection.identity.startup_gate(); + projection.personal.entry = projection.identity.personal_entry(); + sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate); + sync_farm_setup_flow_stage( + &mut projection.farm_setup_flow_stage, + projection.startup_gate, + projection.farm_setup.has_saved_farm(), + ); + + projection + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +struct PersistedAppStateEnvelope { + version: u32, + state: PersistedAppState, +} + +impl PersistedAppStateEnvelope { + fn new(state: PersistedAppState) -> Self { + Self { + version: APP_STATE_SCHEMA_VERSION, + state, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppStateCommand { SelectActiveSurface(ActiveSurface), @@ -720,17 +918,17 @@ impl AppStateRepositoryError { } pub trait AppStateRepository { - fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError>; + fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError>; - fn save_shell_projection( + fn save_persisted_state( &mut self, - projection: &AppShellProjection, + state: &PersistedAppState, ) -> Result<(), AppStateRepositoryError>; } #[derive(Clone, Debug, Eq, PartialEq)] pub struct InMemoryAppStateRepository { - projection: AppShellProjection, + state: PersistedAppState, } impl Default for InMemoryAppStateRepository { @@ -741,32 +939,154 @@ impl Default for InMemoryAppStateRepository { impl InMemoryAppStateRepository { pub fn new(projection: AppShellProjection) -> Self { - Self { projection } + let state = PersistedAppState { + shell: PersistedShellProjection::from_shell(&projection), + ..PersistedAppState::default() + }; + + Self { state } } - pub fn projection(&self) -> &AppShellProjection { - &self.projection + pub fn from_persisted_state(state: PersistedAppState) -> Self { + Self { state } + } + + pub fn projection(&self) -> AppShellProjection { + self.state.shell.to_shell_projection() + } + + pub fn persisted_state(&self) -> &PersistedAppState { + &self.state } - pub fn overwrite(&mut self, projection: AppShellProjection) { - self.projection = projection; + pub fn overwrite(&mut self, state: PersistedAppState) { + self.state = state; } } impl AppStateRepository for InMemoryAppStateRepository { - fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError> { - Ok(self.projection.clone()) + fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> { + Ok(self.state.clone()) } - fn save_shell_projection( + fn save_persisted_state( &mut self, - projection: &AppShellProjection, + state: &PersistedAppState, ) -> Result<(), AppStateRepositoryError> { - self.projection = projection.clone(); + self.state = state.clone(); Ok(()) } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileBackedAppStateRepository { + path: PathBuf, +} + +impl FileBackedAppStateRepository { + pub fn new(path: impl Into<PathBuf>) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } + + fn write_state(&self, state: &PersistedAppState) -> Result<(), AppStateRepositoryError> { + let Some(parent) = self.path.parent() else { + return Err(AppStateRepositoryError::save( + "app state path must have a parent directory", + )); + }; + fs::create_dir_all(parent) + .map_err(|error| AppStateRepositoryError::save(error.to_string()))?; + let payload = serde_json::to_vec_pretty(&PersistedAppStateEnvelope::new(state.clone())) + .map_err(|error| AppStateRepositoryError::save(error.to_string()))?; + let temporary_path = self.path.with_extension("tmp"); + let _ = fs::remove_file(&temporary_path); + fs::write(&temporary_path, payload) + .map_err(|error| AppStateRepositoryError::save(error.to_string()))?; + if self.path.exists() { + fs::remove_file(&self.path) + .map_err(|error| AppStateRepositoryError::save(error.to_string()))?; + } + fs::rename(&temporary_path, &self.path) + .map_err(|error| AppStateRepositoryError::save(error.to_string())) + } +} + +impl AppStateRepository for FileBackedAppStateRepository { + fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> { + let contents = match fs::read_to_string(&self.path) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => { + return Ok(PersistedAppState::default()); + } + Err(error) => { + return Err(AppStateRepositoryError::load(error.to_string())); + } + }; + + let envelope = match serde_json::from_str::<PersistedAppStateEnvelope>(&contents) { + Ok(envelope) if envelope.version == APP_STATE_SCHEMA_VERSION => envelope, + Ok(_) | Err(_) => { + let default_state = PersistedAppState::default(); + self.write_state(&default_state)?; + return Ok(default_state); + } + }; + + let sanitized = envelope.state.sanitized_for_restart(); + if sanitized != envelope.state { + self.write_state(&sanitized)?; + } + + Ok(sanitized) + } + + fn save_persisted_state( + &mut self, + state: &PersistedAppState, + ) -> Result<(), AppStateRepositoryError> { + self.write_state(state) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AppStatePersistenceRepository { + InMemory(InMemoryAppStateRepository), + FileBacked(FileBackedAppStateRepository), +} + +impl AppStatePersistenceRepository { + pub fn in_memory() -> Self { + Self::InMemory(InMemoryAppStateRepository::default()) + } + + pub fn file_backed(path: impl Into<PathBuf>) -> Self { + Self::FileBacked(FileBackedAppStateRepository::new(path)) + } +} + +impl AppStateRepository for AppStatePersistenceRepository { + fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> { + match self { + Self::InMemory(repository) => repository.load_persisted_state(), + Self::FileBacked(repository) => repository.load_persisted_state(), + } + } + + fn save_persisted_state( + &mut self, + state: &PersistedAppState, + ) -> Result<(), AppStateRepositoryError> { + match self { + Self::InMemory(repository) => repository.save_persisted_state(state), + Self::FileBacked(repository) => repository.save_persisted_state(state), + } + } +} + #[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum AppStateStoreError { #[error(transparent)] @@ -777,19 +1097,18 @@ pub enum AppStateStoreError { pub struct AppStateStore<R> { repository: R, projection: AppProjection, + persisted_state: PersistedAppState, } impl<R: AppStateRepository> AppStateStore<R> { pub fn load(repository: R) -> Result<Self, AppStateStoreError> { - let projection = AppProjection::new( - repository.load_shell_projection()?, - AppIdentityProjection::default(), - TodayAgendaProjection::default(), - ); + let persisted_state = repository.load_persisted_state()?; + let projection = persisted_state.to_projection(); Ok(Self { repository, projection, + persisted_state, }) } @@ -865,57 +1184,36 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.repository } + pub fn persisted_state(&self) -> &PersistedAppState { + &self.persisted_state + } + pub fn apply(&mut self, command: AppStateCommand) -> Result<bool, AppStateStoreError> { let mut next_projection = self.projection.clone(); + if matches!( + apply_command(&mut next_projection, command), + AppStateMutation::NoChange + ) { + return Ok(false); + } - match apply_command(&mut next_projection, command) { - AppStateMutation::NoChange => Ok(false), - AppStateMutation::ShellChanged => { - self.repository - .save_shell_projection(&next_projection.shell)?; - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::FarmSetupChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::StartupChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::SyncChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::PersonalChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::TodayChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::ProductsChanged => { - self.projection = next_projection; - - Ok(true) - } - AppStateMutation::OrdersChanged => { - self.projection = next_projection; + let next_persisted_state = PersistedAppState::from_projection(&next_projection); + if next_persisted_state != self.persisted_state { + self.repository + .save_persisted_state(&next_persisted_state)?; + } + self.persisted_state = next_persisted_state; + self.projection = next_projection; - Ok(true) - } - AppStateMutation::PackDayChanged => { - self.projection = next_projection; + Ok(true) + } - Ok(true) + pub fn apply_in_memory(&mut self, command: AppStateCommand) -> bool { + match self.apply(command) { + Ok(changed) => changed, + Err(error) => { + error!(target: "app_state", error = %error, "failed to persist app state"); + false } } } @@ -923,67 +1221,12 @@ impl<R: AppStateRepository> AppStateStore<R> { impl AppStateStore<InMemoryAppStateRepository> { pub fn in_memory(projection: AppShellProjection) -> Self { + let repository = InMemoryAppStateRepository::new(projection.clone()); + let persisted_state = repository.persisted_state().clone(); Self { - repository: InMemoryAppStateRepository::new(projection.clone()), - projection: AppProjection::new( - projection, - AppIdentityProjection::default(), - TodayAgendaProjection::default(), - ), - } - } - - pub fn apply_in_memory(&mut self, command: AppStateCommand) -> bool { - let mut next_projection = self.projection.clone(); - - match apply_command(&mut next_projection, command) { - AppStateMutation::NoChange => false, - AppStateMutation::ShellChanged => { - self.repository.overwrite(next_projection.shell.clone()); - self.projection = next_projection; - - true - } - AppStateMutation::FarmSetupChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::StartupChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::SyncChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::PersonalChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::TodayChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::ProductsChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::OrdersChanged => { - self.projection = next_projection; - - true - } - AppStateMutation::PackDayChanged => { - self.projection = next_projection; - - true - } + repository, + projection: persisted_state.to_projection(), + persisted_state, } } } @@ -1457,8 +1700,8 @@ mod tests { AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, InMemoryAppStateRepository, OrdersScreenProjection, PackDayScreenProjection, - ProductEditorState, ProductsScreenProjection, ProductsScreenQueryState, SettingsPreference, - derive_sync_projection, derive_sync_run_status, + PersistedAppState, ProductEditorState, ProductsScreenProjection, ProductsScreenQueryState, + SettingsPreference, derive_sync_projection, derive_sync_run_status, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, @@ -1483,13 +1726,13 @@ mod tests { struct FailingRepository; impl AppStateRepository for FailingRepository { - fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError> { - Ok(AppShellProjection::default()) + fn load_persisted_state(&self) -> Result<PersistedAppState, AppStateRepositoryError> { + Ok(PersistedAppState::default()) } - fn save_shell_projection( + fn save_persisted_state( &mut self, - _: &AppShellProjection, + _: &PersistedAppState, ) -> Result<(), AppStateRepositoryError> { Err(AppStateRepositoryError::save("disk unavailable")) } @@ -1634,7 +1877,11 @@ mod tests { assert_eq!(store.projection().products.list, products_list); assert_eq!( store.repository().projection(), - &AppShellProjection::default() + AppShellProjection::default() + ); + assert_eq!( + store.repository().persisted_state().seller.products_query, + ProductsScreenQueryState::new("pea", ProductsFilter::NeedAttention, ProductsSort::Name) ); } @@ -1825,7 +2072,28 @@ mod tests { assert_eq!(store.projection().orders.detail, None); assert_eq!( store.repository().projection(), - &AppShellProjection::default() + AppShellProjection::default() + ); + assert_eq!( + store.repository().persisted_state().seller.orders_query, + OrdersScreenQueryState { + filter: OrdersFilter::NeedsAction, + fulfillment_window_id: Some(fulfillment_window_id), + } + ); + assert_eq!( + store + .repository() + .persisted_state() + .seller + .order_detail_order_id, + None + ); + assert_eq!( + store.repository().persisted_state().seller.pack_day_query, + PackDayScreenQueryState { + fulfillment_window_id: Some(fulfillment_window_id), + } ); } @@ -1881,7 +2149,15 @@ mod tests { ); assert_eq!( store.repository().projection(), - &AppShellProjection::default() + AppShellProjection::default() + ); + assert_eq!( + store + .repository() + .persisted_state() + .logged_out_startup + .phase, + LoggedOutStartupPhase::GenerateKeyStarting ); assert_eq!( @@ -2263,7 +2539,7 @@ mod tests { assert_eq!(changed, Ok(true)); assert!(store.projection().shell.settings.general.launch_at_login); assert!( - store + !store .repository() .projection() .settings @@ -2567,7 +2843,7 @@ mod tests { .allow_relay_connections ); assert!( - !store + store .repository() .projection() .settings