app

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

commit 3e1416eb9f62025d7c7b2e53bb5d589252d7beb5
parent 1dce31d18b21b95607eace4704391a457e04ee34
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 03:28:13 -0700

runtime: add SDK listing enqueue command

Add the app SDK listing publish request, worker command, runtime method, SDK enqueue implementation, and workflow receipt mapping.

Cover the command with a runtime test that enqueues listing publish work into the SDK outbox without publishing.

Diffstat:
MCargo.lock | 1+
Mcrates/runtime/Cargo.toml | 3+++
Mcrates/runtime/src/lib.rs | 21+++++++++++----------
Mcrates/runtime/src/sdk.rs | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 231 insertions(+), 13 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5137,6 +5137,7 @@ dependencies = [ "chrono", "radroots_app_view", "radroots_authority", + "radroots_core", "radroots_events", "radroots_local_events", "radroots_nostr", diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml @@ -24,5 +24,8 @@ tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true +[dev-dependencies] +radroots_core.workspace = true + [lints] workspace = true diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs @@ -38,15 +38,16 @@ pub use runtime::{ pub use sdk::{ APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkDiagnostics, AppSdkEventStoreDiagnostics, AppSdkFarmPublishRequest, - AppSdkIntegrityDiagnostics, AppSdkLifecycleState, AppSdkOrderCancellationRequest, - AppSdkOrderDecisionRequest, AppSdkOrderFulfillmentUpdateRequest, - AppSdkOrderReceiptRecordRequest, AppSdkOrderRevisionDecisionRequest, - AppSdkOrderRevisionProposalRequest, AppSdkOrderSubmitRequest, AppSdkOutboxDiagnostics, - AppSdkProjectionLifecycleState, AppSdkProjectionLifecycleStatus, AppSdkRelayUrlPolicy, - AppSdkRestorePreflightReceipt, AppSdkRestorePreflightRequest, AppSdkRuntime, - AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics, - AppSdkStorageDiagnostics, AppSdkStoragePaths, AppSdkSyncDiagnostics, - AppSdkSyncEventStoreDiagnostics, AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics, - AppSdkWorkflowReceipt, app_sdk_storage_root_from_data_root, + AppSdkIntegrityDiagnostics, AppSdkLifecycleState, AppSdkListingPublishRequest, + AppSdkOrderCancellationRequest, AppSdkOrderDecisionRequest, + AppSdkOrderFulfillmentUpdateRequest, AppSdkOrderReceiptRecordRequest, + AppSdkOrderRevisionDecisionRequest, AppSdkOrderRevisionProposalRequest, + AppSdkOrderSubmitRequest, AppSdkOutboxDiagnostics, AppSdkProjectionLifecycleState, + AppSdkProjectionLifecycleStatus, AppSdkRelayUrlPolicy, AppSdkRestorePreflightReceipt, + AppSdkRestorePreflightRequest, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue, + AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics, AppSdkStorageDiagnostics, + AppSdkStoragePaths, AppSdkSyncDiagnostics, AppSdkSyncEventStoreDiagnostics, + AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics, AppSdkWorkflowReceipt, + app_sdk_storage_root_from_data_root, }; pub use startup::{AppStartupEvent, AppStartupEventMetadata, launch_startup_event}; diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs @@ -15,6 +15,7 @@ use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, contract::RadrootsActorRole, farm::RadrootsFarm, + listing::RadrootsListing, order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderFulfillmentUpdate, RadrootsOrderReceipt, RadrootsOrderRequest, RadrootsOrderRevisionDecision, @@ -24,7 +25,8 @@ use radroots_events::{ use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt, - IntegrityRequest, ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, + IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, + ListingEnqueueReceipt, ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, ORDER_FULFILLMENT_UPDATE_OPERATION_KIND, ORDER_RECEIPT_RECORD_OPERATION_KIND, ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND, OrderCancellationEnqueueRequest, OrderCancellationReceipt, @@ -214,6 +216,16 @@ pub struct AppSdkFarmPublishRequest { pub idempotency_key: Option<String>, } +pub struct AppSdkListingPublishRequest { + pub actor_account_id: String, + pub actor_pubkey: String, + pub signer_keys: RadrootsNostrKeys, + pub listing: RadrootsListing, + pub target_relays: Vec<String>, + pub relay_url_policy: AppSdkRelayUrlPolicy, + pub idempotency_key: Option<String>, +} + pub struct AppSdkOrderSubmitRequest { pub actor_account_id: String, pub actor_pubkey: String, @@ -398,6 +410,10 @@ enum AppSdkWorkerCommand { AppSdkFarmPublishRequest, mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>, ), + EnqueueListingPublish( + AppSdkListingPublishRequest, + mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>, + ), EnqueueOrderSubmit( AppSdkOrderSubmitRequest, mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>, @@ -443,6 +459,7 @@ impl fmt::Debug for AppSdkWorkerCommand { Self::Diagnostics(_) => formatter.write_str("Diagnostics"), Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"), Self::EnqueueFarmPublish(_, _) => formatter.write_str("EnqueueFarmPublish"), + Self::EnqueueListingPublish(_, _) => formatter.write_str("EnqueueListingPublish"), Self::EnqueueOrderSubmit(_, _) => formatter.write_str("EnqueueOrderSubmit"), Self::EnqueueOrderDecision(_, _) => formatter.write_str("EnqueueOrderDecision"), Self::EnqueueOrderRevisionProposal(_, _) => { @@ -588,6 +605,15 @@ impl AppSdkRuntime { }) } + pub fn enqueue_listing_publish( + &self, + request: AppSdkListingPublishRequest, + ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { + self.run_command(|response_sender| { + AppSdkWorkerCommand::EnqueueListingPublish(request, response_sender) + }) + } + pub fn enqueue_order_submit( &self, request: AppSdkOrderSubmitRequest, @@ -1127,6 +1153,17 @@ fn run_app_sdk_worker( }; send_worker_result(&shared, response_sender, result); } + AppSdkWorkerCommand::EnqueueListingPublish(request, response_sender) => { + let result = if let Some(issue) = lifecycle_busy_issue(&shared) { + Err(issue) + } else { + match sdk.as_ref() { + Some(sdk) => enqueue_listing_publish_with_sdk(&runtime, sdk, request), + None => Err(runtime_unavailable_issue(&shared)), + } + }; + send_worker_result(&shared, response_sender, result); + } AppSdkWorkerCommand::EnqueueOrderSubmit(request, response_sender) => { let result = if let Some(issue) = lifecycle_busy_issue(&shared) { Err(issue) @@ -1284,6 +1321,13 @@ fn run_degraded_worker( Err(runtime_unavailable_issue(&shared)), ); } + AppSdkWorkerCommand::EnqueueListingPublish(_, response_sender) => { + send_worker_result( + &shared, + response_sender, + Err(runtime_unavailable_issue(&shared)), + ); + } AppSdkWorkerCommand::EnqueueOrderSubmit(_, response_sender) => { send_worker_result( &shared, @@ -1444,6 +1488,30 @@ fn enqueue_farm_publish_with_sdk( Ok(app_sdk_farm_receipt(receipt, request.actor_pubkey)) } +fn enqueue_listing_publish_with_sdk( + runtime: &tokio::runtime::Runtime, + sdk: &RadrootsSdk, + request: AppSdkListingPublishRequest, +) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> { + let actor = sdk_actor_context( + request.actor_pubkey.as_str(), + request.actor_account_id.as_str(), + RadrootsActorRole::Seller, + )?; + let signer = sdk_local_signer(request.signer_keys)?; + let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?; + let mut enqueue = ListingEnqueuePublishRequest::new(actor, request.listing, target_relays); + if let Some(idempotency_key) = request.idempotency_key.as_deref() { + enqueue = enqueue + .try_with_idempotency_key(idempotency_key) + .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?; + } + let receipt = runtime + .block_on(sdk.listings().enqueue_publish(enqueue, &signer)) + .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?; + Ok(app_sdk_listing_receipt(receipt, request.actor_pubkey)) +} + fn enqueue_order_submit_with_sdk( runtime: &tokio::runtime::Runtime, sdk: &RadrootsSdk, @@ -1737,6 +1805,22 @@ fn app_sdk_farm_receipt( } } +fn app_sdk_listing_receipt( + receipt: ListingEnqueueReceipt, + actor_pubkey: String, +) -> AppSdkWorkflowReceipt { + AppSdkWorkflowReceipt { + operation_kind: LISTING_PUBLISH_OPERATION_KIND.to_owned(), + expected_event_id: receipt.expected_event_id.as_str().to_owned(), + signed_event_id: receipt.signed_event_id.as_str().to_owned(), + outbox_operation_id: receipt.outbox_operation_id, + outbox_event_id: receipt.outbox_event_id, + state: sdk_mutation_state_key(receipt.state).to_owned(), + idempotency_digest_prefix: receipt.idempotency_digest_prefix, + actor_pubkey, + } +} + fn app_sdk_order_receipt( receipt: OrderSubmitReceipt, actor_pubkey: String, @@ -1990,7 +2074,24 @@ mod tests { time::{Duration, SystemTime, UNIX_EPOCH}, }; - use radroots_sdk::{BackupRequest, RadrootsSdk, SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy}; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_events::{ + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, + }, + }; + use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; + use radroots_sdk::{ + BackupRequest, LISTING_PUBLISH_OPERATION_KIND, RadrootsSdk, + SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, + }; use crate::{ APP_RUNTIME_NAMESPACE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment, @@ -1998,12 +2099,15 @@ mod tests { }; use super::{ - APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState, + APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState, AppSdkListingPublishRequest, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRestorePreflightRequest, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeShared, AppSdkRuntimeStatus, AppSdkWorkerCommand, app_sdk_storage_root_from_data_root, transition_status_state, }; + const SDK_TEST_SELLER_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + #[test] fn sdk_config_uses_app_data_sdk_storage_root() { let paths = AppDesktopRuntimePaths::for_desktop( @@ -2094,6 +2198,51 @@ mod tests { } #[test] + fn sdk_runtime_enqueues_listing_publish_work() { + let storage_root = temp_storage_root("listing_enqueue"); + let config = AppSdkConfig::from_app_data_root( + storage_root + .parent() + .expect("storage root should have parent"), + vec!["ws://127.0.0.1:8080".to_owned()], + ); + let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start"); + assert_eq!( + runtime.wait_for_startup(Duration::from_secs(5)).state, + AppSdkLifecycleState::Ready + ); + let secret_key = RadrootsNostrSecretKey::from_hex(SDK_TEST_SELLER_SECRET_KEY_HEX) + .expect("secret key should parse"); + let signer_keys = RadrootsNostrKeys::new(secret_key); + let seller_pubkey = signer_keys.public_key().to_hex(); + + let receipt = runtime + .enqueue_listing_publish(AppSdkListingPublishRequest { + actor_account_id: "seller-account".to_owned(), + actor_pubkey: seller_pubkey.clone(), + signer_keys, + listing: test_listing(seller_pubkey.as_str()), + target_relays: vec!["ws://127.0.0.1:8080".to_owned()], + relay_url_policy: AppSdkRelayUrlPolicy::Localhost, + idempotency_key: Some("listing-enqueue-idempotency".to_owned()), + }) + .expect("listing publish should enqueue"); + + assert_eq!(receipt.operation_kind, LISTING_PUBLISH_OPERATION_KIND); + assert_eq!(receipt.actor_pubkey, seller_pubkey); + assert_eq!(receipt.state, "enqueued"); + assert!(!receipt.expected_event_id.is_empty()); + assert_eq!(receipt.expected_event_id, receipt.signed_event_id); + assert!(receipt.outbox_operation_id > 0); + assert!(receipt.outbox_event_id > 0); + assert!(receipt.idempotency_digest_prefix.is_some()); + let sync = runtime.sync_status().expect("sync diagnostics should load"); + assert_eq!(sync.outbox.ready_signed_events, 1); + runtime.shutdown().expect("sdk runtime should shut down"); + let _ = fs::remove_dir_all(storage_root); + } + + #[test] fn sdk_runtime_degrades_with_structured_sdk_error() { let storage_root = temp_storage_root("invalid_relay"); let config = AppSdkConfig::from_app_data_root( @@ -2319,4 +2468,68 @@ mod tests { .join(format!("radroots_app_sdk_runtime_{label}_{nanos}")) .join(APP_SDK_STORAGE_DIR_NAME) } + + fn test_listing(seller_pubkey: &str) -> RadrootsListing { + let bin_id = RadrootsInventoryBinId::parse("bin-1").expect("bin id"); + RadrootsListing { + d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAQ").expect("d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: seller_pubkey.to_owned(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + }, + product: RadrootsListingProduct { + key: "coffee".to_owned(), + title: "Coffee".to_owned(), + category: "coffee".to_owned(), + summary: Some("Single origin coffee".to_owned()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: bin_id.clone(), + bins: vec![RadrootsListingBin { + bin_id, + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".to_owned(), + city: None, + region: None, + country: Some("US".to_owned()), + lat: None, + lng: None, + geohash: None, + }), + images: None, + } + } }