commit 371276014e4be8f774661a61bfc4ee37a6c6e59f
parent 0ccd8f54b2faed1e1ba3bff575540051de0b5adb
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 14:54:04 -0700
listing: migrate publish to SDK runtime
Diffstat:
7 files changed, 715 insertions(+), 62 deletions(-)
diff --git a/src/cli/global.rs b/src/cli/global.rs
@@ -186,6 +186,7 @@ pub struct ListingMutationArgs {
pub idempotency_key: Option<String>,
pub signer_session_id: Option<String>,
pub print_event: bool,
+ pub offline: bool,
}
#[derive(Debug, Clone, Default)]
diff --git a/src/main.rs b/src/main.rs
@@ -404,6 +404,9 @@ fn validate_network_contract(
match request.context().network_mode {
OperationNetworkMode::Default => Ok(()),
OperationNetworkMode::Offline => {
+ if allows_offline_local_mutation(spec.operation_id) {
+ return Ok(());
+ }
if let NetworkRequirement::External {
dry_run_requires_network,
} = requirement
@@ -453,6 +456,10 @@ fn requires_pre_runtime_relay_target(operation_id: &str) -> bool {
!is_publish_mode_routed_operation(operation_id)
}
+fn allows_offline_local_mutation(operation_id: &str) -> bool {
+ matches!(operation_id, "listing.publish")
+}
+
fn validate_publish_mode_contract(
request: &TargetOperationRequest,
config: &RuntimeConfig,
diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs
@@ -13,8 +13,8 @@ use crate::ops::{
ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult,
ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult,
ListingUpdateRequest, ListingUpdateResult, ListingValidateRequest, ListingValidateResult,
- OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
- OperationResult, OperationResultData, OperationService,
+ OperationAdapterError, OperationNetworkMode, OperationRequest, OperationRequestData,
+ OperationRequestPayload, OperationResult, OperationResultData, OperationService,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
@@ -224,8 +224,8 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> {
}
let args = mutation_args(&request)?;
let config = mutation_config(self.config, &request);
- let view = crate::runtime::listing::publish(&config, &args).map_err(|error| {
- OperationAdapterError::runtime_failure(request.operation_id(), error)
+ let view = crate::runtime::listing::publish_via_sdk(&config, &args).map_err(|error| {
+ OperationAdapterError::sdk_adapter_failure(request.operation_id(), error)
})?;
mutation_result::<ListingPublishResult>(request.operation_id(), &view)
}
@@ -277,6 +277,7 @@ where
.or_else(|| string_input(request, "idempotency_key")),
signer_session_id: string_input(request, "signer_session_id"),
print_event: bool_input(request, "print_event").unwrap_or(false),
+ offline: matches!(request.context.network_mode, OperationNetworkMode::Offline),
})
}
@@ -335,12 +336,16 @@ where
}
fn listing_relay_unavailable(view: &ListingMutationView) -> bool {
- view.source == "direct Nostr relay publish · local key"
- && (view.reason.as_deref().is_some_and(|reason| {
- reason.contains("configured relay") || reason.contains("direct relay connection failed")
- }) || !view.target_relays.is_empty()
- || !view.connected_relays.is_empty()
- || !view.failed_relays.is_empty())
+ matches!(
+ view.source.as_str(),
+ "direct Nostr relay publish · local key" | "SDK listing publish · local key"
+ ) && (view.reason.as_deref().is_some_and(|reason| {
+ reason.contains("configured relay")
+ || reason.contains("direct relay connection failed")
+ || reason.contains("SDK relay publish")
+ }) || !view.target_relays.is_empty()
+ || !view.connected_relays.is_empty()
+ || !view.failed_relays.is_empty())
}
fn listing_app_record_export_result<R>(
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -4,12 +4,14 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
+use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_events::RadrootsNostrEvent;
+use radroots_events::contract::RadrootsActorRole;
use radroots_events::farm::RadrootsFarmRef;
use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT};
@@ -23,11 +25,18 @@ use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
use radroots_events_codec::wire::WireEventParts;
use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime};
-use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr};
+use radroots_nostr::prelude::{
+ RadrootsNostrEvent as SignedNostrEvent, RadrootsNostrKeys, radroots_event_from_nostr,
+};
use radroots_replica_db::{ReplicaSql, migrations};
use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
+use radroots_sdk::{
+ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest,
+ ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt,
+ PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy,
+};
use radroots_sql_core::SqliteExecutor;
-use radroots_trade::listing::validation::validate_listing_event;
+use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -50,6 +59,7 @@ use crate::runtime::local_events::{
list_shared_records_latest, mark_signed_event_acknowledged,
mark_signed_event_failed_for_publish_error, shared_local_events_db_path,
};
+use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_relay_url_policy};
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness,
@@ -67,6 +77,7 @@ const LISTING_SOURCE: &str = "local draft · local first";
const LISTING_READ_SOURCE: &str = "local replica · local first";
const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app";
const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key";
+const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · local key";
const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · deferred";
const LISTING_DRAFTS_DIR: &str = "listings/drafts";
const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
@@ -240,6 +251,13 @@ struct ListingMutationEventDraft {
}
#[derive(Debug, Clone)]
+struct SdkListingPublishInput {
+ canonical: CanonicalListingDraft,
+ actor: RadrootsActorContext,
+ document: RadrootsListingDraftDocumentV1,
+}
+
+#[derive(Debug, Clone)]
struct LoadedListingDraft {
file: PathBuf,
updated_at_unix: u64,
@@ -1735,11 +1753,362 @@ fn refresh_market_listing_if_needed(config: &RuntimeConfig) -> Result<(), Runtim
Ok(())
}
-pub fn publish(
+pub fn publish_via_sdk(
config: &RuntimeConfig,
args: &ListingMutationArgs,
-) -> Result<ListingMutationView, RuntimeError> {
- mutate(config, args, ListingMutationOperation::Publish)
+) -> Result<ListingMutationView, CliSdkAdapterError> {
+ let input = sdk_listing_publish_input(config, args)?;
+ if config.output.dry_run {
+ validate_local_listing_signer(config, &input.canonical)?;
+ let session = CliSdkSession::connect_memory(config)?;
+ let plan = session.sdk().listings().prepare_publish(
+ ListingPreparePublishRequest::from_document(
+ input.actor.clone(),
+ input.document.clone(),
+ ),
+ )?;
+ return Ok(sdk_prepared_publish_view(
+ config,
+ args,
+ &input.canonical,
+ plan,
+ ));
+ }
+
+ let session = CliSdkSession::connect(config)?;
+ let signer = sdk_listing_signer(config, &input.canonical)?;
+ let mut request = ListingEnqueuePublishRequest::from_document(
+ input.actor,
+ input.document,
+ SdkRelayTargetPolicy::UseConfiguredRelays,
+ );
+ if let Some(idempotency_key) = args.idempotency_key.as_deref() {
+ request = request.try_with_idempotency_key(idempotency_key)?;
+ }
+ let enqueue_receipt =
+ session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?;
+ let push_receipt = if args.offline {
+ None
+ } else {
+ Some(
+ session.block_on(
+ session.sdk().sync().push_outbox(
+ PushOutboxRequest::new()
+ .with_limit(1)
+ .with_relay_url_policy(sdk_relay_url_policy(config)),
+ ),
+ )?,
+ )
+ };
+ Ok(sdk_enqueued_publish_view(
+ config,
+ args,
+ &input.canonical,
+ enqueue_receipt,
+ push_receipt,
+ ))
+}
+
+fn sdk_listing_publish_input(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+) -> Result<SdkListingPublishInput, RuntimeError> {
+ let contents = fs::read_to_string(&args.file)?;
+ let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| {
+ RuntimeError::Config(format!(
+ "invalid listing draft {}: {error}",
+ args.file.display()
+ ))
+ })?;
+ let context = mutation_validation_context(config)?;
+ let canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| {
+ let issue = match error {
+ ListingDraftValidationError::MissingSellerAccount(issue) => {
+ return account::AccountRuntimeFailure::unresolved_with_detail(
+ format!("{} ({})", issue.message, issue.field),
+ json!({
+ "seller_actor_source": "listing_draft",
+ "listing_file": args.file.display().to_string(),
+ "actions": listing_bound_account_recovery_actions(args.file.as_path()),
+ }),
+ )
+ .into();
+ }
+ ListingDraftValidationError::Issue(issue) => issue,
+ };
+ RuntimeError::Config(format!(
+ "invalid listing draft {}: {} ({})",
+ args.file.display(),
+ issue.message,
+ issue.field
+ ))
+ })?;
+ ensure_listing_bound_account(config, &canonical, args.file.as_path())?;
+ let actor = RadrootsActorContext::local_account(
+ canonical.seller_pubkey.as_str(),
+ canonical.seller_account_id.clone(),
+ [RadrootsActorRole::Seller],
+ )
+ .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?;
+ let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone());
+ Ok(SdkListingPublishInput {
+ canonical,
+ actor,
+ document,
+ })
+}
+
+fn sdk_listing_signer(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+) -> Result<RadrootsLocalEventSigner, RuntimeError> {
+ let signing = resolve_listing_signing_identity(config, canonical)?;
+ let keys: RadrootsNostrKeys = signing.identity.into_keys();
+ RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string()))
+}
+
+fn sdk_prepared_publish_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ canonical: &CanonicalListingDraft,
+ plan: ListingPublishPlan,
+) -> ListingMutationView {
+ let listing_addr = plan.public_listing_addr.as_str().to_owned();
+ let event = sdk_plan_event_view(&plan);
+ ListingMutationView {
+ state: "dry_run".to_owned(),
+ operation: ListingMutationOperation::Publish.as_str().to_owned(),
+ source: SDK_LISTING_WRITE_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: true,
+ deduplicated: false,
+ target_relays: Vec::new(),
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ event_id: Some(plan.expected_event_id.as_str().to_owned()),
+ event_addr: Some(listing_addr),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ local_replica: None,
+ reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()),
+ job: None,
+ event: args.print_event.then_some(event),
+ actions: vec![format!("radroots listing publish {}", args.file.display())],
+ }
+}
+
+fn sdk_enqueued_publish_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ canonical: &CanonicalListingDraft,
+ enqueue: ListingEnqueueReceipt,
+ push: Option<PushOutboxReceipt>,
+) -> ListingMutationView {
+ let push_event = push
+ .as_ref()
+ .and_then(|receipt| sdk_push_event_for_listing(&enqueue, receipt));
+ let state = sdk_publish_state(args, push_event);
+ let reason = sdk_publish_reason(args, push_event);
+ let target_relays = push_event
+ .map(sdk_push_target_relays)
+ .unwrap_or_else(|| config.relay.urls.clone());
+ let connected_relays = push_event
+ .map(sdk_push_connected_relays)
+ .unwrap_or_default();
+ let acknowledged_relays = push_event
+ .map(sdk_push_acknowledged_relays)
+ .unwrap_or_default();
+ let failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default();
+ let event_id = enqueue.signed_event_id.as_str().to_owned();
+ let listing_addr = enqueue.public_listing_addr.as_str().to_owned();
+ ListingMutationView {
+ state,
+ operation: ListingMutationOperation::Publish.as_str().to_owned(),
+ source: SDK_LISTING_WRITE_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr: listing_addr.clone(),
+ seller_account_id: canonical.seller_account_id.clone(),
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ seller_actor_source: canonical.seller_actor_source.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: false,
+ deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued),
+ target_relays,
+ connected_relays,
+ acknowledged_relays,
+ failed_relays,
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ event_id: Some(event_id),
+ event_addr: Some(listing_addr),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ local_replica: None,
+ reason,
+ job: None,
+ event: None,
+ actions: sdk_publish_actions(args, push_event),
+ }
+}
+
+fn sdk_plan_event_view(plan: &ListingPublishPlan) -> ListingMutationEventView {
+ ListingMutationEventView {
+ kind: plan.frozen_draft.kind,
+ author: plan.frozen_draft.expected_pubkey.clone(),
+ created_at: Some(plan.frozen_draft.created_at),
+ content: plan.frozen_draft.content.clone(),
+ tags: plan.frozen_draft.tags.clone(),
+ event_id: Some(plan.expected_event_id.as_str().to_owned()),
+ signature: None,
+ event_addr: plan.public_listing_addr.as_str().to_owned(),
+ }
+}
+
+fn sdk_push_event_for_listing<'a>(
+ enqueue: &ListingEnqueueReceipt,
+ push: &'a PushOutboxReceipt,
+) -> Option<&'a PushOutboxEventReceipt> {
+ push.events
+ .iter()
+ .find(|event| event.event_id == enqueue.signed_event_id)
+}
+
+fn sdk_publish_state(
+ args: &ListingMutationArgs,
+ push_event: Option<&PushOutboxEventReceipt>,
+) -> String {
+ match push_event.map(|event| event.final_state) {
+ Some(PushOutboxEventState::Published) => "published",
+ Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => {
+ "unavailable"
+ }
+ Some(_) | None if args.offline => "queued",
+ Some(_) | None => "queued",
+ }
+ .to_owned()
+}
+
+fn sdk_publish_reason(
+ args: &ListingMutationArgs,
+ push_event: Option<&PushOutboxEventReceipt>,
+) -> Option<String> {
+ match push_event.map(|event| event.final_state) {
+ Some(PushOutboxEventState::Published) => None,
+ Some(PushOutboxEventState::PublishRetryable) => Some(
+ "SDK relay publish did not reach accepted quorum; outbox event remains retryable"
+ .to_owned(),
+ ),
+ Some(PushOutboxEventState::FailedTerminal) => {
+ Some("SDK relay publish failed terminally".to_owned())
+ }
+ Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")),
+ None if args.offline => Some(
+ "listing publish queued in SDK outbox; relay push skipped for offline mode".to_owned(),
+ ),
+ None => Some(
+ "listing publish queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(),
+ ),
+ }
+}
+
+fn sdk_publish_actions(
+ args: &ListingMutationArgs,
+ push_event: Option<&PushOutboxEventReceipt>,
+) -> Vec<String> {
+ if args.offline
+ || !matches!(
+ push_event.map(|event| event.final_state),
+ Some(PushOutboxEventState::Published)
+ )
+ {
+ return vec!["radroots sync push".to_owned()];
+ }
+ Vec::new()
+}
+
+fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
+ event
+ .relays
+ .iter()
+ .map(|relay| relay.relay_url.clone())
+ .collect()
+}
+
+fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
+ event
+ .relays
+ .iter()
+ .filter(|relay| relay.attempted)
+ .map(|relay| relay.relay_url.clone())
+ .collect()
+}
+
+fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
+ event
+ .relays
+ .iter()
+ .filter(|relay| {
+ matches!(
+ relay.outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ | PushOutboxRelayOutcomeKind::DuplicateAccepted
+ )
+ })
+ .map(|relay| relay.relay_url.clone())
+ .collect()
+}
+
+fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> {
+ event
+ .relays
+ .iter()
+ .filter(|relay| {
+ !matches!(
+ relay.outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ | PushOutboxRelayOutcomeKind::DuplicateAccepted
+ )
+ })
+ .map(|relay| RelayFailureView {
+ relay: relay.relay_url.clone(),
+ reason: relay
+ .message
+ .clone()
+ .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()),
+ })
+ .collect()
+}
+
+fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str {
+ match kind {
+ PushOutboxRelayOutcomeKind::Accepted => "accepted",
+ PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted",
+ PushOutboxRelayOutcomeKind::Blocked => "blocked",
+ PushOutboxRelayOutcomeKind::RateLimited => "rate_limited",
+ PushOutboxRelayOutcomeKind::Invalid => "invalid",
+ PushOutboxRelayOutcomeKind::PowRequired => "pow_required",
+ PushOutboxRelayOutcomeKind::Restricted => "restricted",
+ PushOutboxRelayOutcomeKind::AuthRequired => "auth_required",
+ PushOutboxRelayOutcomeKind::Error => "error",
+ PushOutboxRelayOutcomeKind::Timeout => "timeout",
+ PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed",
+ PushOutboxRelayOutcomeKind::Unknown => "unknown",
+ _ => "unknown",
+ }
}
pub fn update(
@@ -3440,13 +3809,21 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
mod tests {
use super::{
DRAFT_KIND, ListingDraftDocument, direct_relay_error_view_parts, encode_base64url_no_pad,
- generate_d_tag, ingest_listing_event_into_local_replica,
+ generate_d_tag, ingest_listing_event_into_local_replica, sdk_publish_actions,
+ sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays,
+ sdk_push_failed_relays,
};
+ use crate::cli::global::ListingMutationArgs;
use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError};
+ use radroots_events::ids::RadrootsEventId;
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::wire::WireEventParts;
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event};
+ use radroots_sdk::{
+ PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind,
+ PushOutboxRelayReceipt,
+ };
#[test]
fn generated_listing_d_tag_is_valid_base64url() {
@@ -3483,6 +3860,49 @@ mod tests {
}
#[test]
+ fn sdk_push_receipt_helpers_map_published_and_auth_required_states() {
+ let accepted = sdk_push_event(
+ PushOutboxEventState::Published,
+ PushOutboxRelayOutcomeKind::Accepted,
+ Some("accepted".to_owned()),
+ );
+ let args = listing_mutation_args(false);
+
+ assert_eq!(sdk_publish_state(&args, Some(&accepted)), "published");
+ assert!(sdk_publish_reason(&args, Some(&accepted)).is_none());
+ assert!(sdk_publish_actions(&args, Some(&accepted)).is_empty());
+ assert_eq!(
+ sdk_push_acknowledged_relays(&accepted),
+ vec!["ws://127.0.0.1:19000".to_owned()]
+ );
+ assert!(sdk_push_failed_relays(&accepted).is_empty());
+
+ let auth_required = sdk_push_event(
+ PushOutboxEventState::PublishRetryable,
+ PushOutboxRelayOutcomeKind::AuthRequired,
+ Some("auth required".to_owned()),
+ );
+ let failed = sdk_push_failed_relays(&auth_required);
+
+ assert_eq!(
+ sdk_publish_state(&args, Some(&auth_required)),
+ "unavailable"
+ );
+ assert!(
+ sdk_publish_reason(&args, Some(&auth_required))
+ .expect("retry reason")
+ .contains("accepted quorum")
+ );
+ assert_eq!(failed.len(), 1);
+ assert_eq!(failed[0].relay, "ws://127.0.0.1:19000");
+ assert_eq!(failed[0].reason, "auth required");
+ assert_eq!(
+ sdk_publish_actions(&args, Some(&auth_required)),
+ vec!["radroots sync push".to_owned()]
+ );
+ }
+
+ #[test]
fn local_replica_ingest_reports_missing_store() {
let temp = tempfile::tempdir().expect("tempdir");
let event = signed_test_listing_event(WireEventParts {
@@ -3732,6 +4152,53 @@ mod tests {
);
}
+ fn sdk_push_event(
+ final_state: PushOutboxEventState,
+ outcome_kind: PushOutboxRelayOutcomeKind,
+ message: Option<String>,
+ ) -> PushOutboxEventReceipt {
+ PushOutboxEventReceipt {
+ event_id: RadrootsEventId::parse("e".repeat(64)).expect("event id"),
+ outbox_event_id: 7,
+ final_state,
+ attempted_count: 1,
+ accepted_count: usize::from(matches!(
+ outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ | PushOutboxRelayOutcomeKind::DuplicateAccepted
+ )),
+ retryable_count: usize::from(matches!(
+ outcome_kind,
+ PushOutboxRelayOutcomeKind::AuthRequired
+ | PushOutboxRelayOutcomeKind::Timeout
+ | PushOutboxRelayOutcomeKind::ConnectionFailed
+ )),
+ terminal_count: 0,
+ quorum: 1,
+ quorum_met: matches!(
+ outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ | PushOutboxRelayOutcomeKind::DuplicateAccepted
+ ),
+ relays: vec![PushOutboxRelayReceipt {
+ relay_url: "ws://127.0.0.1:19000".to_owned(),
+ outcome_kind,
+ attempted: true,
+ message,
+ }],
+ }
+ }
+
+ fn listing_mutation_args(offline: bool) -> ListingMutationArgs {
+ ListingMutationArgs {
+ file: "listing.toml".into(),
+ idempotency_key: None,
+ signer_session_id: None,
+ print_event: false,
+ offline,
+ }
+ }
+
fn signed_test_listing_event(
parts: WireEventParts,
) -> radroots_nostr::prelude::RadrootsNostrEvent {
diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs
@@ -61,12 +61,7 @@ pub struct CliSdkSession {
impl CliSdkSession {
pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> {
let sdk_config = CliSdkConfig::from_runtime_config(config);
- let runtime = TokioRuntimeBuilder::new_multi_thread()
- .enable_all()
- .build()
- .map_err(|error| {
- RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}"))
- })?;
+ let runtime = sdk_runtime()?;
let sdk = runtime.block_on(sdk_config.builder().build())?;
Ok(Self {
runtime,
@@ -75,6 +70,17 @@ impl CliSdkSession {
})
}
+ pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> {
+ let sdk_config = CliSdkConfig::from_runtime_config(config);
+ let runtime = sdk_runtime()?;
+ let sdk = runtime.block_on(memory_builder(&sdk_config).build())?;
+ Ok(Self {
+ runtime,
+ sdk,
+ config: sdk_config,
+ })
+ }
+
pub fn sdk(&self) -> &RadrootsSdk {
&self.sdk
}
@@ -134,6 +140,22 @@ pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf {
config.local.root.join(SDK_STORAGE_DIR_NAME)
}
+fn sdk_runtime() -> Result<Runtime, RuntimeError> {
+ TokioRuntimeBuilder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|error| {
+ RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}"))
+ })
+}
+
+fn memory_builder(config: &CliSdkConfig) -> RadrootsSdkBuilder {
+ config.relay_urls.iter().fold(
+ RadrootsSdk::builder().relay_url_policy(config.relay_url_policy),
+ |builder, relay_url| builder.relay_url(relay_url.clone()),
+ )
+}
+
pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy {
if config
.relay
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -955,12 +955,9 @@ fn local_listing_publish_fails_without_configured_relay() {
assert!(!output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"], serde_json::Value::Null);
- assert_eq!(value["errors"][0]["code"], "network_unavailable");
- assert_eq!(value["errors"][0]["detail"]["class"], "network");
- assert_contains(
- &value["errors"][0]["message"],
- "requires at least one configured relay",
- );
+ assert_eq!(value["errors"][0]["code"], "empty_target_relays");
+ assert_eq!(value["errors"][0]["detail"]["class"], "configuration");
+ assert_contains(&value["errors"][0]["message"], "sdk empty target relays");
assert_no_removed_command_reference(&value, &["listing", "publish"]);
assert_no_daemon_runtime_reference(&value, &["listing", "publish"]);
}
@@ -985,7 +982,17 @@ fn local_listing_publish_dry_run_does_not_sign_matching_listing() {
assert_eq!(value["dry_run"], true);
assert_eq!(value["result"]["state"], "dry_run");
assert_eq!(value["result"]["dry_run"], true);
- assert_eq!(value["result"]["event_id"], serde_json::Value::Null);
+ assert_eq!(
+ value["result"]["event_id"]
+ .as_str()
+ .expect("dry-run event id")
+ .len(),
+ 64
+ );
+ assert!(
+ !sandbox.root().join("data/apps/cli/replica/sdk").exists(),
+ "dry-run must not materialize durable SDK storage"
+ );
assert_no_removed_command_reference(&value, &["listing", "publish", "--dry-run"]);
assert_no_daemon_runtime_reference(&value, &["listing", "publish", "--dry-run"]);
}
@@ -2022,11 +2029,16 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() {
listing_file_arg.as_ref(),
]);
assert!(!publish_output.status.success());
- assert_direct_relay_connection_failure(
- &publish_value,
- "listing.publish",
- &["listing", "publish"],
+ assert_eq!(publish_value["operation_id"], "listing.publish");
+ assert_eq!(publish_value["result"], serde_json::Value::Null);
+ assert_eq!(publish_value["errors"][0]["code"], "network_unavailable");
+ assert_eq!(publish_value["errors"][0]["detail"]["class"], "network");
+ assert_contains(
+ &publish_value["errors"][0]["message"],
+ "SDK relay publish did not reach accepted quorum",
);
+ assert_no_removed_command_reference(&publish_value, &["listing", "publish"]);
+ assert_no_daemon_runtime_reference(&publish_value, &["listing", "publish"]);
assert_eq!(
publish_value["errors"][0]["detail"]["target_relays"][0],
relay
@@ -2036,7 +2048,7 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() {
.as_array()
.expect("connected relays")
.len(),
- 0
+ 1
);
assert_eq!(
publish_value["errors"][0]["detail"]["failed_relays"]
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -3054,6 +3054,148 @@ fn offline_allows_supported_external_dry_run() {
}
#[test]
+fn offline_listing_publish_enqueues_sdk_outbox_without_direct_relay_push() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Offline Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let farm_d_tag = farm["result"]["config"]["farm_d_tag"]
+ .as_str()
+ .expect("farm d tag");
+ let listing_file = create_listing_draft(&sandbox, "offline-sdk-enqueue");
+ make_listing_publishable(&listing_file, farm_d_tag);
+ let relay = "ws://127.0.0.1:9";
+ let local_event_records_before_publish = sandbox.local_event_records().len();
+
+ let publish = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--offline",
+ "--relay",
+ relay,
+ "--approval-token",
+ "approve",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert_eq!(publish["operation_id"], "listing.publish");
+ assert_eq!(publish["result"]["state"], "queued");
+ assert_eq!(
+ publish["result"]["source"],
+ "SDK listing publish · local key"
+ );
+ assert_eq!(publish["result"]["target_relays"][0], relay);
+ assert_eq!(publish["result"]["actions"][0], "radroots sync push");
+ assert_eq!(
+ publish["result"]["event_id"]
+ .as_str()
+ .expect("sdk event id")
+ .len(),
+ 64
+ );
+ assert!(
+ sandbox
+ .root()
+ .join("data/apps/cli/replica/sdk/outbox.sqlite")
+ .exists()
+ );
+ assert_eq!(
+ sandbox.local_event_records().len(),
+ local_event_records_before_publish
+ );
+}
+
+#[test]
+fn listing_publish_idempotency_conflict_maps_sdk_partial_mutation_recovery() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Conflict Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let farm_d_tag = farm["result"]["config"]["farm_d_tag"]
+ .as_str()
+ .expect("farm d tag");
+ let listing_file = create_listing_draft(&sandbox, "idem-conflict");
+ make_listing_publishable(&listing_file, farm_d_tag);
+ let relay = "ws://127.0.0.1:9";
+ let idempotency_key = "listing-idem-conflict";
+
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--offline",
+ "--relay",
+ relay,
+ "--approval-token",
+ "approve",
+ "--idempotency-key",
+ idempotency_key,
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+ let raw = fs::read_to_string(&listing_file).expect("listing draft");
+ fs::write(
+ &listing_file,
+ raw.replace("title = \"Eggs\"", "title = \"Conflict Eggs\""),
+ )
+ .expect("rewrite listing draft");
+
+ let (output, conflict) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--offline",
+ "--relay",
+ relay,
+ "--approval-token",
+ "approve",
+ "--idempotency-key",
+ idempotency_key,
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(conflict["operation_id"], "listing.publish");
+ assert_eq!(conflict["errors"][0]["code"], "partial_local_mutation");
+ assert_eq!(conflict["errors"][0]["detail"]["class"], "local_mutation");
+ assert_eq!(
+ conflict["errors"][0]["detail"]["detail"]["failure"],
+ "outbox_idempotency_conflict"
+ );
+ assert_eq!(
+ conflict["errors"][0]["detail"]["actions"][0],
+ "radroots listing publish"
+ );
+}
+
+#[test]
fn offline_rejects_order_decision_dry_run() {
for (operation_id, args) in [
(
@@ -5271,7 +5413,7 @@ fn farm_publish_writes_acknowledged_signed_outbox_records() {
}
#[test]
-fn listing_publish_failure_writes_failed_signed_outbox_record() {
+fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);
let farm = sandbox.json_success(&[
@@ -5293,14 +5435,14 @@ fn listing_publish_failure_writes_failed_signed_outbox_record() {
.expect("farm d tag");
let listing_file = create_listing_draft(&sandbox, "failed-outbox-eggs");
make_listing_publishable(&listing_file, farm_d_tag);
- let relay = RelayPublishServer::with_publish_outcomes(vec![(false, "rejected by test relay")]);
- let relay_url = relay.endpoint().to_owned();
+ let relay_url = "ws://127.0.0.1:9";
+ let local_event_records_before_publish = sandbox.local_event_records().len();
let (output, publish) = sandbox.json_output(&[
"--format",
"json",
"--relay",
- relay_url.as_str(),
+ relay_url,
"--approval-token",
"approve",
"listing",
@@ -5311,36 +5453,33 @@ fn listing_publish_failure_writes_failed_signed_outbox_record() {
assert!(!output.status.success());
assert_eq!(publish["operation_id"], "listing.publish");
assert_eq!(publish["errors"][0]["code"], "network_unavailable");
- let requests = relay.take_requests(1);
- assert_eq!(requests.len(), 1);
-
- let records = sandbox.local_event_records();
- let signed_records = records
- .iter()
- .filter(|record| record.family == LocalRecordFamily::SignedEvent)
- .collect::<Vec<_>>();
- assert_eq!(signed_records.len(), 1);
- let record = signed_records[0];
- assert_eq!(record.status, LocalRecordStatus::Failed);
- assert_eq!(record.outbox_status, PublishOutboxStatus::Failed);
- assert_eq!(record.source_runtime, SourceRuntime::Cli);
- assert_eq!(record.farm_id.as_deref(), Some(farm_d_tag));
- assert_eq!(record.event_kind, Some(30402));
assert_eq!(
- record.relay_delivery_json.as_ref().unwrap()["state"],
- "failed"
+ publish["errors"][0]["detail"]["source"],
+ "SDK listing publish · local key"
+ );
+ assert_eq!(publish["errors"][0]["detail"]["state"], "unavailable");
+ assert_eq!(
+ publish["errors"][0]["detail"]["target_relays"][0],
+ relay_url
);
assert_eq!(
- record.relay_delivery_json.as_ref().unwrap()["failed_relays"][0]["relay_url"],
+ publish["errors"][0]["detail"]["failed_relays"][0]["relay"],
relay_url
);
assert_eq!(
- record.relay_delivery_json.as_ref().unwrap()["failed_relays"][0]["error"],
- "rejected by test relay"
+ publish["errors"][0]["detail"]["actions"][0],
+ "radroots sync push"
);
assert_eq!(
- record.raw_event_json.as_ref().unwrap()["id"],
- record.event_id.as_deref().expect("event id")
+ publish["errors"][0]["detail"]["event_id"]
+ .as_str()
+ .expect("sdk event id")
+ .len(),
+ 64
+ );
+ assert_eq!(
+ sandbox.local_event_records().len(),
+ local_event_records_before_publish
);
}
@@ -7778,11 +7917,11 @@ fn seller_target_flow_acceptance_uses_target_operations() {
assert_eq!(unavailable_publish["operation_id"], "listing.publish");
assert_eq!(
unavailable_publish["errors"][0]["code"],
- "network_unavailable"
+ "empty_target_relays"
);
assert_eq!(
unavailable_publish["errors"][0]["detail"]["class"],
- "network"
+ "configuration"
);
assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]);
assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]);