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