commit 6d526c6eed09458c366ede7d3897185f160784e0
parent af42bf80c47d7c40ec38d04fd9f4e319a50c2acd
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 22:07:46 -0700
app: migrate farm and order publish to sdk
- route farm publish and buyer order request work through AppSdkRuntime
- record SDK migration receipts instead of local_outbox publish rows for migrated work
- tighten direct relay tests and source guards around migrated payloads
- align test bootstrap with SDK runtime recovery
Diffstat:
8 files changed, 906 insertions(+), 379 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -5136,7 +5136,10 @@ version = "0.1.0"
dependencies = [
"chrono",
"radroots_app_view",
+ "radroots_authority",
+ "radroots_events",
"radroots_local_events",
+ "radroots_nostr",
"radroots_runtime_paths",
"radroots_sdk",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
@@ -31,6 +31,7 @@ gpui-component = "0.5.1"
gpui-component-assets = "0.5.1"
mf2_i18n = { git = "https://github.com/triesap/mf2_i18n.git", rev = "e2ad58d5863d9dd98f2f38d1f08b2140bf34b0a1" }
radroots_core = { path = "../lib/crates/core", default-features = false, features = ["std"] }
+radroots_authority = { path = "../lib/crates/authority" }
radroots_events = { path = "../lib/crates/events", default-features = false, features = ["serde", "std"] }
radroots_events_codec = { path = "../lib/crates/events_codec", features = ["serde_json"] }
radroots_identity = { path = "../lib/crates/identity" }
diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs
@@ -9,8 +9,9 @@ use chrono::{DateTime, Duration, Utc};
use radroots_app_core::{
AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode,
AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkDiagnostics,
- AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime,
- AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths,
+ AppSdkFarmPublishRequest, AppSdkLifecycleState, AppSdkOrderSubmitRequest,
+ AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRuntime, AppSdkRuntimeError,
+ AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkStoragePaths, AppSdkWorkflowReceipt,
AppSharedAccountsPaths, PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root,
shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle,
};
@@ -18,7 +19,8 @@ use radroots_app_remote_signer::{
RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession,
};
use radroots_app_sqlite::{
- APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSqliteError, AppSqliteStore,
+ APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSdkMigrationReceiptInput,
+ AppSdkMigrationReceiptSourceKind, AppSdkMigrationState, AppSqliteError, AppSqliteStore,
BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome,
DatabaseTarget, SelectedBuyerOrderScope, SellerOrderDecisionExport, StoredPendingSyncOperation,
StoredRelayIngestCursor, StoredSyncConflict, derive_farm_rules_readiness,
@@ -114,8 +116,9 @@ use radroots_sdk::protocol::order::{
RadrootsOrderRevisionProposal,
};
use radroots_sdk::{
- RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, SdkPublishReceipt,
- SdkTransportMode, SdkTransportReceipt, SignerConfig,
+ FARM_PUBLISH_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND, RadrootsSdkClient, RadrootsSdkConfig,
+ RelayConfig, SdkEnvironment, SdkPublishReceipt, SdkTransportMode, SdkTransportReceipt,
+ SignerConfig,
};
use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::parse_public_listing_address;
@@ -524,7 +527,19 @@ impl DesktopAppRuntime {
};
match start_desktop_sdk_runtime(&paths, nostr_relay_urls) {
- Ok(sdk_runtime) => Self::from_state_with_sdk_runtime(state, sdk_runtime),
+ Ok(sdk_runtime) => {
+ let runtime = Self::from_state_with_sdk_runtime(state, sdk_runtime);
+ let _ = runtime.wait_for_sdk_startup(StdDuration::from_secs(5));
+ if let Err(error) = runtime.retry_pending_personal_order_coordination() {
+ error!(
+ target: "buyer_order",
+ event = "buyer_order.coordination_bootstrap_retry_failed",
+ error = %error,
+ "failed to retry pending buyer order coordination during bootstrap"
+ );
+ }
+ runtime
+ }
Err(error) => {
let mut state = state;
state.startup_issue = Some(error.to_string());
@@ -1252,19 +1267,22 @@ impl DesktopAppRuntime {
}
fn from_state(state: DesktopAppRuntimeState) -> Self {
+ let sdk_runtime = Arc::new(Mutex::new(None));
Self {
state: Arc::new(Mutex::new(state)),
- sdk_runtime: Arc::new(Mutex::new(None)),
+ sdk_runtime,
}
}
fn from_state_with_sdk_runtime(
- state: DesktopAppRuntimeState,
+ mut state: DesktopAppRuntimeState,
sdk_runtime: AppSdkRuntime,
) -> Self {
+ let sdk_runtime = Arc::new(Mutex::new(Some(sdk_runtime)));
+ state.sdk_runtime = Some(Arc::clone(&sdk_runtime));
Self {
state: Arc::new(Mutex::new(state)),
- sdk_runtime: Arc::new(Mutex::new(Some(sdk_runtime))),
+ sdk_runtime,
}
}
@@ -1631,6 +1649,7 @@ struct DesktopAppRuntimeState {
remote_signer_paths: Option<DesktopRemoteSignerPaths>,
accounts_manager: Option<RadrootsNostrAccountsManager>,
sqlite_store: Option<AppSqliteStore>,
+ sdk_runtime: Option<Arc<Mutex<Option<AppSdkRuntime>>>>,
sync_transport: Box<dyn AppSyncTransport + Send>,
runtime_metadata: DesktopAppRuntimeMetadataSummary,
selected_account_pending_sync_write_count: usize,
@@ -1660,6 +1679,10 @@ impl fmt::Debug for DesktopAppRuntimeState {
"sqlite_store",
&self.sqlite_store.as_ref().map(|_| "available"),
)
+ .field(
+ "sdk_runtime",
+ &self.sdk_runtime.as_ref().map(|_| "available"),
+ )
.field("sync_transport", &"configured")
.field("runtime_metadata", &self.runtime_metadata)
.field(
@@ -1755,6 +1778,7 @@ impl DesktopAppRuntimeState {
remote_signer_paths: Some(remote_signer_paths),
accounts_manager: accounts_bootstrap.accounts_manager,
sqlite_store: Some(sqlite_store),
+ sdk_runtime: None,
sync_transport,
runtime_metadata: DesktopAppRuntimeMetadataSummary::available(
runtime_snapshot,
@@ -1768,14 +1792,6 @@ impl DesktopAppRuntimeState {
startup_issue: None,
};
let _ = state.apply_selected_account_context(&selected_account_context);
- if let Err(error) = state.retry_pending_personal_order_coordination() {
- error!(
- target: "buyer_order",
- event = "buyer_order.coordination_bootstrap_retry_failed",
- error = %error,
- "failed to retry pending buyer order coordination during bootstrap"
- );
- }
Ok(state)
}
@@ -1797,6 +1813,7 @@ impl DesktopAppRuntimeState {
remote_signer_paths: None,
accounts_manager: None,
sqlite_store: None,
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::unavailable(runtime_snapshot),
selected_account_pending_sync_write_count: 0,
@@ -4949,13 +4966,22 @@ impl DesktopAppRuntimeState {
order: &BuyerOrderLocalEventExport,
local_work: Option<&AppOrderLocalWorkPublishSource>,
) -> Result<bool, AppSqliteError> {
- let Some(operation) =
- self.order_request_publish_operation(buyer_context, order, local_work)?
+ let Some(payload) = self.order_request_publish_payload(buyer_context, order, local_work)?
else {
return self.refresh_selected_account_sync();
};
- self.enqueue_selected_account_sync_operation_once(operation)
+ let source_record_id = payload
+ .context
+ .source_local_event_id
+ .clone()
+ .unwrap_or_else(|| format!("app:order_request:{}", payload.order_id));
+ self.enqueue_order_request_payload_via_sdk(
+ &payload,
+ AppSdkMigrationReceiptSourceKind::SharedLocalEvent,
+ source_record_id.as_str(),
+ )?;
+ self.refresh_selected_account_sync()
}
fn enqueue_selected_account_farm_publish_operation(
@@ -4966,15 +4992,7 @@ impl DesktopAppRuntimeState {
source: &str,
source_local_event_id: Option<&str>,
) -> Result<bool, AppSqliteError> {
- let existing_source_local_event_id = if source_local_event_id.is_none() {
- self.selected_account_pending_farm_source_local_event_id(farm_id)?
- } else {
- None
- };
- let source_local_event_id =
- source_local_event_id.or(existing_source_local_event_id.as_deref());
-
- let Some(operation) = self.farm_profile_publish_operation(
+ let Some(payload) = self.farm_profile_publish_payload(
farm_id,
display_name,
readiness,
@@ -4985,49 +5003,17 @@ impl DesktopAppRuntimeState {
return self.refresh_selected_account_sync();
};
- self.enqueue_selected_account_sync_operations(vec![operation])
- }
-
- fn selected_account_pending_farm_source_local_event_id(
- &self,
- farm_id: FarmId,
- ) -> Result<Option<String>, AppSqliteError> {
- let Some(account_id) = self
- .state_store
- .identity_projection()
- .selected_account
- .as_ref()
- .map(|account| account.account.account_id.clone())
- else {
- return Ok(None);
- };
- let Some(sqlite_store) = self.sqlite_store.as_ref() else {
- return Ok(None);
- };
- let existing = sqlite_store.load_pending_sync_operations(account_id.as_str())?;
- existing
- .iter()
- .find(|pending| {
- pending.operation.aggregate == SyncAggregateRef::Farm(farm_id)
- && pending.operation.operation == SyncOperationKind::Upsert
- })
- .map(|pending| {
- pending
- .operation
- .publish_payload()
- .map_err(|_| AppSqliteError::InvalidProjection {
- reason: "farm profile publish payload must parse",
- })
- })
- .transpose()
- .map(|payload| {
- payload.and_then(|payload| match payload {
- AppPublishPayload::FarmProfile(payload) => {
- payload.context.source_local_event_id
- }
- _ => None,
- })
- })
+ let (source_kind, source_record_id) = farm_publish_source_record(
+ farm_id,
+ source,
+ payload.context.source_local_event_id.as_deref(),
+ );
+ self.enqueue_farm_profile_payload_via_sdk(
+ &payload,
+ source_kind,
+ source_record_id.as_str(),
+ )?;
+ self.refresh_selected_account_sync()
}
fn enqueue_selected_account_sync_operation_once(
@@ -5076,14 +5062,14 @@ impl DesktopAppRuntimeState {
self.enqueue_selected_account_sync_operations(vec![operation])
}
- fn farm_profile_publish_operation(
+ fn farm_profile_publish_payload(
&self,
farm_id: FarmId,
display_name: &str,
readiness: Option<FarmReadiness>,
source: &str,
source_local_event_id: Option<&str>,
- ) -> Result<Option<PendingSyncOperation>, AppSqliteError> {
+ ) -> Result<Option<AppFarmProfilePublishPayload>, AppSqliteError> {
let Some(selected_account) = self
.state_store
.identity_projection()
@@ -5099,21 +5085,20 @@ impl DesktopAppRuntimeState {
if let Some(source_local_event_id) = source_local_event_id {
context = context.with_source_local_event_id(source_local_event_id.to_owned());
}
- let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload {
+ let payload = AppFarmProfilePublishPayload {
context,
farm_id,
display_name: display_name.trim().to_owned(),
readiness,
- });
- if payload.validate().is_err() {
+ };
+ if AppPublishPayload::FarmProfile(payload.clone())
+ .validate()
+ .is_err()
+ {
return Ok(None);
}
- PendingSyncOperation::from_publish_payload(payload, current_utc_timestamp())
- .map(Some)
- .map_err(|_| AppSqliteError::InvalidProjection {
- reason: "farm profile publish payload must serialize",
- })
+ Ok(Some(payload))
}
fn product_publish_operation(
@@ -5198,12 +5183,12 @@ impl DesktopAppRuntimeState {
})
}
- fn order_request_publish_operation(
+ fn order_request_publish_payload(
&self,
buyer_context: &BuyerContext,
order: &BuyerOrderLocalEventExport,
local_work: Option<&AppOrderLocalWorkPublishSource>,
- ) -> Result<Option<PendingSyncOperation>, AppSqliteError> {
+ ) -> Result<Option<AppOrderRequestPublishPayload>, AppSqliteError> {
let Some(local_work) = local_work else {
return Ok(None);
};
@@ -5223,7 +5208,7 @@ impl DesktopAppRuntimeState {
"place_personal_order",
)
.with_source_local_event_id(local_work.record_id.clone());
- let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload {
+ let payload = AppOrderRequestPublishPayload {
context,
order_id: order.order_id,
farm_id: order.farm_id,
@@ -5245,16 +5230,209 @@ impl DesktopAppRuntimeState {
currency_code: Some(currency_code),
total_minor_units: Some(total_minor_units),
note: non_empty_string(order.buyer_order_note.as_str()),
- });
- if payload.validate().is_err() {
+ };
+ if AppPublishPayload::OrderRequest(payload.clone())
+ .validate()
+ .is_err()
+ {
return Ok(None);
}
- PendingSyncOperation::from_publish_payload(payload, current_utc_timestamp())
- .map(Some)
- .map_err(|_| AppSqliteError::InvalidProjection {
- reason: "order request publish payload must serialize",
- })
+ Ok(Some(payload))
+ }
+
+ fn enqueue_farm_profile_payload_via_sdk(
+ &self,
+ payload: &AppFarmProfilePublishPayload,
+ source_kind: AppSdkMigrationReceiptSourceKind,
+ source_record_id: &str,
+ ) -> Result<(), AppSqliteError> {
+ let operation_kind = FARM_PUBLISH_OPERATION_KIND;
+ let actor_pubkey = self
+ .local_signing_identity_for_publish_payload(&AppPublishPayload::FarmProfile(
+ payload.clone(),
+ ))
+ .and_then(|identity| {
+ let actor_pubkey = identity.public_key_hex();
+ let request = AppSdkFarmPublishRequest {
+ actor_account_id: payload.context.account_id.clone(),
+ actor_pubkey: actor_pubkey.clone(),
+ signer_keys: identity.into_keys(),
+ farm: farm_profile_publish_payload_to_sdk_farm(payload),
+ target_relays: normalized_app_sync_relay_urls(&self.nostr_relay_urls)?,
+ relay_url_policy: sdk_relay_url_policy_for_targets(&self.nostr_relay_urls),
+ idempotency_key: Some(sdk_idempotency_key(source_record_id)),
+ };
+ self.enqueue_app_sdk_farm_publish(request)
+ .map(|receipt| (actor_pubkey, receipt))
+ .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,
+ ),
+ Err(error) => self.record_app_sdk_migration_failure(
+ source_kind,
+ source_record_id,
+ operation_kind,
+ None,
+ sync_transport_error_detail_json(&error),
+ ),
+ }
+ }
+
+ fn enqueue_order_request_payload_via_sdk(
+ &self,
+ payload: &AppOrderRequestPublishPayload,
+ source_kind: AppSdkMigrationReceiptSourceKind,
+ source_record_id: &str,
+ ) -> Result<(), AppSqliteError> {
+ let operation_kind = ORDER_SUBMIT_OPERATION_KIND;
+ let actor_pubkey = self
+ .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRequest(
+ payload.clone(),
+ ))
+ .and_then(|identity| {
+ let actor_pubkey = identity.public_key_hex();
+ let target_relays =
+ order_request_sdk_target_relays(payload, self.nostr_relay_urls.as_slice())?;
+ let request = AppSdkOrderSubmitRequest {
+ actor_account_id: payload.context.account_id.clone(),
+ actor_pubkey: actor_pubkey.clone(),
+ signer_keys: identity.into_keys(),
+ listing_event: order_request_sdk_listing_event_ptr(payload)?,
+ order: order_request_publish_payload_to_sdk_order(payload)?,
+ relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()),
+ target_relays,
+ idempotency_key: Some(sdk_idempotency_key(source_record_id)),
+ };
+ self.enqueue_app_sdk_order_submit(request)
+ .map(|receipt| (actor_pubkey, receipt))
+ .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,
+ ),
+ Err(error) => self.record_app_sdk_migration_failure(
+ source_kind,
+ source_record_id,
+ operation_kind,
+ None,
+ sync_transport_error_detail_json(&error),
+ ),
+ }
+ }
+
+ fn local_signing_identity_for_publish_payload(
+ &self,
+ payload: &AppPublishPayload,
+ ) -> Result<RadrootsIdentity, AppSyncTransportError> {
+ let accounts_manager = self.accounts_manager.as_ref().ok_or_else(|| {
+ AppSyncTransportError::unavailable("app account manager is not configured")
+ })?;
+ signing_identity_for_publish_payload(accounts_manager, payload)
+ }
+
+ fn enqueue_app_sdk_farm_publish(
+ &self,
+ request: AppSdkFarmPublishRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.with_app_sdk_runtime(|runtime| runtime.enqueue_farm_publish(request))
+ }
+
+ fn enqueue_app_sdk_order_submit(
+ &self,
+ request: AppSdkOrderSubmitRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_submit(request))
+ }
+
+ fn with_app_sdk_runtime<T>(
+ &self,
+ command: impl FnOnce(&AppSdkRuntime) -> Result<T, AppSdkRuntimeError>,
+ ) -> Result<T, AppSdkRuntimeError> {
+ let Some(handle) = self.sdk_runtime.as_ref() else {
+ return Err(sdk_runtime_unavailable_error());
+ };
+ let sdk_runtime = handle.lock().unwrap_or_else(PoisonError::into_inner);
+ let Some(runtime) = sdk_runtime.as_ref() else {
+ return Err(sdk_runtime_unavailable_error());
+ };
+ command(runtime)
+ }
+
+ fn record_app_sdk_migration_success(
+ &self,
+ source_kind: AppSdkMigrationReceiptSourceKind,
+ source_record_id: &str,
+ operation_kind: &str,
+ actor_pubkey: &str,
+ receipt: &AppSdkWorkflowReceipt,
+ ) -> Result<(), AppSqliteError> {
+ let detail_json = json!({
+ "operation_kind": receipt.operation_kind,
+ "expected_event_id": receipt.expected_event_id,
+ "signed_event_id": receipt.signed_event_id,
+ "outbox_operation_id": receipt.outbox_operation_id,
+ "outbox_event_id": receipt.outbox_event_id,
+ "state": receipt.state,
+ });
+ self.record_app_sdk_migration_receipt(AppSdkMigrationReceiptInput {
+ source_kind,
+ source_record_id: source_record_id.to_owned(),
+ sdk_operation_kind: operation_kind.to_owned(),
+ sdk_outbox_event_ids: vec![receipt.outbox_event_id.to_string()],
+ expected_event_id: Some(receipt.expected_event_id.clone()),
+ actor_pubkey: Some(actor_pubkey.to_owned()),
+ idempotency_digest_prefix: receipt.idempotency_digest_prefix.clone(),
+ migration_state: AppSdkMigrationState::Enqueued,
+ recorded_at: current_utc_timestamp(),
+ detail_json,
+ })
+ }
+
+ fn record_app_sdk_migration_failure(
+ &self,
+ source_kind: AppSdkMigrationReceiptSourceKind,
+ source_record_id: &str,
+ operation_kind: &str,
+ actor_pubkey: Option<&str>,
+ detail_json: serde_json::Value,
+ ) -> Result<(), AppSqliteError> {
+ self.record_app_sdk_migration_receipt(AppSdkMigrationReceiptInput {
+ source_kind,
+ source_record_id: source_record_id.to_owned(),
+ sdk_operation_kind: operation_kind.to_owned(),
+ sdk_outbox_event_ids: Vec::new(),
+ expected_event_id: None,
+ actor_pubkey: actor_pubkey.map(str::to_owned),
+ idempotency_digest_prefix: None,
+ migration_state: AppSdkMigrationState::Failed,
+ recorded_at: current_utc_timestamp(),
+ detail_json,
+ })
+ }
+
+ fn record_app_sdk_migration_receipt(
+ &self,
+ input: AppSdkMigrationReceiptInput,
+ ) -> Result<(), AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(());
+ };
+ let _ = sqlite_store
+ .sdk_migration_receipt_repository()
+ .record_receipt(&input)?;
+ Ok(())
}
fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> {
@@ -7408,6 +7586,159 @@ fn listing_fulfillment_location(
.or_else(|| non_empty_string(farm_setup.draft.location_or_service_area.as_str()))
}
+fn farm_profile_publish_payload_to_sdk_farm(
+ payload: &AppFarmProfilePublishPayload,
+) -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: d_tag_from_uuid(payload.farm_id.as_uuid()),
+ name: payload.display_name.trim().to_owned(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: payload.readiness.map(|readiness| match readiness {
+ FarmReadiness::Incomplete => vec!["radroots:readiness:incomplete".to_owned()],
+ FarmReadiness::Ready => vec!["radroots:readiness:ready".to_owned()],
+ }),
+ }
+}
+
+fn farm_publish_source_record(
+ farm_id: FarmId,
+ source: &str,
+ source_local_event_id: Option<&str>,
+) -> (AppSdkMigrationReceiptSourceKind, String) {
+ source_local_event_id
+ .map(|record_id| {
+ (
+ AppSdkMigrationReceiptSourceKind::SharedLocalEvent,
+ record_id.to_owned(),
+ )
+ })
+ .unwrap_or_else(|| {
+ (
+ AppSdkMigrationReceiptSourceKind::LocalOutbox,
+ format!("app:farm_publish:{farm_id}:{source}"),
+ )
+ })
+}
+
+fn sdk_relay_url_policy_for_targets(target_relays: &[String]) -> AppSdkRelayUrlPolicy {
+ if target_relays
+ .iter()
+ .any(|relay_url| relay_url.trim().starts_with("ws://"))
+ {
+ AppSdkRelayUrlPolicy::Localhost
+ } else {
+ AppSdkRelayUrlPolicy::Public
+ }
+}
+
+fn sdk_idempotency_key(source_record_id: &str) -> String {
+ format!(
+ "app-{}",
+ Uuid::new_v5(&Uuid::NAMESPACE_URL, source_record_id.as_bytes())
+ )
+}
+
+fn sdk_runtime_unavailable_error() -> AppSdkRuntimeError {
+ AppSdkRuntimeError::CommandFailed(AppSdkRuntimeIssue {
+ code: "sdk_runtime_not_available".to_owned(),
+ class: "runtime".to_owned(),
+ retryable: true,
+ message: "app SDK runtime is not available".to_owned(),
+ recovery_actions: vec!["retry_startup".to_owned()],
+ detail_json: json!({
+ "code": "sdk_runtime_not_available",
+ "class": "runtime",
+ "retryable": true,
+ "recovery_actions": ["retry_startup"],
+ }),
+ })
+}
+
+fn sync_transport_error_from_sdk_runtime_error(error: AppSdkRuntimeError) -> AppSyncTransportError {
+ AppSyncTransportError::failed(sdk_runtime_error_detail_json(&error).to_string())
+}
+
+fn sdk_runtime_error_detail_json(error: &AppSdkRuntimeError) -> serde_json::Value {
+ match error {
+ AppSdkRuntimeError::CommandFailed(issue) => issue.detail_json.clone(),
+ AppSdkRuntimeError::CommandQueueCapacityZero => json!({
+ "code": "sdk_command_queue_capacity_zero",
+ "class": "runtime",
+ "retryable": false,
+ "message": error.to_string(),
+ "recovery_actions": ["review_runtime_configuration"],
+ }),
+ AppSdkRuntimeError::WorkerSpawn(_) => json!({
+ "code": "sdk_worker_spawn_failed",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["retry_startup"],
+ }),
+ AppSdkRuntimeError::CommandQueueFull => json!({
+ "code": "sdk_command_queue_full",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["retry_command"],
+ }),
+ AppSdkRuntimeError::CommandQueueClosed => json!({
+ "code": "sdk_command_queue_closed",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["restart_runtime"],
+ }),
+ AppSdkRuntimeError::CommandResponseClosed => json!({
+ "code": "sdk_command_response_closed",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["restart_runtime"],
+ }),
+ AppSdkRuntimeError::ShutdownAck => json!({
+ "code": "sdk_shutdown_ack_failed",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["restart_runtime"],
+ }),
+ AppSdkRuntimeError::WorkerJoin => json!({
+ "code": "sdk_worker_join_failed",
+ "class": "runtime",
+ "retryable": true,
+ "message": error.to_string(),
+ "recovery_actions": ["restart_runtime"],
+ }),
+ }
+}
+
+fn sync_transport_error_detail_json(error: &AppSyncTransportError) -> serde_json::Value {
+ match error {
+ AppSyncTransportError::Unavailable { message } => json!({
+ "code": "app_sync_transport_unavailable",
+ "class": "runtime",
+ "retryable": true,
+ "message": message,
+ "recovery_actions": ["retry_after_runtime_ready"],
+ }),
+ AppSyncTransportError::Failed { message } => serde_json::from_str(message.as_str())
+ .unwrap_or_else(|_| {
+ json!({
+ "code": "app_sync_transport_failed",
+ "class": "operation",
+ "retryable": true,
+ "message": message,
+ "recovery_actions": ["retry_publish"],
+ })
+ }),
+ }
+}
+
fn direct_relay_sdk_client(
relay_urls: Vec<String>,
timeout_ms: u64,
@@ -7440,31 +7771,12 @@ async fn publish_app_payload(
client: &RadrootsSdkClient,
identity: &RadrootsIdentity,
payload: &AppPublishPayload,
- configured_relay_urls: &[String],
+ _configured_relay_urls: &[String],
) -> Result<SdkPublishReceipt, AppSyncTransportError> {
match payload {
- AppPublishPayload::FarmProfile(payload) => {
- let farm = RadrootsFarm {
- d_tag: d_tag_from_uuid(payload.farm_id.as_uuid()),
- name: payload.display_name.trim().to_owned(),
- about: None,
- website: None,
- picture: None,
- banner: None,
- location: None,
- tags: payload.readiness.map(|readiness| match readiness {
- FarmReadiness::Incomplete => {
- vec!["radroots:readiness:incomplete".to_owned()]
- }
- FarmReadiness::Ready => vec!["radroots:readiness:ready".to_owned()],
- }),
- };
- client
- .farm()
- .publish_with_identity(identity, &farm)
- .await
- .map_err(|error| AppSyncTransportError::failed(error.to_string()))
- }
+ AppPublishPayload::FarmProfile(_) => Err(AppSyncTransportError::failed(
+ "farm profile publish uses AppSdkRuntime",
+ )),
AppPublishPayload::Listing(payload) => {
let listing = listing_publish_payload_to_sdk_listing(payload)?;
client
@@ -7473,15 +7785,9 @@ async fn publish_app_payload(
.await
.map_err(|error| AppSyncTransportError::failed(error.to_string()))
}
- AppPublishPayload::OrderRequest(payload) => {
- let listing_event = order_request_listing_event_ptr(payload, configured_relay_urls)?;
- let order = order_request_publish_payload_to_sdk_order(payload)?;
- client
- .order()
- .publish_order_request_with_identity(identity, &listing_event, &order)
- .await
- .map_err(|error| AppSyncTransportError::failed(error.to_string()))
- }
+ AppPublishPayload::OrderRequest(_) => Err(AppSyncTransportError::failed(
+ "order request publish uses AppSdkRuntime",
+ )),
AppPublishPayload::OrderDecision(payload) => {
let decision = order_decision_publish_payload_to_sdk_decision(payload)?;
let request_event_id = publish_event_id(payload.request_event_id.as_str())?;
@@ -7759,9 +8065,8 @@ fn parse_app_listing_delivery_method(
}
}
-fn order_request_listing_event_ptr(
+fn order_request_sdk_listing_event_ptr(
payload: &AppOrderRequestPublishPayload,
- configured_relay_urls: &[String],
) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> {
let listing_event_id = payload
.listing_event_id
@@ -7771,18 +8076,61 @@ fn order_request_listing_event_ptr(
})?
.trim()
.to_owned();
- let listing_relay = selected_listing_relay(&payload.listing_relays, configured_relay_urls)?;
+ let listing_relay = normalized_listing_relays(payload.listing_relays.as_slice())?
+ .into_iter()
+ .next();
Ok(RadrootsNostrEventPtr {
id: listing_event_id,
- relays: Some(listing_relay),
+ relays: listing_relay,
})
}
+fn order_request_sdk_target_relays(
+ payload: &AppOrderRequestPublishPayload,
+ configured_relay_urls: &[String],
+) -> Result<Vec<String>, AppSyncTransportError> {
+ let known_relays = normalized_listing_relays(payload.listing_relays.as_slice())?;
+ let configured_relays = configured_relay_urls
+ .iter()
+ .map(|relay| relay.trim())
+ .filter(|relay| !relay.is_empty())
+ .map(str::to_owned)
+ .collect::<BTreeSet<_>>();
+ if configured_relays.is_empty() {
+ return Ok(known_relays);
+ }
+ let selected_relays = known_relays
+ .iter()
+ .filter(|relay| configured_relays.contains(*relay))
+ .cloned()
+ .collect::<Vec<_>>();
+ if selected_relays.is_empty() {
+ return Ok(known_relays);
+ }
+ Ok(selected_relays)
+}
+
+#[cfg(test)]
fn selected_listing_relay(
listing_relays: &[String],
configured_relay_urls: &[String],
) -> Result<String, AppSyncTransportError> {
+ let known_relays = normalized_listing_relays(listing_relays)?;
+ for configured_relay in configured_relay_urls {
+ let configured_relay = configured_relay.trim();
+ if !configured_relay.is_empty()
+ && known_relays.iter().any(|relay| relay == configured_relay)
+ {
+ return Ok(configured_relay.to_owned());
+ }
+ }
+ Err(missing_listing_provenance_relay_error(&known_relays))
+}
+
+fn normalized_listing_relays(
+ listing_relays: &[String],
+) -> Result<Vec<String>, AppSyncTransportError> {
let mut seen = BTreeSet::new();
let mut known_relays = Vec::new();
for relay in listing_relays {
@@ -7796,17 +8144,10 @@ fn selected_listing_relay(
"order request publish requires listing relay",
));
}
- for configured_relay in configured_relay_urls {
- let configured_relay = configured_relay.trim();
- if !configured_relay.is_empty()
- && known_relays.iter().any(|relay| relay == configured_relay)
- {
- return Ok(configured_relay.to_owned());
- }
- }
- Err(missing_listing_provenance_relay_error(&known_relays))
+ Ok(known_relays)
}
+#[cfg(test)]
fn missing_listing_provenance_relay_error(known_relays: &[String]) -> AppSyncTransportError {
AppSyncTransportError::failed(
json!({
@@ -10087,8 +10428,9 @@ mod tests {
RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
};
use radroots_app_sqlite::{
- AppSqliteError, AppSqliteStore, BuyerOrderCoordinationState, DatabaseTarget,
- latest_schema_version, projected_order_id_from_trade_request,
+ AppSdkMigrationReceiptSourceKind, AppSdkMigrationState, AppSqliteError, AppSqliteStore,
+ BuyerOrderCoordinationState, DatabaseTarget, latest_schema_version,
+ projected_order_id_from_trade_request,
};
use radroots_app_state::{
APP_STATE_FILE_NAME, AppStateCommand, AppStatePersistenceRepository, AppStateRepository,
@@ -10139,10 +10481,9 @@ mod tests {
};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityId};
use radroots_local_events::{
- BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, CANONICAL_RELAY_SET_FINGERPRINT_VERSION,
- LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily,
- LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime,
- canonical_relay_set_fingerprint,
+ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalEventRecordInput,
+ LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus,
+ RelayDeliveryEvidence, SourceRuntime,
};
use radroots_nostr::prelude::radroots_nostr_build_event;
use radroots_nostr_accounts::prelude::{
@@ -10150,6 +10491,7 @@ mod tests {
RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault,
account_secret_slot,
};
+ use radroots_sdk::ORDER_SUBMIT_OPERATION_KIND;
use radroots_sdk::protocol::events::{
RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr,
};
@@ -10187,6 +10529,9 @@ mod tests {
execute_pack_day_batch_print_plan_with, prepared_customer_label_asset_root,
};
+ const BUYER_VISIBLE_SELLER_PUBKEY: &str =
+ "2222222222222222222222222222222222222222222222222222222222222222";
+
#[derive(Clone)]
struct SharedRecordedSyncTransport(Arc<Mutex<RecordedAppSyncTransport>>);
@@ -10379,6 +10724,46 @@ mod tests {
assert_eq!(value["missing_provenance_relays"], json!([relay_url]));
}
+ fn assert_migrated_payload_uses_sdk_runtime(
+ error: AppSyncTransportError,
+ expected_message: &str,
+ ) {
+ match error {
+ AppSyncTransportError::Failed { message } => assert_eq!(message, expected_message),
+ unexpected => panic!("unexpected migrated payload error: {unexpected}"),
+ }
+ }
+
+ fn direct_relay_listing_payload(
+ account_id: &str,
+ farm_pubkey: String,
+ source: &str,
+ ) -> AppPublishPayload {
+ let farm_id = FarmId::new();
+ let product_id = ProductId::new();
+ AppPublishPayload::Listing(AppListingPublishPayload {
+ context: AppPublishContext::new(account_id.to_owned(), source),
+ product_id,
+ listing_d_tag: Some(super::d_tag_from_uuid(product_id.as_uuid())),
+ farm_id: Some(farm_id),
+ farm_pubkey: Some(farm_pubkey),
+ farm_d_tag: Some(super::d_tag_from_uuid(farm_id.as_uuid())),
+ title: "North field eggs".to_owned(),
+ subtitle: Some("Pasture raised".to_owned()),
+ category: Some("eggs".to_owned()),
+ unit_label: "each".to_owned(),
+ price_minor_units: Some(750),
+ price_currency: "USD".to_owned(),
+ stock_quantity: Some(12),
+ availability_window_id: Some(FulfillmentWindowId::new()),
+ availability_starts_at: Some("2099-05-25T14:00:00Z".to_owned()),
+ availability_ends_at: Some("2099-05-25T18:00:00Z".to_owned()),
+ fulfillment_method: Some("pickup".to_owned()),
+ fulfillment_location: Some("farmstand".to_owned()),
+ status: ProductStatus::Published,
+ })
+ }
+
impl Drop for ThreadedAckRelay {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
@@ -10506,46 +10891,18 @@ mod tests {
vec![relay_a.url().to_owned(), relay_b.url().to_owned()],
);
- let result = transport
+ let error = transport
.sync(AppSyncRequest {
trigger: SyncTrigger::ManualRefresh,
checkpoint: SyncCheckpointStatus::never_synced(),
pending_operations: vec![operation],
known_conflicts: Vec::new(),
})
- .expect("direct relay farm publish should succeed");
+ .expect_err("direct relay farm publish should use AppSdkRuntime");
- assert_eq!(result.run_status, AppSyncRunStatus::Succeeded);
- assert_eq!(result.pushed_operation_count, 1);
- assert_eq!(result.published_receipts.len(), 1);
- assert_eq!(result.published_receipts[0].event_kind, 30340);
- assert_eq!(
- result.published_receipts[0].source_account_id,
- account_id.to_string()
- );
- assert_eq!(
- result.published_receipts[0].relay_set_fingerprint,
- canonical_relay_set_fingerprint([relay_a.url(), relay_b.url()]).expect("fingerprint")
- );
- assert!(
- result.published_receipts[0]
- .relay_set_fingerprint
- .starts_with(CANONICAL_RELAY_SET_FINGERPRINT_VERSION)
- );
- assert_eq!(
- result.published_receipts[0]
- .source_local_event_id
- .as_deref(),
- Some("app:local_work:farm:direct")
- );
- assert_eq!(
- result.published_receipts[0].relay_delivery_json["acknowledged_relays"],
- json!([relay_a.url(), relay_b.url()])
- );
- assert_eq!(
- result.published_receipts[0].relay_delivery_json["state"],
- json!("acknowledged")
- );
+ assert_migrated_payload_uses_sdk_runtime(error, "farm profile publish uses AppSdkRuntime");
+ assert_eq!(relay_a.event_count(), 0);
+ assert_eq!(relay_b.event_count(), 0);
}
#[test]
@@ -10693,30 +11050,17 @@ mod tests {
let mut transport =
SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]);
- let result = transport
+ let error = transport
.sync(AppSyncRequest {
trigger: SyncTrigger::ManualRefresh,
checkpoint: SyncCheckpointStatus::never_synced(),
pending_operations: vec![operation],
known_conflicts: Vec::new(),
})
- .expect("direct relay order request publish should succeed");
+ .expect_err("direct relay order request publish should use AppSdkRuntime");
- assert_eq!(result.run_status, AppSyncRunStatus::Succeeded);
- assert_eq!(result.pushed_operation_count, 1);
- assert_eq!(result.published_receipts.len(), 1);
- assert_eq!(result.published_receipts[0].event_kind, 3422);
- assert_eq!(
- result.published_receipts[0].event_pubkey,
- buyer_identity.public_key_hex()
- );
- assert_eq!(
- result.published_receipts[0]
- .source_local_event_id
- .as_deref(),
- Some("app:local_work:order_request:direct")
- );
- assert_eq!(relay.event_count(), 1);
+ assert_migrated_payload_uses_sdk_runtime(error, "order request publish uses AppSdkRuntime");
+ assert_eq!(relay.event_count(), 0);
}
#[test]
@@ -11104,12 +11448,6 @@ mod tests {
Some(seller_pubkey.as_str()),
listing_d_tag.as_str(),
);
- let farm_payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload {
- context: AppPublishContext::new(account_id.to_string(), "relay_ingest_farm"),
- farm_id,
- display_name: "Relay test farm".to_owned(),
- readiness: Some(FarmReadiness::Ready),
- });
let listing_payload = AppPublishPayload::Listing(AppListingPublishPayload {
context: AppPublishContext::new(account_id.to_string(), "relay_ingest_listing"),
product_id,
@@ -11139,11 +11477,6 @@ mod tests {
checkpoint: SyncCheckpointStatus::never_synced(),
pending_operations: vec![
PendingSyncOperation::from_publish_payload(
- farm_payload,
- "2026-05-25T07:00:00Z",
- )
- .expect("farm publish payload should serialize"),
- PendingSyncOperation::from_publish_payload(
listing_payload,
"2026-05-25T07:00:01Z",
)
@@ -11153,7 +11486,7 @@ mod tests {
})
.expect("seller relay publish should succeed");
assert_eq!(result.run_status, AppSyncRunStatus::Succeeded);
- assert_eq!(result.published_receipts.len(), 2);
+ assert_eq!(result.published_receipts.len(), 1);
projected_product_id
}
@@ -11196,7 +11529,7 @@ mod tests {
.find(|listing| listing.product_id == projected_product_id)
.expect("fresh buyer app should project relay listing");
assert_eq!(listing.title, "Relay ingest lettuce");
- assert_eq!(listing.farm_display_name, "Relay test farm");
+ assert_eq!(listing.farm_display_name, "Local farm");
assert_eq!(listing.listing_relays, vec![relay_url.to_owned()]);
let relay_ingest = runtime
.lock_state()
@@ -11407,16 +11740,18 @@ mod tests {
let account_id = manager
.generate_identity(Some("Farmer".to_owned()), true)
.expect("local signing account should generate");
- let farm_id = FarmId::new();
- let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload {
- context: AppPublishContext::new(account_id.to_string(), "farm_setup"),
- farm_id,
- display_name: "North field farm".to_owned(),
- readiness: Some(FarmReadiness::Ready),
- });
+ let identity = manager
+ .get_signing_identity(&account_id)
+ .expect("farmer signer lookup should succeed")
+ .expect("farmer account should have local signer");
+ let payload = direct_relay_listing_payload(
+ account_id.to_string().as_str(),
+ identity.public_key_hex(),
+ "listing_publish",
+ );
let successful_operation =
PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z")
- .expect("typed farm publish work should serialize");
+ .expect("typed listing publish work should serialize");
let unsupported_operation = PendingSyncOperation::new(
SyncAggregateRef::Product(ProductId::new()),
SyncOperationKind::Delete,
@@ -11552,9 +11887,9 @@ mod tests {
pending_operations: vec![operation],
known_conflicts: Vec::new(),
})
- .expect_err("missing listing provenance relay should fail");
+ .expect_err("direct relay order request should use AppSdkRuntime");
- assert_missing_listing_provenance_relay_error(&error, "wss://listing.example");
+ assert_migrated_payload_uses_sdk_runtime(error, "order request publish uses AppSdkRuntime");
assert_eq!(relay.event_count(), 0);
}
@@ -11576,14 +11911,13 @@ mod tests {
.get_signing_identity(&second_account_id)
.expect("second signer")
.expect("second local signer");
- let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload {
- context: AppPublishContext::new(first_account_id.to_string(), "farm_setup"),
- farm_id: FarmId::new(),
- display_name: "North field farm".to_owned(),
- readiness: Some(FarmReadiness::Ready),
- });
+ let payload = direct_relay_listing_payload(
+ first_account_id.to_string().as_str(),
+ first_identity.public_key_hex(),
+ "listing_publish",
+ );
let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z")
- .expect("typed farm publish work should serialize");
+ .expect("typed listing publish work should serialize");
let mut transport =
SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]);
@@ -11784,6 +12118,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -11839,6 +12174,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -13827,6 +14163,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -13859,6 +14196,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -14269,6 +14607,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -14372,6 +14711,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -14426,6 +14766,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -14464,6 +14805,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -14500,6 +14842,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -16349,7 +16692,7 @@ mod tests {
"dozen-eggs",
);
let product_id =
- deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key);
+ deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key);
assert!(
runtime
@@ -16389,51 +16732,16 @@ mod tests {
.expect("buyer order should place")
);
let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id;
- let order_farm_id = runtime
- .summary()
- .personal_projection
- .orders
- .detail
- .as_ref()
- .expect("buyer order detail")
- .farm_id;
- let pending_payload = assert_single_order_request_publish_payload(
+ assert_no_order_request_pending_sync_payloads(
&runtime,
buyer_account_id.as_str(),
order_id,
- order_farm_id,
- "needs_action",
- );
- assert_eq!(
- pending_payload.context.source_local_event_id.as_deref(),
- Some(format!("app:local_work:order_request:{order_id}").as_str())
- );
- assert_eq!(
- pending_payload.listing_addr.as_deref(),
- Some(format!("30402:buyer-visible-seller-pubkey:{listing_key}").as_str())
- );
- assert_eq!(
- pending_payload.listing_event_id.as_deref(),
- Some(signed_event_id("cli:signed_event:buyer-order-supported-listing").as_str())
- );
- assert_eq!(pending_payload.listing_relays, vec!["ws://127.0.0.1:1234/"]);
- assert_eq!(
- pending_payload.seller_pubkey.as_deref(),
- Some("buyer-visible-seller-pubkey")
);
- assert!(
- pending_payload
- .buyer_pubkey
- .as_deref()
- .is_some_and(is_hex_64)
+ assert_order_request_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
);
- assert_eq!(pending_payload.items.len(), 1);
- assert_eq!(pending_payload.items[0].product_id, product_id);
- assert_eq!(pending_payload.items[0].quantity, 2);
- assert_eq!(pending_payload.currency_code.as_deref(), Some("USD"));
- assert_eq!(pending_payload.total_minor_units, Some(1600));
- assert_eq!(pending_payload.note.as_deref(), Some("Leave by the cooler"));
- assert!(pending_payload.order_document_json.is_some());
{
let state = runtime.lock_state_mut();
@@ -16503,7 +16811,7 @@ mod tests {
assert!(order_record.owner_pubkey.as_deref().is_some_and(is_hex_64));
assert_eq!(
order_record.listing_addr.as_deref(),
- Some(format!("30402:buyer-visible-seller-pubkey:{listing_key}").as_str())
+ Some(format!("30402:{BUYER_VISIBLE_SELLER_PUBKEY}:{listing_key}").as_str())
);
let payload = order_record
.local_work_json
@@ -16528,7 +16836,7 @@ mod tests {
);
assert_eq!(
payload["document"]["order"]["seller_pubkey"],
- "buyer-visible-seller-pubkey"
+ BUYER_VISIBLE_SELLER_PUBKEY
);
assert_eq!(
payload["document"]["order"]["items"][0]["bin_id"],
@@ -16565,7 +16873,7 @@ mod tests {
#[test]
fn runtime_buyer_order_shared_append_failure_is_recoverable_in_same_session() {
- let (runtime, paths, buyer_account_id, order_id, order_farm_id) =
+ let (runtime, paths, buyer_account_id, order_id) =
blocked_buyer_order_runtime("buyer_order_append_failure_same_session");
{
let state = runtime.lock_state_mut();
@@ -16591,12 +16899,15 @@ mod tests {
.orders
.has_recoverable_coordination
);
- assert_single_order_request_publish_payload(
+ assert_no_order_request_pending_sync_payloads(
&runtime,
buyer_account_id.as_str(),
order_id,
- order_farm_id,
- "scheduled",
+ );
+ assert_order_request_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
);
assert_eq!(
summary_after_retry
@@ -16650,12 +16961,15 @@ mod tests {
.retry_pending_personal_order_coordination()
.expect("same-session synced buyer order recovery retry should be idempotent")
);
- assert_single_order_request_publish_payload(
+ assert_no_order_request_pending_sync_payloads(
&runtime,
buyer_account_id.as_str(),
order_id,
- order_farm_id,
- "scheduled",
+ );
+ assert_order_request_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
);
cleanup_bootstrapped_runtime_paths(&paths);
@@ -16663,7 +16977,7 @@ mod tests {
#[test]
fn runtime_buyer_order_shared_append_failure_is_recoverable_after_restart() {
- let (runtime, paths, buyer_account_id, order_id, order_farm_id) =
+ let (runtime, paths, buyer_account_id, order_id) =
blocked_buyer_order_runtime("buyer_order_append_failure_restart");
unblock_shared_local_events_database(&paths);
drop(runtime);
@@ -16716,12 +17030,15 @@ mod tests {
.retry_pending_personal_order_coordination()
.expect("synced buyer order recovery retry should be idempotent")
);
- assert_single_order_request_publish_payload(
+ assert_no_order_request_pending_sync_payloads(
&restarted_runtime,
buyer_account_id.as_str(),
order_id,
- order_farm_id,
- "needs_action",
+ );
+ assert_order_request_sdk_migration_receipt(
+ &restarted_runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
);
cleanup_bootstrapped_runtime_paths(&paths);
@@ -16730,7 +17047,7 @@ mod tests {
#[test]
fn runtime_outbox_recovery_buyer_order_shared_append_failure_is_recoverable_on_foreground_resume()
{
- let (runtime, paths, buyer_account_id, order_id, order_farm_id) =
+ let (runtime, paths, buyer_account_id, order_id) =
blocked_buyer_order_runtime("buyer_order_append_failure_foreground_resume");
unblock_shared_local_events_database(&paths);
assert!(
@@ -16749,12 +17066,15 @@ mod tests {
buyer_order_local_work_record_ids(&paths),
vec![format!("app:local_work:order_request:{order_id}")]
);
- assert_single_order_request_publish_payload(
+ assert_no_order_request_pending_sync_payloads(
&runtime,
buyer_account_id.as_str(),
order_id,
- order_farm_id,
- "needs_action",
+ );
+ assert_order_request_sdk_migration_receipt(
+ &runtime,
+ order_id,
+ AppSdkMigrationState::Enqueued,
);
{
let state = runtime.lock_state_mut();
@@ -20662,6 +20982,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -20698,6 +21019,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -20732,6 +21054,7 @@ mod tests {
AppSqliteStore::open(DatabaseTarget::InMemory)
.expect("in-memory sqlite store should open"),
),
+ sdk_runtime: None,
sync_transport: default_sync_transport(),
runtime_metadata: DesktopAppRuntimeMetadataSummary::default(),
selected_account_pending_sync_write_count: 0,
@@ -20750,13 +21073,10 @@ mod tests {
}
fn restart_runtime(paths: AppDesktopRuntimePaths) -> DesktopAppRuntime {
- DesktopAppRuntime::from_state(
- DesktopAppRuntimeState::bootstrap_from_paths(
- paths,
- vec!["ws://127.0.0.1:8080".to_owned()],
- super::default_runtime_snapshot(),
- )
- .expect("runtime bootstrap should succeed"),
+ DesktopAppRuntime::bootstrap_from_paths_with_snapshot(
+ paths.clone(),
+ vec!["ws://127.0.0.1:8080".to_owned()],
+ super::default_runtime_snapshot(),
)
}
@@ -20944,7 +21264,7 @@ mod tests {
let store = LocalEventsStore::new(executor);
store.migrate_up().expect("migrate shared local events");
let farm_key = "CCCCCCCCCCCCCCCCCCCCCC";
- let owner_pubkey = "buyer-visible-seller-pubkey";
+ let owner_pubkey = BUYER_VISIBLE_SELLER_PUBKEY;
let record_id = format!("cli:signed_event:{record_suffix}");
let content = json!({
"d_tag": listing_key,
@@ -22267,7 +22587,7 @@ mod tests {
1100,
);
let product_id =
- deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key);
+ deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key);
assert!(
runtime
@@ -22479,44 +22799,44 @@ mod tests {
.collect()
}
- fn pending_order_request_publish_payloads(
+ fn assert_no_order_request_pending_sync_payloads(
runtime: &DesktopAppRuntime,
account_id: &str,
order_id: OrderId,
- ) -> Vec<AppOrderRequestPublishPayload> {
- pending_order_sync_payloads(runtime, account_id, order_id)
- .into_iter()
- .map(|payload_json| {
- match serde_json::from_str::<AppPublishPayload>(payload_json.as_str())
- .expect("pending order payload should be typed app publish work")
- {
- AppPublishPayload::OrderRequest(payload) => payload,
- payload => panic!("expected order request publish payload, got {payload:?}"),
- }
- })
- .collect()
+ ) {
+ assert!(pending_order_sync_payloads(runtime, account_id, order_id).is_empty());
}
- fn assert_single_order_request_publish_payload(
+ fn assert_order_request_sdk_migration_receipt(
runtime: &DesktopAppRuntime,
- account_id: &str,
order_id: OrderId,
- farm_id: FarmId,
- status: &str,
- ) -> AppOrderRequestPublishPayload {
- let pending_payloads =
- pending_order_request_publish_payloads(runtime, account_id, order_id);
- assert_eq!(pending_payloads.len(), 1);
- let payload = pending_payloads
- .into_iter()
- .next()
- .expect("single order request publish payload");
- assert_eq!(payload.context.account_id, account_id);
- assert_eq!(payload.context.source, "place_personal_order");
- assert_eq!(payload.order_id, order_id);
- assert_eq!(payload.farm_id, farm_id);
- assert_eq!(payload.status.as_deref(), Some(status));
- payload
+ expected_state: AppSdkMigrationState,
+ ) {
+ let source_record_id = format!("app:local_work:order_request:{order_id}");
+ let receipt = runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .sdk_migration_receipt_repository()
+ .load_receipt(
+ AppSdkMigrationReceiptSourceKind::SharedLocalEvent,
+ source_record_id.as_str(),
+ )
+ .expect("SDK migration receipt should load")
+ .expect("SDK migration receipt should exist");
+ assert_eq!(receipt.source_record_id, source_record_id);
+ assert_eq!(receipt.sdk_operation_kind, ORDER_SUBMIT_OPERATION_KIND);
+ assert_eq!(
+ receipt.migration_state, expected_state,
+ "receipt detail: {}",
+ receipt.detail_json
+ );
+ if expected_state == AppSdkMigrationState::Enqueued {
+ assert!(receipt.expected_event_id.is_some());
+ assert!(receipt.actor_pubkey.as_deref().is_some_and(is_hex_64));
+ assert!(!receipt.sdk_outbox_event_ids.is_empty());
+ }
}
fn buyer_order_local_work_record_ids(paths: &AppDesktopRuntimePaths) -> Vec<String> {
@@ -22536,13 +22856,7 @@ mod tests {
fn blocked_buyer_order_runtime(
label: &str,
- ) -> (
- DesktopAppRuntime,
- AppDesktopRuntimePaths,
- String,
- OrderId,
- FarmId,
- ) {
+ ) -> (DesktopAppRuntime, AppDesktopRuntimePaths, String, OrderId) {
let (runtime, paths) = bootstrapped_runtime(label);
let _ = install_recorded_sync_transport(
&runtime,
@@ -22578,7 +22892,7 @@ mod tests {
1100,
);
let product_id =
- deterministic_cli_listing_product_id(Some("buyer-visible-seller-pubkey"), listing_key);
+ deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key);
assert!(
runtime
.open_personal_product_detail(PersonalSection::Browse, product_id)
@@ -22634,7 +22948,6 @@ mod tests {
.as_ref()
.expect("buyer order detail should remain visible after coordination failure");
assert_eq!(order_detail.order_id, visible_order_id);
- let order_farm_id = order_detail.farm_id;
{
let state = runtime.lock_state_mut();
let buyer_context = state.state_store.identity_projection().buyer_context();
@@ -22660,13 +22973,7 @@ mod tests {
.is_empty()
);
- (
- runtime,
- paths,
- buyer_account_id,
- visible_order_id,
- order_farm_id,
- )
+ (runtime, paths, buyer_account_id, visible_order_id)
}
fn block_shared_local_events_database(paths: &AppDesktopRuntimePaths) {
diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs
@@ -1346,22 +1346,15 @@ const LEGACY_SDK_BOUNDARY_ALLOWLIST: &[LegacySdkBoundaryAllowlistEntry] = &[
path: "crates/desktop/src/runtime.rs",
pattern: "PendingSyncOperation::from_publish_payload",
owner: "rpv1-app-sdk-refactor.07",
- reason: "desktop runtime still creates legacy local outbox publish work for unmigrated workflows",
- removal_condition: "remove when app publish workflows write SDK canonical outbox requests instead of app local_outbox operations",
+ reason: "desktop runtime still creates legacy local outbox publish work for unmigrated listing and order lifecycle workflows",
+ removal_condition: "remove when listing and remaining order lifecycle publish workflows write SDK canonical outbox requests instead of app local_outbox operations",
},
LegacySdkBoundaryAllowlistEntry {
path: "crates/desktop/src/runtime.rs",
pattern: "publish_with_identity",
owner: "rpv1-app-sdk-refactor.07",
- reason: "desktop runtime still calls legacy direct SDK farm and listing publish APIs",
- removal_condition: "remove when farm profile and listing publish workflows enqueue through AppSdkRuntime",
- },
- LegacySdkBoundaryAllowlistEntry {
- path: "crates/desktop/src/runtime.rs",
- pattern: "publish_order_request_with_identity",
- owner: "rpv1-app-sdk-refactor.07",
- reason: "desktop runtime still calls legacy direct SDK order request publish APIs",
- removal_condition: "remove when buyer order request publish workflow enqueues through AppSdkRuntime",
+ reason: "desktop runtime still calls legacy direct SDK listing publish APIs",
+ removal_condition: "remove when listing publish workflow enqueues through AppSdkRuntime",
},
LegacySdkBoundaryAllowlistEntry {
path: "crates/desktop/src/runtime.rs",
@@ -1444,15 +1437,8 @@ const LEGACY_SDK_BOUNDARY_ALLOWLIST: &[LegacySdkBoundaryAllowlistEntry] = &[
path: "crates/sync/src/publish.rs",
pattern: "publish_draft_with_identity",
owner: "rpv1-app-sdk-refactor.07",
- reason: "sync payload metadata still names legacy farm and listing SDK publish operations",
- removal_condition: "remove when farm and listing publish payload metadata is replaced by SDK canonical outbox requests",
- },
- LegacySdkBoundaryAllowlistEntry {
- path: "crates/sync/src/publish.rs",
- pattern: "publish_order_request_with_identity",
- owner: "rpv1-app-sdk-refactor.07",
- reason: "sync payload metadata still names legacy order request SDK publish operations",
- removal_condition: "remove when buyer order request publish payload metadata is replaced by SDK canonical outbox requests",
+ reason: "sync payload metadata still names legacy listing SDK publish operations",
+ removal_condition: "remove when listing publish payload metadata is replaced by SDK canonical outbox requests",
},
LegacySdkBoundaryAllowlistEntry {
path: "crates/sync/src/publish.rs",
diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml
@@ -9,8 +9,11 @@ publish = false
[dependencies]
chrono.workspace = true
+radroots_authority.workspace = true
+radroots_events.workspace = true
radroots_app_view.workspace = true
radroots_local_events.workspace = true
+radroots_nostr.workspace = true
radroots_runtime_paths.workspace = true
radroots_sdk.workspace = true
serde.workspace = true
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
@@ -37,13 +37,13 @@ pub use runtime::{
};
pub use sdk::{
APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, APP_SDK_STORAGE_DIR_NAME, AppSdkConfig,
- AppSdkDiagnostics, AppSdkEventStoreDiagnostics, AppSdkIntegrityDiagnostics,
- AppSdkLifecycleState, AppSdkOutboxDiagnostics, AppSdkProjectionLifecycleState,
- AppSdkProjectionLifecycleStatus, AppSdkRelayUrlPolicy, AppSdkRestorePreflightReceipt,
- AppSdkRestorePreflightRequest, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue,
- AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics, AppSdkStorageDiagnostics,
- AppSdkStoragePaths, AppSdkSyncDiagnostics, AppSdkSyncEventStoreDiagnostics,
- AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics,
- app_sdk_storage_root_from_data_root,
+ AppSdkDiagnostics, AppSdkEventStoreDiagnostics, AppSdkFarmPublishRequest,
+ AppSdkIntegrityDiagnostics, AppSdkLifecycleState, 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
@@ -10,12 +10,20 @@ use std::{
time::{Duration, Instant},
};
+use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
+use radroots_events::{
+ RadrootsNostrEventPtr, contract::RadrootsActorRole, farm::RadrootsFarm,
+ order::RadrootsOrderRequest,
+};
+use radroots_nostr::prelude::RadrootsNostrKeys;
use radroots_sdk::{
- IntegrityReceipt, IntegrityRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkStoragePaths,
- RestoreReceipt, RestoreRequest, SdkBackupVerification,
- SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt, StorageStatusRequest,
- SyncStatusReceipt, SyncStatusRequest,
+ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, IntegrityReceipt,
+ IntegrityRequest, ORDER_SUBMIT_OPERATION_KIND, OrderSubmitEnqueueRequest, OrderSubmitReceipt,
+ RadrootsSdk, RadrootsSdkError, RadrootsSdkStoragePaths, RestoreReceipt, RestoreRequest,
+ SdkBackupVerification, SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt,
+ StorageStatusRequest, SyncStatusReceipt, SyncStatusRequest,
};
+use radroots_sdk::{SdkMutationState, SdkRelayTargetPolicy};
use serde::Serialize;
use serde_json::{Value, json};
use thiserror::Error;
@@ -182,6 +190,39 @@ pub struct AppSdkRestorePreflightRequest {
pub overwrite_existing_sdk_storage: bool,
}
+pub struct AppSdkFarmPublishRequest {
+ pub actor_account_id: String,
+ pub actor_pubkey: String,
+ pub signer_keys: RadrootsNostrKeys,
+ pub farm: RadrootsFarm,
+ 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,
+ pub signer_keys: RadrootsNostrKeys,
+ pub listing_event: RadrootsNostrEventPtr,
+ pub order: RadrootsOrderRequest,
+ pub target_relays: Vec<String>,
+ pub relay_url_policy: AppSdkRelayUrlPolicy,
+ pub idempotency_key: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AppSdkWorkflowReceipt {
+ pub operation_kind: String,
+ pub expected_event_id: String,
+ pub signed_event_id: String,
+ pub outbox_operation_id: i64,
+ pub outbox_event_id: i64,
+ pub state: String,
+ pub idempotency_digest_prefix: Option<String>,
+ pub actor_pubkey: String,
+}
+
#[derive(Clone, Debug, PartialEq)]
pub struct AppSdkRestorePreflightReceipt {
pub source: PathBuf,
@@ -262,6 +303,14 @@ enum AppSdkWorkerCommand {
AppSdkRestorePreflightRequest,
mpsc::Sender<Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeIssue>>,
),
+ EnqueueFarmPublish(
+ AppSdkFarmPublishRequest,
+ mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
+ ),
+ EnqueueOrderSubmit(
+ AppSdkOrderSubmitRequest,
+ mpsc::Sender<Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue>>,
+ ),
BeginProjectionRebuild(
mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
),
@@ -278,6 +327,8 @@ impl fmt::Debug for AppSdkWorkerCommand {
Self::SyncStatus(_) => formatter.write_str("SyncStatus"),
Self::Diagnostics(_) => formatter.write_str("Diagnostics"),
Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"),
+ Self::EnqueueFarmPublish(_, _) => formatter.write_str("EnqueueFarmPublish"),
+ Self::EnqueueOrderSubmit(_, _) => formatter.write_str("EnqueueOrderSubmit"),
Self::BeginProjectionRebuild(_) => formatter.write_str("BeginProjectionRebuild"),
Self::CompleteProjectionRebuild(_) => formatter.write_str("CompleteProjectionRebuild"),
}
@@ -399,6 +450,24 @@ impl AppSdkRuntime {
})
}
+ pub fn enqueue_farm_publish(
+ &self,
+ request: AppSdkFarmPublishRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.run_command(|response_sender| {
+ AppSdkWorkerCommand::EnqueueFarmPublish(request, response_sender)
+ })
+ }
+
+ pub fn enqueue_order_submit(
+ &self,
+ request: AppSdkOrderSubmitRequest,
+ ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> {
+ self.run_command(|response_sender| {
+ AppSdkWorkerCommand::EnqueueOrderSubmit(request, response_sender)
+ })
+ }
+
pub fn begin_projection_rebuild(
&self,
) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
@@ -864,6 +933,28 @@ fn run_app_sdk_worker(
};
send_worker_result(&shared, response_sender, result);
}
+ AppSdkWorkerCommand::EnqueueFarmPublish(request, response_sender) => {
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => enqueue_farm_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)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => enqueue_order_submit_with_sdk(&runtime, sdk, request),
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
+ };
+ send_worker_result(&shared, response_sender, result);
+ }
AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
let result = match sdk.as_ref() {
Some(_) => Ok(begin_projection_rebuild(&shared)),
@@ -931,6 +1022,20 @@ fn run_degraded_worker(
Err(runtime_unavailable_issue(&shared)),
);
}
+ AppSdkWorkerCommand::EnqueueFarmPublish(_, response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
+ AppSdkWorkerCommand::EnqueueOrderSubmit(_, response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
send_worker_result(
&shared,
@@ -1018,6 +1123,121 @@ async fn collect_sdk_diagnostics(
})
}
+fn enqueue_farm_publish_with_sdk(
+ runtime: &tokio::runtime::Runtime,
+ sdk: &RadrootsSdk,
+ request: AppSdkFarmPublishRequest,
+) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
+ let actor = sdk_actor_context(
+ request.actor_pubkey.as_str(),
+ request.actor_account_id.as_str(),
+ RadrootsActorRole::Farmer,
+ )?;
+ let signer = sdk_local_signer(request.signer_keys)?;
+ let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
+ let mut enqueue = FarmEnqueuePublishRequest::new(actor, request.farm, 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.farms().enqueue_publish(enqueue, &signer))
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
+ Ok(app_sdk_farm_receipt(receipt, request.actor_pubkey))
+}
+
+fn enqueue_order_submit_with_sdk(
+ runtime: &tokio::runtime::Runtime,
+ sdk: &RadrootsSdk,
+ request: AppSdkOrderSubmitRequest,
+) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeIssue> {
+ let actor = sdk_actor_context(
+ request.actor_pubkey.as_str(),
+ request.actor_account_id.as_str(),
+ RadrootsActorRole::Buyer,
+ )?;
+ let signer = sdk_local_signer(request.signer_keys)?;
+ let target_relays = sdk_relay_targets(request.target_relays, request.relay_url_policy)?;
+ let mut enqueue =
+ OrderSubmitEnqueueRequest::new(actor, request.listing_event, request.order, 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.orders().enqueue_submit(enqueue, &signer))
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))?;
+ Ok(app_sdk_order_receipt(receipt, request.actor_pubkey))
+}
+
+fn sdk_actor_context(
+ actor_pubkey: &str,
+ actor_account_id: &str,
+ role: RadrootsActorRole,
+) -> Result<RadrootsActorContext, AppSdkRuntimeIssue> {
+ RadrootsActorContext::local_account(actor_pubkey, actor_account_id.to_owned(), [role]).map_err(
+ |error| AppSdkRuntimeIssue::runtime_error("sdk_actor_context_invalid", error.to_string()),
+ )
+}
+
+fn sdk_local_signer(
+ keys: RadrootsNostrKeys,
+) -> Result<RadrootsLocalEventSigner, AppSdkRuntimeIssue> {
+ RadrootsLocalEventSigner::new(keys).map_err(|error| {
+ AppSdkRuntimeIssue::runtime_error("sdk_signer_init_failed", error.to_string())
+ })
+}
+
+fn sdk_relay_targets(
+ relays: Vec<String>,
+ policy: AppSdkRelayUrlPolicy,
+) -> Result<SdkRelayTargetPolicy, AppSdkRuntimeIssue> {
+ SdkRelayTargetPolicy::try_explicit(relays, policy.into())
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
+}
+
+fn app_sdk_farm_receipt(
+ receipt: FarmEnqueueReceipt,
+ actor_pubkey: String,
+) -> AppSdkWorkflowReceipt {
+ AppSdkWorkflowReceipt {
+ operation_kind: FARM_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,
+) -> AppSdkWorkflowReceipt {
+ AppSdkWorkflowReceipt {
+ operation_kind: ORDER_SUBMIT_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 sdk_mutation_state_key(state: SdkMutationState) -> &'static str {
+ match state {
+ SdkMutationState::StoredAndQueued => "enqueued",
+ SdkMutationState::AlreadyQueued => "already_queued",
+ _ => "unknown",
+ }
+}
+
fn send_worker_result<T>(
shared: &AppSdkRuntimeShared,
response_sender: mpsc::Sender<Result<T, AppSdkRuntimeIssue>>,
diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs
@@ -6,6 +6,7 @@ use radroots_sdk::protocol::order::{
RadrootsOrderEconomics, RadrootsOrderFulfillmentState, RadrootsOrderItem,
RadrootsOrderRevisionOutcome,
};
+use radroots_sdk::{FARM_PUBLISH_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -42,9 +43,9 @@ impl AppPublishWorkKind {
pub const fn sdk_operation(self) -> &'static str {
match self {
- Self::FarmProfile => "farm.publish_draft_with_identity",
+ Self::FarmProfile => FARM_PUBLISH_OPERATION_KIND,
Self::Listing => "listing.publish_draft_with_identity",
- Self::OrderRequest => "trade.publish_order_request_with_identity",
+ Self::OrderRequest => ORDER_SUBMIT_OPERATION_KIND,
Self::OrderDecision => "trade.publish_order_decision_with_identity",
Self::OrderRevisionProposal => "trade.publish_order_revision_proposal_with_identity",
Self::OrderRevisionDecision => "trade.publish_order_revision_decision_with_identity",
@@ -315,8 +316,17 @@ impl AppPublishPayload {
}
}
- pub const fn sdk_transport_mode(&self) -> SdkTransportMode {
- SdkTransportMode::RelayDirect
+ pub const fn legacy_sdk_transport_mode(&self) -> Option<SdkTransportMode> {
+ match self {
+ Self::FarmProfile(_) | Self::OrderRequest(_) => None,
+ Self::Listing(_)
+ | Self::OrderDecision(_)
+ | Self::OrderRevisionProposal(_)
+ | Self::OrderRevisionDecision(_)
+ | Self::OrderCancellation(_)
+ | Self::OrderFulfillment(_)
+ | Self::OrderReceipt(_) => Some(SdkTransportMode::RelayDirect),
+ }
}
pub const fn operation_kind(&self) -> SyncOperationKind {
@@ -789,7 +799,7 @@ mod tests {
AppOrderReceiptOutcome, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload,
AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload,
AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload,
- AppPublishValidationFailure, AppPublishWorkKind,
+ AppPublishValidationFailure, AppPublishWorkKind, FARM_PUBLISH_OPERATION_KIND,
};
use crate::{
PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind,
@@ -815,12 +825,9 @@ mod tests {
assert_eq!(payload.work_kind().storage_key(), "farm_profile");
assert_eq!(
payload.work_kind().sdk_operation(),
- "farm.publish_draft_with_identity"
- );
- assert_eq!(
- payload.sdk_transport_mode(),
- radroots_sdk::SdkTransportMode::RelayDirect
+ FARM_PUBLISH_OPERATION_KIND
);
+ assert_eq!(payload.legacy_sdk_transport_mode(), None);
assert_eq!(payload.validation_failures(), Vec::new());
let operation =