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