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:
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