app

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

commit 9c059aa95eb3d76521b708c9d93864e3996c1985
parent 4bafc5949810eaa6bd88471c45be1e156031a117
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 15:00:35 -0700

runtime: harden listing publish retry state

- move listing enqueue failures out of sqlite store errors
- keep product editor and stock retries enabled after local persistence succeeds
- return successful publish enqueue as a changed runtime result
- cover product and stock SDK retry recovery with regression tests

Diffstat:
Mcrates/desktop/src/runtime.rs | 292++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/desktop/src/source_guards.rs | 2++
Mcrates/desktop/src/window.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/i18n/src/keys.rs | 1+
Mcrates/store/src/error.rs | 2--
Mi18n/locales/en/messages.json | 1+
6 files changed, 323 insertions(+), 39 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -164,7 +164,6 @@ use crate::remote_signer::{ const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; const SYNC_TRANSPORT_UNAVAILABLE_MESSAGE: &str = "remote sync transport is not configured"; const APP_SYNC_PUBLISH_USES_SDK_RUNTIME_MESSAGE: &str = "app sync publish work uses AppSdkRuntime"; -const LISTING_PUBLISH_SDK_ENQUEUE_FAILED_REASON: &str = "listing publish SDK enqueue failed"; const APP_DIRECT_RELAY_SYNC_TIMEOUT_MS: u64 = 2_000; const APP_DIRECT_RELAY_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(10); const APP_DIRECT_RELAY_INGEST_LIMIT: usize = 1_000; @@ -1017,7 +1016,7 @@ impl DesktopAppRuntime { &self, product_id: ProductId, stock_quantity: u32, - ) -> Result<bool, AppSqliteError> { + ) -> Result<bool, DesktopAppRuntimeProductStockUpdateError> { self.lock_state_mut() .update_product_stock(product_id, stock_quantity) } @@ -3852,7 +3851,7 @@ impl DesktopAppRuntimeState { &mut self, product_id: ProductId, stock_quantity: u32, - ) -> Result<bool, AppSqliteError> { + ) -> Result<bool, DesktopAppRuntimeProductStockUpdateError> { let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Ok(false); }; @@ -3869,9 +3868,6 @@ impl DesktopAppRuntimeState { } let updated = sqlite_store.update_product_stock(product_id, stock_quantity)?; - if !updated { - return Ok(false); - } let continuity_state = self.continuity_state_with_order_detail(self.selected_order_detail_id()); @@ -4945,11 +4941,13 @@ impl DesktopAppRuntimeState { product_id: ProductId, source: &str, source_local_event_id: Option<&str>, - ) -> Result<bool, AppSqliteError> { + ) -> Result<bool, DesktopAppRuntimeProductPublishError> { let Some(payload) = self.product_publish_payload(product_id, source, source_local_event_id)? else { - return self.refresh_selected_account_sync(); + return self + .refresh_selected_account_sync() + .map_err(DesktopAppRuntimeProductPublishError::from); }; let (source_kind, source_record_id) = listing_publish_source_record( @@ -4958,7 +4956,8 @@ impl DesktopAppRuntimeState { payload.context.source_local_event_id.as_deref(), ); self.enqueue_listing_payload_via_sdk(&payload, source_kind, source_record_id.as_str())?; - self.refresh_selected_account_sync() + let _ = self.refresh_selected_account_sync()?; + Ok(true) } fn farm_profile_publish_payload( @@ -5188,7 +5187,7 @@ impl DesktopAppRuntimeState { payload: &AppListingPublishPayload, source_kind: AppSdkMigrationReceiptSourceKind, source_record_id: &str, - ) -> Result<(), AppSqliteError> { + ) -> Result<(), DesktopAppRuntimeProductPublishError> { let operation_kind = LISTING_PUBLISH_OPERATION_KIND; let actor_pubkey = self .local_signing_identity_for_publish_payload(&AppPublishPayload::Listing( @@ -5210,13 +5209,15 @@ impl DesktopAppRuntimeState { .map_err(sync_transport_error_from_sdk_runtime_error) }); match actor_pubkey { - Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( - source_kind, - source_record_id, - operation_kind, - actor_pubkey.as_str(), - &receipt, - ), + Ok((actor_pubkey, receipt)) => self + .record_app_sdk_migration_success( + source_kind, + source_record_id, + operation_kind, + actor_pubkey.as_str(), + &receipt, + ) + .map_err(DesktopAppRuntimeProductPublishError::from), Err(error) => { self.record_app_sdk_migration_failure( source_kind, @@ -5225,9 +5226,7 @@ impl DesktopAppRuntimeState { None, sync_transport_error_detail_json(&error), )?; - Err(AppSqliteError::ProductPublish { - reason: LISTING_PUBLISH_SDK_ENQUEUE_FAILED_REASON, - }) + Err(DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed) } } } @@ -7343,6 +7342,14 @@ pub enum DesktopAppRuntimeCommandError { } #[derive(Debug, Error)] +enum DesktopAppRuntimeProductPublishError { + #[error(transparent)] + Sqlite(#[from] AppSqliteError), + #[error("listing publish could not be queued through the SDK runtime")] + ListingPublishSdkEnqueueFailed, +} + +#[derive(Debug, Error)] pub enum DesktopAppRuntimeProductEditorSaveError { #[error(transparent)] Sqlite(AppSqliteError), @@ -7354,13 +7361,17 @@ pub enum DesktopAppRuntimeProductEditorSaveError { impl From<AppSqliteError> for DesktopAppRuntimeProductEditorSaveError { fn from(error: AppSqliteError) -> Self { + Self::Sqlite(error) + } +} + +impl From<DesktopAppRuntimeProductPublishError> for DesktopAppRuntimeProductEditorSaveError { + fn from(error: DesktopAppRuntimeProductPublishError) -> Self { match error { - AppSqliteError::ProductPublish { reason } - if reason == LISTING_PUBLISH_SDK_ENQUEUE_FAILED_REASON => - { + DesktopAppRuntimeProductPublishError::Sqlite(error) => Self::Sqlite(error), + DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed => { Self::ListingPublishSdkEnqueueFailed } - error => Self::Sqlite(error), } } } @@ -7371,6 +7382,37 @@ impl DesktopAppRuntimeProductEditorSaveError { } } +#[derive(Debug, Error)] +pub enum DesktopAppRuntimeProductStockUpdateError { + #[error(transparent)] + Sqlite(AppSqliteError), + #[error("stock was saved, but listing publish could not be queued through the SDK runtime")] + ListingPublishSdkEnqueueFailed, +} + +impl From<AppSqliteError> for DesktopAppRuntimeProductStockUpdateError { + fn from(error: AppSqliteError) -> Self { + Self::Sqlite(error) + } +} + +impl From<DesktopAppRuntimeProductPublishError> for DesktopAppRuntimeProductStockUpdateError { + fn from(error: DesktopAppRuntimeProductPublishError) -> Self { + match error { + DesktopAppRuntimeProductPublishError::Sqlite(error) => Self::Sqlite(error), + DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed => { + Self::ListingPublishSdkEnqueueFailed + } + } + } +} + +impl DesktopAppRuntimeProductStockUpdateError { + pub fn is_listing_publish_sdk_enqueue_failed(&self) -> bool { + matches!(self, Self::ListingPublishSdkEnqueueFailed) + } +} + impl From<DesktopRemoteSignerError> for DesktopAppRuntimeCommandError { fn from(error: DesktopRemoteSignerError) -> Self { Self::RemoteSigner(error.to_string()) @@ -12815,7 +12857,7 @@ mod tests { .expect("sqlite store") .load_product_editor_draft(product_id) .expect("saved product draft should load"), - Some(draft) + Some(draft.clone()) ); let records = shared_local_event_records(&paths); @@ -12852,6 +12894,192 @@ mod tests { assert_eq!(receipt.detail_json["class"], "runtime"); assert_eq!(receipt.detail_json["retryable"], true); + restore_sdk_runtime(&runtime, &paths); + assert!( + runtime + .save_product_editor_draft(draft.clone()) + .expect("retry should enqueue listing publish through SDK runtime") + ); + let retry_records = shared_local_event_records(&paths); + let enqueued_listing_receipts = { + let state = runtime.lock_state(); + let repository = state + .sqlite_store + .as_ref() + .expect("sqlite store") + .sdk_migration_receipt_repository(); + retry_records + .iter() + .filter(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .filter_map(|record| { + repository + .load_receipt( + AppSdkMigrationReceiptSourceKind::SharedLocalEvent, + record.record_id.as_str(), + ) + .expect("retry listing SDK migration receipt should load") + }) + .filter(|receipt| receipt.migration_state == AppSdkMigrationState::Enqueued) + .count() + }; + assert!(enqueued_listing_receipts >= 1); + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down after retry") + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_product_stock_update_retries_sdk_listing_enqueue_after_local_save() { + let (runtime, paths) = bootstrapped_runtime("stock_listing_sdk_retry"); + let (_account_id, farm_id) = provision_ready_farmer_account(&runtime); + let pickup_location_id = PickupLocationId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + + runtime + .save_farm_rules_projection(FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![FulfillmentWindowRecord { + fulfillment_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2099-04-25T14:00:00Z".to_owned(), + ends_at: "2099-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: Vec::new(), + ..runtime + .load_farm_rules_projection() + .expect("farm rules projection should load") + }) + .expect("farm rules should save"); + + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); + let product_id = match runtime.summary().products_projection.editor { + radroots_app_state::ProductEditorState::Open(session) => session + .selected_product_id + .expect("open product editor should select a product"), + radroots_app_state::ProductEditorState::Closed => { + panic!("product editor should be open") + } + }; + let draft = ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Cut this morning".to_owned(), + category: "greens".to_owned(), + unit_label: "each".to_owned(), + price_minor_units: Some(900), + price_currency: "USD".to_owned(), + stock_quantity: Some(11), + availability_window_id: Some(fulfillment_window_id), + status: ProductStatus::Published, + }; + assert!( + runtime + .save_product_editor_draft(draft) + .expect("initial published product save should enqueue") + ); + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + + let error = runtime + .update_product_stock(product_id, 13) + .expect_err("SDK listing enqueue failure should fail stock update action"); + assert!(matches!( + error, + super::DesktopAppRuntimeProductStockUpdateError::ListingPublishSdkEnqueueFailed + )); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_product_editor_draft(product_id) + .expect("saved product draft should load") + .expect("saved product draft should exist") + .stock_quantity, + Some(13) + ); + let (source_kind, source_record_id) = + super::listing_publish_source_record(product_id, "update_product_stock", None); + let failed_receipt = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .sdk_migration_receipt_repository() + .load_receipt(source_kind, source_record_id.as_str()) + .expect("failed stock listing SDK migration receipt should load") + .expect("failed stock listing SDK migration receipt should exist"); + assert_eq!(failed_receipt.migration_state, AppSdkMigrationState::Failed); + assert_eq!( + failed_receipt.detail_json["code"], + "sdk_runtime_not_available" + ); + + restore_sdk_runtime(&runtime, &paths); + assert!( + runtime + .update_product_stock(product_id, 13) + .expect("retry should enqueue stock listing publish through SDK runtime") + ); + let retry_receipt = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .sdk_migration_receipt_repository() + .load_receipt(source_kind, source_record_id.as_str()) + .expect("retry stock listing SDK migration receipt should load") + .expect("retry stock listing SDK migration receipt should exist"); + assert_eq!( + retry_receipt.migration_state, + AppSdkMigrationState::Enqueued + ); + assert!(retry_receipt.expected_event_id.is_some()); + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down after retry") + ); + cleanup_bootstrapped_runtime_paths(&paths); } @@ -21274,6 +21502,20 @@ mod tests { ) } + fn restore_sdk_runtime(runtime: &DesktopAppRuntime, paths: &AppDesktopRuntimePaths) { + let sdk_runtime = + super::start_desktop_sdk_runtime(paths, vec!["ws://127.0.0.1:8080".to_owned()]) + .expect("sdk runtime should restart"); + { + let mut handle = runtime.sdk_runtime.lock().expect("sdk runtime lock"); + *handle = Some(sdk_runtime); + } + let status = runtime + .wait_for_sdk_startup(StdDuration::from_secs(5)) + .expect("sdk runtime should be present after restart"); + assert_eq!(status.state, AppSdkLifecycleState::Ready); + } + fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -918,6 +918,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ProductsEditorCloseAction", "AppTextKey::ProductsEditorSaveAction", "AppTextKey::ProductsEditorSaveFailed", + "AppTextKey::ProductsEditorPublishQueueFailed", "AppTextKey::ProductsEditorInvalidPrice", "AppTextKey::ProductsEditorInvalidStock", "AppTextKey::ProductsEditorPublishReadinessTitle", @@ -935,6 +936,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::ProductsStockEditorCancelAction", "AppTextKey::ProductsStockEditorInvalidQuantity", "AppTextKey::ProductsStockEditorSaveFailed", + "AppTextKey::ProductsStockEditorPublishQueueFailed", "AppTextKey::ProductsStatusDraft", "AppTextKey::ProductsStatusLive", "AppTextKey::ProductsStatusPaused", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -102,7 +102,8 @@ use crate::pack_day_print::{ PackDayPrintError, execute_pack_day_batch_print_plan, execute_pack_day_print_plan, }; use crate::runtime::{ - DesktopAppRuntime, DesktopAppRuntimeProductEditorSaveError, DesktopAppRuntimeSummary, + DesktopAppRuntime, DesktopAppRuntimeProductEditorSaveError, + DesktopAppRuntimeProductStockUpdateError, DesktopAppRuntimeSummary, DesktopAppSdkDiagnosticsState, DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, @@ -3251,11 +3252,11 @@ impl HomeView { return; }; - if editor.input != *state || !editor.save_failed { + if editor.input != *state || editor.save_issue.is_none() { return; } - editor.save_failed = false; + editor.save_issue = None; cx.notify(); } @@ -3290,7 +3291,12 @@ impl HomeView { ); if let Some(editor) = self.products_stock_editor.as_mut() { - editor.save_failed = true; + let save_issue = + ProductsStockEditorSaveIssue::from_runtime_error(&runtime_error); + if save_issue == ProductsStockEditorSaveIssue::PublishQueueFailed { + editor.initial_stock_quantity = Some(stock_quantity); + } + editor.save_issue = Some(save_issue); } cx.notify(); } @@ -3433,6 +3439,9 @@ impl HomeView { product_id = %form.product_id, "failed to save product editor draft" ); + if runtime_error.is_listing_publish_sdk_enqueue_failed() { + form.initial_draft = draft; + } form.save_issue = Some(ProductEditorSaveIssue::from_runtime_error(&runtime_error)); cx.notify(); } @@ -6089,7 +6098,7 @@ struct ProductsStockEditorState { initial_stock_quantity: Option<u32>, input: Entity<InputState>, _input_subscription: Subscription, - save_failed: bool, + save_issue: Option<ProductsStockEditorSaveIssue>, } impl ProductsStockEditorState { @@ -6118,7 +6127,7 @@ impl ProductsStockEditorState { initial_stock_quantity: stock_quantity, input, _input_subscription: input_subscription, - save_failed: false, + save_issue: None, } } @@ -6133,6 +6142,29 @@ impl ProductsStockEditorState { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProductsStockEditorSaveIssue { + SaveFailed, + PublishQueueFailed, +} + +impl ProductsStockEditorSaveIssue { + fn from_runtime_error(error: &DesktopAppRuntimeProductStockUpdateError) -> Self { + if error.is_listing_publish_sdk_enqueue_failed() { + Self::PublishQueueFailed + } else { + Self::SaveFailed + } + } + + fn text_key(self) -> AppTextKey { + match self { + Self::SaveFailed => AppTextKey::ProductsStockEditorSaveFailed, + Self::PublishQueueFailed => AppTextKey::ProductsStockEditorPublishQueueFailed, + } + } +} + struct ProductEditorFormState { account_id: String, product_id: ProductId, @@ -15535,7 +15567,12 @@ fn products_stock_editor_card( cx: &App, ) -> impl IntoElement { let validation_key = products_stock_editor_validation_key(editor, cx); - let save_ready = editor.has_changes(cx) && editor.parsed_stock_quantity(cx).is_some(); + let save_ready = (editor.has_changes(cx) + || matches!( + editor.save_issue, + Some(ProductsStockEditorSaveIssue::PublishQueueFailed) + )) + && editor.parsed_stock_quantity(cx).is_some(); div() .w_full() @@ -15602,7 +15639,7 @@ fn products_stock_editor_card( .child(app_shared_text(key)), ) }) - .when(editor.save_failed, |this| { + .when_some(editor.save_issue, |this, issue| { this.child( div() .text_size(px(APP_UI_THEME @@ -15611,9 +15648,7 @@ fn products_stock_editor_card( .utility_title_text_px)) .line_height(relative(1.2)) .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) - .child(app_shared_text( - AppTextKey::ProductsStockEditorSaveFailed, - )), + .child(app_shared_text(issue.text_key())), ) }), ) @@ -15665,7 +15700,12 @@ fn products_editor_surface( cx: &mut Context<HomeView>, ) -> AnyElement { let validation_keys = products_editor_validation_keys(form, cx); - let save_ready = form.has_changes(cx) && validation_keys.is_empty(); + let save_ready = (form.has_changes(cx) + || matches!( + form.save_issue, + Some(ProductEditorSaveIssue::PublishQueueFailed) + )) + && validation_keys.is_empty(); let save_action = if save_ready { action_button_primary( diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -648,6 +648,7 @@ define_app_text_keys! { ProductsStockEditorCancelAction => "products.stock_editor.action.cancel", ProductsStockEditorInvalidQuantity => "products.stock_editor.invalid_quantity", ProductsStockEditorSaveFailed => "products.stock_editor.save_failed", + ProductsStockEditorPublishQueueFailed => "products.stock_editor.publish_queue_failed", ProductsStatusDraft => "products.status.draft", ProductsStatusLive => "products.status.live", ProductsStatusPaused => "products.status.paused", diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs @@ -93,8 +93,6 @@ pub enum AppSqliteError { DecodeEnum { field: &'static str, value: String }, #[error("invalid farm-rules projection: {reason}")] InvalidProjection { reason: &'static str }, - #[error("product publish operation failed: {reason}")] - ProductPublish { reason: &'static str }, #[error("failed to access shared local events store during {operation}")] LocalEventsSql { operation: &'static str, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -627,6 +627,7 @@ "products.stock_editor.action.cancel": "Cancel", "products.stock_editor.invalid_quantity": "Enter a whole number.", "products.stock_editor.save_failed": "Couldn't save stock. Try again.", + "products.stock_editor.publish_queue_failed": "Stock was saved, but publishing couldn't be queued. Try again.", "products.status.draft": "Draft", "products.status.live": "Live", "products.status.paused": "Paused",