app

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

commit 4bafc5949810eaa6bd88471c45be1e156031a117
parent 7b24c1c8706571fedd28f6476a99afd3943c881a
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 14:18:01 -0700

runtime: propagate listing SDK enqueue failure

Diffstat:
Mcrates/desktop/src/runtime.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/desktop/src/window.rs | 49+++++++++++++++++++++++++++++++++++--------------
Mcrates/i18n/src/keys.rs | 1+
Mcrates/store/src/error.rs | 2++
Mi18n/locales/en/messages.json | 1+
5 files changed, 213 insertions(+), 23 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -164,6 +164,7 @@ 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; @@ -1036,7 +1037,7 @@ impl DesktopAppRuntime { pub fn save_product_editor_draft( &self, draft: ProductEditorDraft, - ) -> Result<bool, AppSqliteError> { + ) -> Result<bool, DesktopAppRuntimeProductEditorSaveError> { self.lock_state_mut().save_product_editor_draft(draft) } @@ -3941,7 +3942,7 @@ impl DesktopAppRuntimeState { fn save_product_editor_draft( &mut self, draft: ProductEditorDraft, - ) -> Result<bool, AppSqliteError> { + ) -> Result<bool, DesktopAppRuntimeProductEditorSaveError> { let Some(product_id) = self.selected_product_editor_id() else { return Ok(false); }; @@ -5216,13 +5217,18 @@ impl DesktopAppRuntimeState { actor_pubkey.as_str(), &receipt, ), - Err(error) => self.record_app_sdk_migration_failure( - source_kind, - source_record_id, - operation_kind, - None, - sync_transport_error_detail_json(&error), - ), + Err(error) => { + self.record_app_sdk_migration_failure( + source_kind, + source_record_id, + operation_kind, + None, + sync_transport_error_detail_json(&error), + )?; + Err(AppSqliteError::ProductPublish { + reason: LISTING_PUBLISH_SDK_ENQUEUE_FAILED_REASON, + }) + } } } @@ -7336,6 +7342,35 @@ pub enum DesktopAppRuntimeCommandError { PackDayBatchPrint(#[from] PackDayBatchPrintError), } +#[derive(Debug, Error)] +pub enum DesktopAppRuntimeProductEditorSaveError { + #[error(transparent)] + Sqlite(AppSqliteError), + #[error( + "product details were saved, but listing publish could not be queued through the SDK runtime" + )] + ListingPublishSdkEnqueueFailed, +} + +impl From<AppSqliteError> for DesktopAppRuntimeProductEditorSaveError { + fn from(error: AppSqliteError) -> Self { + match error { + AppSqliteError::ProductPublish { reason } + if reason == LISTING_PUBLISH_SDK_ENQUEUE_FAILED_REASON => + { + Self::ListingPublishSdkEnqueueFailed + } + error => Self::Sqlite(error), + } + } +} + +impl DesktopAppRuntimeProductEditorSaveError { + 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()) @@ -12691,6 +12726,136 @@ mod tests { } #[test] + fn runtime_product_publishable_save_returns_error_when_sdk_listing_enqueue_fails() { + let (runtime, paths) = bootstrapped_runtime("publishable_product_listing_sdk_failure"); + 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") + } + }; + assert!( + runtime + .shutdown_sdk_runtime() + .expect("sdk runtime should shut down") + ); + 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, + }; + + let error = runtime + .save_product_editor_draft(draft.clone()) + .expect_err("SDK listing enqueue failure should fail the save action"); + assert!(matches!( + error, + super::DesktopAppRuntimeProductEditorSaveError::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"), + Some(draft) + ); + + let records = shared_local_event_records(&paths); + let listing_record = records + .iter() + .find(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .expect("listing local work record"); + let receipt = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .sdk_migration_receipt_repository() + .load_receipt( + AppSdkMigrationReceiptSourceKind::SharedLocalEvent, + listing_record.record_id.as_str(), + ) + .expect("failed listing SDK migration receipt should load") + .expect("failed listing SDK migration receipt should exist"); + assert_eq!(receipt.source_record_id, listing_record.record_id); + assert_eq!(receipt.sdk_operation_kind, LISTING_PUBLISH_OPERATION_KIND); + assert_eq!(receipt.migration_state, AppSdkMigrationState::Failed); + assert!(receipt.sdk_outbox_event_ids.is_empty()); + assert!(receipt.expected_event_id.is_none()); + assert!(receipt.actor_pubkey.is_none()); + assert!(receipt.idempotency_digest_prefix.is_none()); + assert_eq!(receipt.detail_json["code"], "sdk_runtime_not_available"); + assert_eq!(receipt.detail_json["class"], "runtime"); + assert_eq!(receipt.detail_json["retryable"], true); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_product_stale_availability_save_records_blocker_without_publish_work() { let (runtime, paths) = bootstrapped_runtime("stale_product_listing_work"); let (account_id, farm_id) = provision_ready_farmer_account(&runtime); diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -102,8 +102,8 @@ use crate::pack_day_print::{ PackDayPrintError, execute_pack_day_batch_print_plan, execute_pack_day_print_plan, }; use crate::runtime::{ - DesktopAppRuntime, DesktopAppRuntimeSummary, DesktopAppSdkDiagnosticsState, - DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, + DesktopAppRuntime, DesktopAppRuntimeProductEditorSaveError, DesktopAppRuntimeSummary, + DesktopAppSdkDiagnosticsState, DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, }; @@ -3371,8 +3371,8 @@ impl HomeView { return; } - if form.save_failed { - form.save_failed = false; + if form.save_issue.is_some() { + form.save_issue = None; } cx.notify(); @@ -3392,7 +3392,7 @@ impl HomeView { } form.selected_availability_window_id = Some(availability_window_id); - form.save_failed = false; + form.save_issue = None; cx.notify(); } @@ -3406,7 +3406,7 @@ impl HomeView { } form.status = status; - form.save_failed = false; + form.save_issue = None; cx.notify(); } @@ -3421,7 +3421,7 @@ impl HomeView { match self.runtime.save_product_editor_draft(draft.clone()) { Ok(true) => { form.initial_draft = draft; - form.save_failed = false; + form.save_issue = None; cx.notify(); } Ok(false) => {} @@ -3433,7 +3433,7 @@ impl HomeView { product_id = %form.product_id, "failed to save product editor draft" ); - form.save_failed = true; + form.save_issue = Some(ProductEditorSaveIssue::from_runtime_error(&runtime_error)); cx.notify(); } } @@ -6151,7 +6151,30 @@ struct ProductEditorFormState { _unit_subscription: Subscription, _price_subscription: Subscription, _stock_subscription: Subscription, - save_failed: bool, + save_issue: Option<ProductEditorSaveIssue>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProductEditorSaveIssue { + SaveFailed, + PublishQueueFailed, +} + +impl ProductEditorSaveIssue { + fn from_runtime_error(error: &DesktopAppRuntimeProductEditorSaveError) -> Self { + if error.is_listing_publish_sdk_enqueue_failed() { + Self::PublishQueueFailed + } else { + Self::SaveFailed + } + } + + fn text_key(self) -> AppTextKey { + match self { + Self::SaveFailed => AppTextKey::ProductsEditorSaveFailed, + Self::PublishQueueFailed => AppTextKey::ProductsEditorPublishQueueFailed, + } + } } impl ProductEditorFormState { @@ -6232,7 +6255,7 @@ impl ProductEditorFormState { _unit_subscription: unit_subscription, _price_subscription: price_subscription, _stock_subscription: stock_subscription, - save_failed: false, + save_issue: None, } } @@ -15738,10 +15761,8 @@ fn products_editor_surface( cx, )) .child(products_editor_publish_readiness_section(form, runtime, cx)) - .when(form.save_failed, |this| { - this.child(home_body_text(app_shared_text( - AppTextKey::ProductsEditorSaveFailed, - ))) + .when_some(form.save_issue, |this, issue| { + this.child(home_body_text(app_shared_text(issue.text_key()))) }) .child( div() diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -625,6 +625,7 @@ define_app_text_keys! { ProductsEditorCloseAction => "products.editor.action.close", ProductsEditorSaveAction => "products.editor.action.save", ProductsEditorSaveFailed => "products.editor.save_failed", + ProductsEditorPublishQueueFailed => "products.editor.publish_queue_failed", ProductsEditorInvalidPrice => "products.editor.invalid_price", ProductsEditorInvalidStock => "products.editor.invalid_stock", ProductsEditorPublishReadinessTitle => "products.editor.publish_readiness.title", diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs @@ -93,6 +93,8 @@ 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 @@ -604,6 +604,7 @@ "products.editor.action.close": "Close", "products.editor.action.save": "Save changes", "products.editor.save_failed": "Couldn't save product details. Try again.", + "products.editor.publish_queue_failed": "Product details were saved, but publishing couldn't be queued. Try again.", "products.editor.invalid_price": "Enter dollars and cents, for example 6.50.", "products.editor.invalid_stock": "Enter a whole number or leave blank.", "products.editor.publish_readiness.title": "Publish readiness",