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:
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")
+ }
}