commit 3885ce5f284399bafcd63c5ef53a46698c0aa946
parent f8b493c8c5bbdb66d3c59eaaf13dfb5c83117072
Author: triesap <tyson@radroots.org>
Date: Sat, 23 May 2026 03:24:48 +0000
local_events: add signed publish outbox records
- split direct relay signing from signed event publish execution
- append pending signed event records before farm and listing relay publish
- update signed event outbox state for relay acknowledgements and failures
- cover farm acknowledgement and listing failure outbox records in CLI tests
Diffstat:
5 files changed, 582 insertions(+), 72 deletions(-)
diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs
@@ -100,13 +100,26 @@ pub fn publish_parts_with_identity(
return Err(DirectRelayPublishError::MissingRelays);
}
+ let event = sign_parts_with_identity(identity, parts)?;
+ publish_signed_event_with_identity(identity, relay_urls, event)
+}
+
+pub fn publish_signed_event_with_identity(
+ identity: &RadrootsIdentity,
+ relay_urls: &[String],
+ event: RadrootsNostrEvent,
+) -> Result<DirectRelayPublishReceipt, DirectRelayPublishError> {
+ if relay_urls.is_empty() {
+ return Err(DirectRelayPublishError::MissingRelays);
+ }
+
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| DirectRelayPublishError::Runtime(error.to_string()))?;
- runtime.block_on(publish_parts_with_identity_async(
- identity, relay_urls, parts,
+ runtime.block_on(publish_signed_event_with_identity_async(
+ identity, relay_urls, event,
))
}
@@ -183,12 +196,11 @@ async fn fetch_events_from_relays_async(
})
}
-async fn publish_parts_with_identity_async(
+async fn publish_signed_event_with_identity_async(
identity: &RadrootsIdentity,
relay_urls: &[String],
- parts: WireEventParts,
+ event: RadrootsNostrEvent,
) -> Result<DirectRelayPublishReceipt, DirectRelayPublishError> {
- let event = sign_parts_with_identity(identity, parts)?;
let event_id = event.id.to_hex();
let created_at = event_created_at_u32(&event);
let signature = event.sig.to_string();
@@ -257,7 +269,7 @@ async fn publish_parts_with_identity_async(
})
}
-fn sign_parts_with_identity(
+pub fn sign_parts_with_identity(
identity: &RadrootsIdentity,
parts: WireEventParts,
) -> Result<RadrootsNostrEvent, DirectRelayPublishError> {
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -28,13 +28,16 @@ use crate::runtime::config::{
};
use crate::runtime::direct_relay::{
DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt,
- publish_parts_with_identity,
+ publish_signed_event_with_identity, sign_parts_with_identity,
};
use crate::runtime::farm_config::{
self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults,
FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION,
};
-use crate::runtime::local_events::append_local_work;
+use crate::runtime::local_events::{
+ append_local_work, append_signed_event, mark_signed_event_acknowledged,
+ mark_signed_event_failed_for_publish_error,
+};
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime_args::{
FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs,
@@ -746,7 +749,7 @@ fn publish_via_direct_relay(
args: &FarmPublishArgs,
mut resolved: ResolvedFarmConfig,
account_pubkey: String,
- previews: FarmPublishPreviews,
+ mut previews: FarmPublishPreviews,
profile_idempotency_key: Option<String>,
farm_idempotency_key: Option<String>,
) -> Result<FarmPublishView, RuntimeError> {
@@ -771,37 +774,99 @@ fn publish_via_direct_relay(
}
};
- let profile_receipt = publish_parts_with_identity(
- &signing.identity,
- &config.relay.urls,
- previews.profile.parts.clone(),
- )
- .map_err(|error| RuntimeError::Network(error.to_string()))?;
- let profile_local_replica =
- farm_local_replica_ingest_view(config, "profile", &profile_receipt, None);
- persist_profile_publication(config, &mut resolved, profile_receipt.event_id.clone())?;
+ if config.relay.urls.is_empty() {
+ return Err(RuntimeError::Network(
+ DirectRelayPublishError::MissingRelays.to_string(),
+ ));
+ }
- let farm_receipt = match publish_parts_with_identity(
+ let profile_event = sign_parts_with_identity(&signing.identity, previews.profile.parts.clone())
+ .map_err(|error| RuntimeError::Network(error.to_string()))?;
+ previews.profile.event.event_id = Some(profile_event.id.to_hex());
+ let profile_record = append_signed_event(
+ config,
+ format!("farm_profile:{}", resolved.document.selection.farm_d_tag).as_str(),
+ Some(resolved.document.selection.account.clone()),
+ Some(account_pubkey.clone()),
+ Some(resolved.document.selection.farm_d_tag.clone()),
+ None,
+ &profile_event,
+ )?;
+ let profile_receipt = match publish_signed_event_with_identity(
&signing.identity,
&config.relay.urls,
- previews.farm.parts.clone(),
+ profile_event,
) {
- Ok(receipt) => receipt,
+ Ok(receipt) => {
+ mark_signed_event_acknowledged(
+ config,
+ profile_record.record_id.as_str(),
+ receipt.target_relays.clone(),
+ receipt.connected_relays.clone(),
+ receipt.acknowledged_relays.clone(),
+ receipt.failed_relays.clone(),
+ )?;
+ receipt
+ }
Err(error) => {
- return Ok(partial_publish_view(
+ mark_signed_event_failed_for_publish_error(
config,
- args,
- &resolved,
- &account_pubkey,
- previews,
- profile_idempotency_key,
- farm_idempotency_key,
- profile_receipt,
- profile_local_replica,
- error,
- ));
+ profile_record.record_id.as_str(),
+ &error,
+ )?;
+ return Err(RuntimeError::Network(error.to_string()));
}
};
+ let profile_local_replica =
+ farm_local_replica_ingest_view(config, "profile", &profile_receipt, None);
+ persist_profile_publication(config, &mut resolved, profile_receipt.event_id.clone())?;
+
+ let farm_event = sign_parts_with_identity(&signing.identity, previews.farm.parts.clone())
+ .map_err(|error| RuntimeError::Network(error.to_string()))?;
+ previews.farm.event.event_id = Some(farm_event.id.to_hex());
+ let farm_record = append_signed_event(
+ config,
+ format!("farm:{}", resolved.document.selection.farm_d_tag).as_str(),
+ Some(resolved.document.selection.account.clone()),
+ Some(account_pubkey.clone()),
+ Some(resolved.document.selection.farm_d_tag.clone()),
+ None,
+ &farm_event,
+ )?;
+ let farm_receipt =
+ match publish_signed_event_with_identity(&signing.identity, &config.relay.urls, farm_event)
+ {
+ Ok(receipt) => {
+ mark_signed_event_acknowledged(
+ config,
+ farm_record.record_id.as_str(),
+ receipt.target_relays.clone(),
+ receipt.connected_relays.clone(),
+ receipt.acknowledged_relays.clone(),
+ receipt.failed_relays.clone(),
+ )?;
+ receipt
+ }
+ Err(error) => {
+ mark_signed_event_failed_for_publish_error(
+ config,
+ farm_record.record_id.as_str(),
+ &error,
+ )?;
+ return Ok(partial_publish_view(
+ config,
+ args,
+ &resolved,
+ &account_pubkey,
+ previews,
+ profile_idempotency_key,
+ farm_idempotency_key,
+ profile_receipt,
+ profile_local_replica,
+ error,
+ ));
+ }
+ };
let farm_local_replica = farm_local_replica_ingest_view(
config,
"farm",
@@ -1277,7 +1342,8 @@ fn failed_component(
error: DirectRelayPublishError,
) -> FarmPublishComponentView {
let reason = error.to_string();
- let failure = publish_failure_details(error, relay_urls);
+ let failure = publish_failure_details(&error, relay_urls);
+ let event_id = failure.event_id.or_else(|| event.event_id.clone());
FarmPublishComponentView {
state: "failed".to_owned(),
rpc_method: rpc_method.to_owned(),
@@ -1291,7 +1357,7 @@ fn failed_component(
job_status: None,
signer_mode: Some("local".to_owned()),
signer_session_id: None,
- event_id: failure.event_id,
+ event_id,
event_addr: event.event_addr.clone(),
idempotency_key,
reason: Some(reason),
@@ -1431,7 +1497,7 @@ struct FarmPublishFailureDetails {
}
fn publish_failure_details(
- error: DirectRelayPublishError,
+ error: &DirectRelayPublishError,
relay_urls: &[String],
) -> FarmPublishFailureDetails {
match error {
@@ -1449,7 +1515,7 @@ fn publish_failure_details(
target_relays: relay_urls.to_vec(),
connected_relays: Vec::new(),
failed_relays: vec![RelayFailureView {
- relay,
+ relay: relay.clone(),
reason: source.to_string(),
}],
},
@@ -1460,9 +1526,9 @@ fn publish_failure_details(
..
} => FarmPublishFailureDetails {
event_id: None,
- target_relays,
- connected_relays,
- failed_relays: relay_failures(failed_relays),
+ target_relays: target_relays.clone(),
+ connected_relays: connected_relays.clone(),
+ failed_relays: relay_failures(failed_relays.clone()),
},
DirectRelayPublishError::Publish {
event_id,
@@ -1471,10 +1537,10 @@ fn publish_failure_details(
failed_relays,
..
} => FarmPublishFailureDetails {
- event_id: Some(event_id),
- target_relays,
- connected_relays,
- failed_relays: relay_failures(failed_relays),
+ event_id: Some(event_id.clone()),
+ target_relays: target_relays.clone(),
+ connected_relays: connected_relays.clone(),
+ failed_relays: relay_failures(failed_relays.clone()),
},
}
}
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -42,10 +42,13 @@ use crate::runtime::config::{
};
use crate::runtime::direct_relay::{
DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt,
- publish_parts_with_identity,
+ publish_signed_event_with_identity, sign_parts_with_identity,
};
use crate::runtime::farm_config;
-use crate::runtime::local_events::append_local_work;
+use crate::runtime::local_events::{
+ append_local_work, append_signed_event, mark_signed_event_acknowledged,
+ mark_signed_event_failed_for_publish_error,
+};
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness,
@@ -1271,28 +1274,72 @@ fn mutate_via_direct_relay(
}
};
- let receipt =
- match publish_parts_with_identity(&signing.identity, &config.relay.urls, event_draft.parts)
- {
- Ok(receipt) => receipt,
- Err(
- error @ (DirectRelayPublishError::MissingRelays
- | DirectRelayPublishError::RelayConfig { .. }
- | DirectRelayPublishError::Connect { .. }
- | DirectRelayPublishError::Publish { .. }),
- ) => {
- return Ok(direct_relay_error_view(
- config,
- args,
- operation,
- canonical,
- listing_addr,
- event_draft.event,
- error,
- ));
- }
- Err(error) => return Err(RuntimeError::Network(error.to_string())),
- };
+ if config.relay.urls.is_empty() {
+ return Ok(direct_relay_error_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event_draft.event,
+ DirectRelayPublishError::MissingRelays,
+ ));
+ }
+
+ let signed_event = sign_parts_with_identity(&signing.identity, event_draft.parts)
+ .map_err(|error| RuntimeError::Network(error.to_string()))?;
+ let record = append_signed_event(
+ config,
+ format!("listing:{}", canonical.listing_id).as_str(),
+ Some(canonical.seller_account_id.clone()),
+ Some(canonical.seller_pubkey.clone()),
+ Some(canonical.farm_d_tag.clone()),
+ Some(listing_addr.clone()),
+ &signed_event,
+ )?;
+ let receipt = match publish_signed_event_with_identity(
+ &signing.identity,
+ &config.relay.urls,
+ signed_event,
+ ) {
+ Ok(receipt) => {
+ mark_signed_event_acknowledged(
+ config,
+ record.record_id.as_str(),
+ receipt.target_relays.clone(),
+ receipt.connected_relays.clone(),
+ receipt.acknowledged_relays.clone(),
+ receipt.failed_relays.clone(),
+ )?;
+ receipt
+ }
+ Err(
+ error @ (DirectRelayPublishError::RelayConfig { .. }
+ | DirectRelayPublishError::Connect { .. }
+ | DirectRelayPublishError::Publish { .. }),
+ ) => {
+ mark_signed_event_failed_for_publish_error(config, record.record_id.as_str(), &error)?;
+ let mut event = event_draft.event;
+ event.event_id = record.event_id.clone();
+ event.created_at = record
+ .event_created_at
+ .and_then(|created_at| u32::try_from(created_at).ok());
+ event.signature = record.event_sig.clone();
+ return Ok(direct_relay_error_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event,
+ error,
+ ));
+ }
+ Err(error) => {
+ mark_signed_event_failed_for_publish_error(config, record.record_id.as_str(), &error)?;
+ return Err(RuntimeError::Network(error.to_string()));
+ }
+ };
Ok(published_mutation_view(
config,
@@ -1982,9 +2029,8 @@ fn direct_relay_error_view(
error: DirectRelayPublishError,
) -> ListingMutationView {
let parts = direct_relay_error_view_parts(config.relay.urls.as_slice(), error);
- if let Some(event_id) = parts.event_id.as_ref() {
- event_preview.event_id = Some(event_id.clone());
- }
+ let event_id = parts.event_id.or_else(|| event_preview.event_id.clone());
+ event_preview.event_id = event_id.clone();
ListingMutationView {
state: "unavailable".to_owned(),
@@ -2006,7 +2052,7 @@ fn direct_relay_error_view(
job_id: None,
job_status: None,
signer_mode: Some(config.signer.backend.as_str().to_owned()),
- event_id: parts.event_id,
+ event_id,
event_addr: Some(listing_addr),
idempotency_key: args.idempotency_key.clone(),
signer_session_id: None,
diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs
@@ -7,10 +7,11 @@ use radroots_local_events::{
LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
};
use radroots_sql_core::SqliteExecutor;
-use serde_json::Value;
+use serde_json::{Value, json};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
+use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError};
const SHARED_LOCAL_EVENTS_DIR: &str = "local_events";
const SHARED_LOCAL_EVENTS_DB_FILE: &str = "local_events.sqlite";
@@ -56,6 +57,129 @@ pub fn append_local_work(
Ok(store.append_record(&input)?)
}
+pub fn append_signed_event(
+ config: &RuntimeConfig,
+ subject: &str,
+ owner_account_id: Option<String>,
+ owner_pubkey: Option<String>,
+ farm_id: Option<String>,
+ listing_addr: Option<String>,
+ event: &radroots_nostr::prelude::RadrootsNostrEvent,
+) -> Result<LocalEventRecord, RuntimeError> {
+ let timestamp = current_time_ms()?;
+ let relay_set = relay_set_fingerprint(&config.relay.urls);
+ let input = LocalEventRecordInput {
+ record_id: format!("cli:signed_event:{subject}:{}", event.id.to_hex()),
+ family: LocalRecordFamily::SignedEvent,
+ status: LocalRecordStatus::PendingPublish,
+ source_runtime: SourceRuntime::Cli,
+ created_at_ms: timestamp,
+ inserted_at_ms: timestamp,
+ owner_account_id,
+ owner_pubkey,
+ farm_id,
+ listing_addr,
+ local_work_json: None,
+ event_id: Some(event.id.to_hex()),
+ event_kind: Some(i64::from(u32::from(event.kind.as_u16()))),
+ event_pubkey: Some(event.pubkey.to_string()),
+ event_created_at: Some(event_created_at_i64(event)?),
+ event_tags_json: Some(json!(event_tags(event))),
+ event_content: Some(event.content.clone()),
+ event_sig: Some(event.sig.to_string()),
+ raw_event_json: Some(raw_event_json(event)?),
+ outbox_status: PublishOutboxStatus::Pending,
+ relay_set_fingerprint: relay_set,
+ relay_delivery_json: Some(pending_delivery_json(&config.relay.urls)),
+ };
+ let store = open_store(config)?;
+ Ok(store.append_record(&input)?)
+}
+
+pub fn mark_signed_event_acknowledged(
+ config: &RuntimeConfig,
+ record_id: &str,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ acknowledged_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+) -> Result<LocalEventRecord, RuntimeError> {
+ update_signed_event_outbox(
+ config,
+ record_id,
+ LocalRecordStatus::Published,
+ PublishOutboxStatus::Acknowledged,
+ json!({
+ "state": "acknowledged",
+ "target_relays": target_relays,
+ "connected_relays": connected_relays,
+ "acknowledged_relays": acknowledged_relays,
+ "failed_relays": relay_failures_json(failed_relays),
+ }),
+ )
+}
+
+pub fn mark_signed_event_failed(
+ config: &RuntimeConfig,
+ record_id: &str,
+ reason: String,
+ target_relays: Vec<String>,
+ connected_relays: Vec<String>,
+ failed_relays: Vec<DirectRelayFailure>,
+) -> Result<LocalEventRecord, RuntimeError> {
+ update_signed_event_outbox(
+ config,
+ record_id,
+ LocalRecordStatus::Failed,
+ PublishOutboxStatus::Failed,
+ json!({
+ "state": "failed",
+ "reason": reason,
+ "target_relays": target_relays,
+ "connected_relays": connected_relays,
+ "acknowledged_relays": [],
+ "failed_relays": relay_failures_json(failed_relays),
+ }),
+ )
+}
+
+pub fn mark_signed_event_failed_for_publish_error(
+ config: &RuntimeConfig,
+ record_id: &str,
+ error: &DirectRelayPublishError,
+) -> Result<LocalEventRecord, RuntimeError> {
+ let (target_relays, connected_relays, failed_relays) =
+ publish_error_delivery_parts(error, &config.relay.urls);
+ mark_signed_event_failed(
+ config,
+ record_id,
+ error.to_string(),
+ target_relays,
+ connected_relays,
+ failed_relays,
+ )
+}
+
+fn update_signed_event_outbox(
+ config: &RuntimeConfig,
+ record_id: &str,
+ status: LocalRecordStatus,
+ outbox_status: PublishOutboxStatus,
+ relay_delivery_json: Value,
+) -> Result<LocalEventRecord, RuntimeError> {
+ let store = open_store(config)?;
+ Ok(
+ store.update_outbox(&radroots_local_events::LocalEventRecordUpdate {
+ record_id: record_id.to_owned(),
+ status,
+ outbox_status,
+ relay_set_fingerprint: relay_set_fingerprint(&config.relay.urls),
+ relay_delivery_json: Some(relay_delivery_json),
+ updated_at_ms: current_time_ms()?,
+ })?,
+ )
+}
+
fn open_store(config: &RuntimeConfig) -> Result<LocalEventsStore<SqliteExecutor>, RuntimeError> {
let root = shared_local_events_root(config)?;
fs::create_dir_all(&root)?;
@@ -84,3 +208,107 @@ fn current_time_ms() -> Result<i64, RuntimeError> {
i64::try_from(duration.as_millis())
.map_err(|_| RuntimeError::Config("current timestamp exceeds i64 milliseconds".to_owned()))
}
+
+fn relay_set_fingerprint(relay_urls: &[String]) -> Option<String> {
+ if relay_urls.is_empty() {
+ return None;
+ }
+ let mut relays = relay_urls
+ .iter()
+ .map(|relay| relay.trim())
+ .filter(|relay| !relay.is_empty())
+ .map(str::to_owned)
+ .collect::<Vec<_>>();
+ relays.sort();
+ relays.dedup();
+ (!relays.is_empty()).then(|| format!("nostr-relay-set-v1:{}", relays.join(",")))
+}
+
+fn pending_delivery_json(relay_urls: &[String]) -> Value {
+ json!({
+ "state": "pending",
+ "target_relays": relay_urls,
+ "connected_relays": [],
+ "acknowledged_relays": [],
+ "failed_relays": [],
+ })
+}
+
+fn relay_failures_json(failures: Vec<DirectRelayFailure>) -> Value {
+ Value::Array(
+ failures
+ .into_iter()
+ .map(|failure| {
+ json!({
+ "relay": failure.relay,
+ "reason": failure.reason,
+ })
+ })
+ .collect(),
+ )
+}
+
+fn publish_error_delivery_parts(
+ error: &DirectRelayPublishError,
+ relay_urls: &[String],
+) -> (Vec<String>, Vec<String>, Vec<DirectRelayFailure>) {
+ match error {
+ DirectRelayPublishError::MissingRelays
+ | DirectRelayPublishError::Runtime(_)
+ | DirectRelayPublishError::Build(_)
+ | DirectRelayPublishError::Sign(_) => (relay_urls.to_vec(), Vec::new(), Vec::new()),
+ DirectRelayPublishError::RelayConfig { relay, source } => (
+ relay_urls.to_vec(),
+ Vec::new(),
+ vec![DirectRelayFailure {
+ relay: relay.clone(),
+ reason: source.to_string(),
+ }],
+ ),
+ DirectRelayPublishError::Connect {
+ target_relays,
+ connected_relays,
+ failed_relays,
+ ..
+ }
+ | DirectRelayPublishError::Publish {
+ target_relays,
+ connected_relays,
+ failed_relays,
+ ..
+ } => (
+ target_relays.clone(),
+ connected_relays.clone(),
+ failed_relays.clone(),
+ ),
+ }
+}
+
+fn event_tags(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> Vec<Vec<String>> {
+ event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect()
+}
+
+fn event_created_at_i64(
+ event: &radroots_nostr::prelude::RadrootsNostrEvent,
+) -> Result<i64, RuntimeError> {
+ i64::try_from(event.created_at.as_secs())
+ .map_err(|_| RuntimeError::Config("event timestamp exceeds i64 seconds".to_owned()))
+}
+
+fn raw_event_json(
+ event: &radroots_nostr::prelude::RadrootsNostrEvent,
+) -> Result<Value, RuntimeError> {
+ Ok(json!({
+ "id": event.id.to_hex(),
+ "pubkey": event.pubkey.to_string(),
+ "created_at": event_created_at_i64(event)?,
+ "kind": u32::from(event.kind.as_u16()),
+ "tags": event_tags(event),
+ "content": event.content.clone(),
+ "sig": event.sig.to_string(),
+ }))
+}
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -8,6 +8,7 @@ use std::thread::{self, JoinHandle};
use std::time::Duration;
use radroots_events::RadrootsNostrEventPtr;
+use radroots_events::kinds::{KIND_FARM, KIND_PROFILE};
use radroots_events::trade::{
RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
};
@@ -3373,6 +3374,163 @@ fn seller_local_writes_append_shared_local_work_records() {
}
#[test]
+fn farm_publish_writes_acknowledged_signed_outbox_records() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Green Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let farm_config = &farm["result"]["config"];
+ let farm_d_tag = farm_config["farm_d_tag"].as_str().expect("farm d tag");
+ let account_id = farm_config["seller_account_id"]
+ .as_str()
+ .expect("seller account id");
+ let seller_pubkey = farm_config["seller_pubkey"]
+ .as_str()
+ .expect("seller pubkey");
+ let relay = RelayPublishServer::with_publish_outcomes(vec![(true, ""), (true, "")]);
+ let relay_url = relay.endpoint().to_owned();
+
+ let publish = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--relay",
+ relay_url.as_str(),
+ "--approval-token",
+ "approve",
+ "farm",
+ "publish",
+ ]);
+
+ assert_eq!(publish["operation_id"], "farm.publish");
+ assert_eq!(publish["result"]["state"], "published");
+ let profile_event_id = publish["result"]["profile"]["event_id"]
+ .as_str()
+ .expect("profile event id");
+ let farm_event_id = publish["result"]["farm"]["event_id"]
+ .as_str()
+ .expect("farm event id");
+ let requests = relay.take_requests(2);
+ assert_eq!(requests.len(), 2);
+
+ let records = sandbox.local_event_records();
+ let signed_records = records
+ .iter()
+ .filter(|record| record.family == LocalRecordFamily::SignedEvent)
+ .collect::<Vec<_>>();
+ assert_eq!(signed_records.len(), 2);
+ for record in &signed_records {
+ assert_eq!(record.status, LocalRecordStatus::Published);
+ assert_eq!(record.outbox_status, PublishOutboxStatus::Acknowledged);
+ assert_eq!(record.source_runtime, SourceRuntime::Cli);
+ assert_eq!(record.owner_account_id.as_deref(), Some(account_id));
+ assert_eq!(record.owner_pubkey.as_deref(), Some(seller_pubkey));
+ assert_eq!(record.farm_id.as_deref(), Some(farm_d_tag));
+ assert_eq!(
+ record.relay_delivery_json.as_ref().unwrap()["state"],
+ "acknowledged"
+ );
+ assert_eq!(
+ record.relay_delivery_json.as_ref().unwrap()["acknowledged_relays"][0],
+ relay_url
+ );
+ assert_eq!(
+ record.raw_event_json.as_ref().unwrap()["id"],
+ record.event_id.as_deref().expect("event id")
+ );
+ }
+ assert!(signed_records.iter().any(|record| {
+ record.event_id.as_deref() == Some(profile_event_id)
+ && record.event_kind == Some(i64::from(KIND_PROFILE))
+ }));
+ assert!(signed_records.iter().any(|record| {
+ record.event_id.as_deref() == Some(farm_event_id)
+ && record.event_kind == Some(i64::from(KIND_FARM))
+ }));
+}
+
+#[test]
+fn listing_publish_failure_writes_failed_signed_outbox_record() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Green 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, "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 (output, publish) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--relay",
+ relay_url.as_str(),
+ "--approval-token",
+ "approve",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ 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"
+ );
+ assert_eq!(
+ record.relay_delivery_json.as_ref().unwrap()["failed_relays"][0]["relay"],
+ relay_url
+ );
+ assert_eq!(
+ record.raw_event_json.as_ref().unwrap()["id"],
+ record.event_id.as_deref().expect("event id")
+ );
+}
+
+#[test]
fn sync_push_partial_mixed_author_queue_reports_error_envelope() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);