cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 847d48f926cee062d3643fd97872dcf28439e70e
parent acf95826e5f5c919b4633bcfc7f52c55b88c4198
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 16:01:21 +0000

listing: add local replica ingest result

- add structured local_replica output to listing mutation views
- add a local store ingest helper that runs migrations and shared replica ingest
- report missing local stores as unconfigured with store init remediation
- preserve local ingest failures as partial relay success detail

Diffstat:
Msrc/domain/runtime.rs | 18++++++++++++++++++
Msrc/runtime/listing.rs | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 187 insertions(+), 5 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2601,6 +2601,8 @@ pub struct ListingMutationView { #[serde(skip_serializing_if = "Option::is_none")] pub requested_signer_session_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub local_replica: Option<ListingMutationLocalReplicaView>, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub job: Option<ListingMutationJobView>, @@ -2622,6 +2624,22 @@ impl ListingMutationView { } #[derive(Debug, Clone, Serialize)] +pub struct ListingMutationLocalReplicaView { + pub state: String, + pub store_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ingest_outcome: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] pub struct ListingMutationJobView { pub rpc_method: String, pub state: String, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -20,7 +20,9 @@ use radroots_events::trade::RadrootsTradeListingValidationError; 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_replica_db::ReplicaSql; +use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr}; +use radroots_replica_db::{ReplicaSql, migrations}; +use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; use radroots_sdk::{ RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError, SdkPublishReceipt, SdkRadrootsdListingPublishOptions, SdkRadrootsdPublishReceipt, @@ -34,9 +36,9 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, - ListingSummaryView, ListingValidateView, ListingValidationIssueView, RelayFailureView, - SyncFreshnessView, + ListingMutationEventView, ListingMutationJobView, ListingMutationLocalReplicaView, + ListingMutationView, ListingNewView, ListingSummaryView, ListingValidateView, + ListingValidationIssueView, RelayFailureView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -941,6 +943,7 @@ fn mutate( idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id, + local_replica: None, reason: Some(dry_run_reason(config)), job: None, event: args.print_event.then_some(event_draft.event), @@ -1243,6 +1246,7 @@ fn radrootsd_mutation_view( idempotency_key: args.idempotency_key.clone(), signer_session_id: radrootsd.signer_session_id.clone(), requested_signer_session_id: Some(requested_session_id), + local_replica: None, reason: None, job: Some(job), event: args.print_event.then_some(event), @@ -1808,6 +1812,7 @@ fn radrootsd_preflight_view( idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id: args.signer_session_id.clone(), + local_replica: None, reason: Some(reason.into()), job: None, event: args.print_event.then_some(event_preview), @@ -1852,6 +1857,7 @@ fn direct_relay_error_view( idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id: args.signer_session_id.clone(), + local_replica: None, reason: Some(parts.reason), job: None, event: args.print_event.then_some(event_preview), @@ -1992,6 +1998,7 @@ fn binding_error_view( event_addr: None, idempotency_key: args.idempotency_key.clone(), requested_signer_session_id: args.signer_session_id.clone(), + local_replica: None, reason: Some(reason), job: None, event: args.print_event.then_some(event_preview), @@ -2020,6 +2027,8 @@ fn published_mutation_view( } = receipt; debug_assert_eq!(event_id, published_event.id.to_hex()); debug_assert_eq!(signature, published_event.sig.to_string()); + let local_replica = + listing_local_replica_ingest_view(config, &published_event, Some(listing_addr.clone())); event.event_id = Some(event_id.clone()); event.created_at = Some(created_at); event.signature = Some(signature); @@ -2050,6 +2059,7 @@ fn published_mutation_view( event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), requested_signer_session_id: args.signer_session_id.clone(), + local_replica: Some(local_replica), reason: None, job: None, event: args.print_event.then_some(event), @@ -2057,6 +2067,98 @@ fn published_mutation_view( } } +fn listing_local_replica_ingest_view( + config: &RuntimeConfig, + event: &SignedNostrEvent, + event_addr: Option<String>, +) -> ListingMutationLocalReplicaView { + ingest_listing_event_into_local_replica( + config.local.replica_db_path.as_path(), + event, + event_addr, + ) +} + +fn ingest_listing_event_into_local_replica( + replica_db_path: &Path, + event: &SignedNostrEvent, + event_addr: Option<String>, +) -> ListingMutationLocalReplicaView { + let event_id = event.id.to_hex(); + if !replica_db_path.exists() { + return ListingMutationLocalReplicaView { + state: "unconfigured".to_owned(), + store_state: "missing".to_owned(), + ingest_outcome: None, + event_id: Some(event_id), + event_addr, + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots store init".to_owned()], + }; + } + + let executor = match SqliteExecutor::open(replica_db_path) { + Ok(executor) => executor, + Err(error) => { + return listing_local_replica_failed_view( + event_id, + event_addr, + format!("failed to open local replica database: {error}"), + ); + } + }; + if let Err(error) = migrations::run_all_up(&executor) { + return listing_local_replica_failed_view( + event_id, + event_addr, + format!("failed to migrate local replica database: {error}"), + ); + } + + let event = radroots_event_from_nostr(event); + match radroots_replica_ingest_event(&executor, &event) { + Ok(RadrootsReplicaIngestOutcome::Applied) => ListingMutationLocalReplicaView { + state: "applied".to_owned(), + store_state: "ready".to_owned(), + ingest_outcome: Some("applied".to_owned()), + event_id: Some(event_id), + event_addr, + reason: None, + actions: Vec::new(), + }, + Ok(RadrootsReplicaIngestOutcome::Skipped) => ListingMutationLocalReplicaView { + state: "skipped".to_owned(), + store_state: "ready".to_owned(), + ingest_outcome: Some("skipped".to_owned()), + event_id: Some(event_id), + event_addr, + reason: Some("shared replica ingest skipped the event".to_owned()), + actions: Vec::new(), + }, + Err(error) => listing_local_replica_failed_view( + event_id, + event_addr, + format!("failed to ingest listing event into local replica: {error}"), + ), + } +} + +fn listing_local_replica_failed_view( + event_id: String, + event_addr: Option<String>, + reason: String, +) -> ListingMutationLocalReplicaView { + ListingMutationLocalReplicaView { + state: "failed".to_owned(), + store_state: "unavailable".to_owned(), + ingest_outcome: None, + event_id: Some(event_id), + event_addr, + reason: Some(reason), + actions: vec!["radroots store status get".to_owned()], + } +} + fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { failures .into_iter() @@ -2391,10 +2493,13 @@ 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, + generate_d_tag, ingest_listing_event_into_local_replica, }; use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError}; use radroots_events_codec::d_tag::is_d_tag_base64url; + use radroots_events_codec::wire::WireEventParts; + use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::radroots_nostr_build_event; #[test] fn generated_listing_d_tag_is_valid_base64url() { @@ -2431,6 +2536,55 @@ mod tests { } #[test] + fn local_replica_ingest_reports_missing_store() { + let temp = tempfile::tempdir().expect("tempdir"); + let event = signed_test_listing_event(WireEventParts { + kind: super::KIND_LISTING, + content: "{}".to_owned(), + tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], + }); + + let view = ingest_listing_event_into_local_replica( + &temp.path().join("missing.sqlite"), + &event, + Some("30402:pubkey:listing-1".to_owned()), + ); + + assert_eq!(view.state, "unconfigured"); + assert_eq!(view.store_state, "missing"); + assert_eq!(view.event_id, Some(event.id.to_hex())); + assert_eq!(view.actions, vec!["radroots store init".to_owned()]); + } + + #[test] + fn local_replica_ingest_preserves_shared_ingest_failure() { + let temp = tempfile::tempdir().expect("tempdir"); + let replica = temp.path().join("replica.sqlite"); + std::fs::File::create(&replica).expect("replica placeholder"); + let event = signed_test_listing_event(WireEventParts { + kind: super::KIND_LISTING, + content: "{}".to_owned(), + tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], + }); + + let view = ingest_listing_event_into_local_replica( + &replica, + &event, + Some("30402:pubkey:listing-1".to_owned()), + ); + + assert_eq!(view.state, "failed"); + assert_eq!(view.store_state, "unavailable"); + assert_eq!(view.event_id, Some(event.id.to_hex())); + assert!( + view.reason + .as_deref() + .unwrap_or_default() + .contains("failed to ingest listing event into local replica") + ); + } + + #[test] fn listing_draft_kind_constant_is_stable() { let document = ListingDraftDocument { version: 1, @@ -2558,4 +2712,14 @@ mod tests { 1 ); } + + fn signed_test_listing_event( + parts: WireEventParts, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let identity = RadrootsIdentity::generate(); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("event builder") + .sign_with_keys(identity.keys()) + .expect("signed event") + } }