commit 273a24aafa6211b0fa60bc88647d4a57e0edc2b8
parent 3166eee6d9f4c71916ee6db9bc49cbaac6139538
Author: triesap <tyson@radroots.org>
Date: Wed, 8 Jul 2026 07:10:23 +0000
transport: expose neutral sync status
Diffstat:
4 files changed, 303 insertions(+), 25 deletions(-)
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -221,7 +221,8 @@ pub use crate::sync_runtime::{
PushOutboxTargetReceipt, SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT,
SYNC_PROJECTION_REFRESH_MAX_LIMIT, SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus,
SyncProjectionRefreshReceipt, SyncProjectionRefreshRequest, SyncStatusReceipt,
- SyncStatusRequest, SyncStatusSource, SyncTransportProfileSummary,
+ SyncStatusRequest, SyncStatusSource, SyncTransportProfileSummary, SyncTransportStatusSummary,
+ SyncTransportTargetSummary,
};
#[cfg(feature = "runtime")]
pub use crate::trade_storage::{
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -28,10 +28,13 @@ use radroots_trade::projection::{
RadrootsProjectionRefreshReceipt, RadrootsProjectionRefreshRequest,
refresh_product_projections,
};
-#[cfg(feature = "runtime")]
-use radroots_transport::RadrootsTransportKind;
#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))]
use radroots_transport::RadrootsTransportSatisfactionPolicy;
+#[cfg(feature = "runtime")]
+use radroots_transport::{
+ RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportReadinessState,
+ RadrootsTransportStatus, RadrootsTransportTarget,
+};
#[cfg(all(feature = "runtime", feature = "relay-runtime"))]
use radroots_transport_nostr::RadrootsNostrClientPublishAdapter;
#[cfg(feature = "runtime")]
@@ -154,8 +157,101 @@ impl From<RadrootsOutboxStatusSummary> for SyncOutboxStatus {
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct SyncTransportProfileSummary {
pub transport_profile_id: String,
- pub configured_nostr_relay_count: usize,
- pub configured_nostr_relays: Vec<String>,
+ pub configured_transport_target_count: usize,
+ pub configured_transport_targets: Vec<SyncTransportTargetSummary>,
+ pub transport_statuses: Vec<SyncTransportStatusSummary>,
+}
+
+#[cfg(feature = "runtime")]
+impl SyncTransportProfileSummary {
+ fn from_transport_profile(profile: &TransportProfile) -> Result<Self, RadrootsSdkError> {
+ let configured_transport_targets = profile
+ .configured_transport_targets()?
+ .iter()
+ .map(SyncTransportTargetSummary::from_transport_target)
+ .collect::<Vec<_>>();
+ Ok(Self {
+ transport_profile_id: profile.transport_profile_id().to_owned(),
+ configured_transport_target_count: configured_transport_targets.len(),
+ configured_transport_targets,
+ transport_statuses: profile
+ .transport_statuses()
+ .into_iter()
+ .map(SyncTransportStatusSummary::from_transport_status)
+ .collect(),
+ })
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SyncTransportTargetSummary {
+ pub transport_kind: String,
+ pub endpoint_uri: String,
+ pub endpoint_fingerprint: String,
+}
+
+#[cfg(feature = "runtime")]
+impl SyncTransportTargetSummary {
+ fn from_transport_target(target: &RadrootsTransportTarget) -> Self {
+ Self {
+ transport_kind: target.kind.canonical_label(),
+ endpoint_uri: target.uri.as_str().to_owned(),
+ endpoint_fingerprint: target.fingerprint.as_str().to_owned(),
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SyncTransportStatusSummary {
+ pub transport_kind: String,
+ pub profile_id: Option<String>,
+ pub endpoint_uri: Option<String>,
+ pub implementation_state: String,
+ pub readiness: String,
+ pub publish_usable: bool,
+ pub fetch_usable: bool,
+ pub redacted_message: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+impl SyncTransportStatusSummary {
+ fn from_transport_status(status: RadrootsTransportStatus) -> Self {
+ Self {
+ transport_kind: status.kind.canonical_label(),
+ profile_id: status.profile_id,
+ endpoint_uri: status.endpoint_uri,
+ implementation_state: transport_implementation_state_label(status.implementation_state)
+ .to_owned(),
+ readiness: transport_readiness_state_label(status.readiness).to_owned(),
+ publish_usable: status.publish_usable,
+ fetch_usable: status.fetch_usable,
+ redacted_message: status.redacted_message,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn transport_implementation_state_label(
+ state: RadrootsTransportImplementationState,
+) -> &'static str {
+ match state {
+ RadrootsTransportImplementationState::Available => "available",
+ RadrootsTransportImplementationState::Disabled => "disabled",
+ RadrootsTransportImplementationState::Misconfigured => "misconfigured",
+ RadrootsTransportImplementationState::PreviewUnavailable => "preview_unavailable",
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn transport_readiness_state_label(state: RadrootsTransportReadinessState) -> &'static str {
+ match state {
+ RadrootsTransportReadinessState::Ready => "ready",
+ RadrootsTransportReadinessState::Disabled => "disabled",
+ RadrootsTransportReadinessState::Misconfigured => "misconfigured",
+ RadrootsTransportReadinessState::PreviewUnavailable => "preview_unavailable",
+ }
}
#[cfg(feature = "runtime")]
@@ -492,15 +588,9 @@ impl<'sdk> SyncClient<'sdk> {
observed_at_ms,
event_store: event_store.into(),
outbox: outbox.into(),
- transport_profile: SyncTransportProfileSummary {
- transport_profile_id: self
- .sdk
- .transport_profile()
- .transport_profile_id()
- .to_owned(),
- configured_nostr_relay_count: self.sdk.configured_nostr_relay_urls().len(),
- configured_nostr_relays: self.sdk.configured_nostr_relay_urls(),
- },
+ transport_profile: SyncTransportProfileSummary::from_transport_profile(
+ self.sdk.transport_profile(),
+ )?,
})
}
diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs
@@ -1,8 +1,10 @@
use crate::RadrootsSdkError;
use radroots_transport::{
- RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportDeliveryReceipt,
- RadrootsTransportKind, RadrootsTransportSatisfactionClass, RadrootsTransportTarget,
- RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet,
+ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
+ RadrootsTransportDeliveryReceipt, RadrootsTransportImplementationState, RadrootsTransportKind,
+ RadrootsTransportReadinessState, RadrootsTransportSatisfactionClass, RadrootsTransportStatus,
+ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetReceipt,
+ RadrootsTransportTargetSet,
};
use radroots_transport_nostr::{RadrootsRelayUrl, RadrootsRelayUrlPolicy};
use serde::ser::{SerializeStruct, Serializer};
@@ -497,6 +499,63 @@ impl TransportProfile {
}
}
+ pub(crate) fn configured_transport_targets(
+ &self,
+ ) -> Result<Vec<RadrootsTransportTarget>, RadrootsSdkError> {
+ Ok(self
+ .target_set()?
+ .map(TargetSet::into_targets)
+ .unwrap_or_default())
+ }
+
+ pub(crate) fn transport_statuses(&self) -> Vec<RadrootsTransportStatus> {
+ match self {
+ Self::LocalOnly => vec![
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Local,
+ RadrootsTransportImplementationState::Available,
+ RadrootsTransportReadinessState::Ready,
+ )
+ .with_profile_id(self.transport_profile_id()),
+ ],
+ Self::Nostr { .. } => vec![nostr_transport_status(self.transport_profile_id())],
+ Self::ReticulumPreview { profile } => {
+ vec![reticulum_preview_transport_status(
+ self.transport_profile_id(),
+ profile.endpoint_uri(),
+ )]
+ }
+ Self::Hybrid { profile } => vec![
+ nostr_transport_status(self.transport_profile_id()),
+ reticulum_preview_transport_status(
+ self.transport_profile_id(),
+ profile.reticulum_preview().endpoint_uri(),
+ ),
+ ],
+ Self::Proxy { profile } => {
+ let auth_configured = matches!(profile.auth(), ProxyAuth::BearerToken(_));
+ vec![
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Proxy,
+ if auth_configured {
+ RadrootsTransportImplementationState::Available
+ } else {
+ RadrootsTransportImplementationState::Misconfigured
+ },
+ if auth_configured {
+ RadrootsTransportReadinessState::Ready
+ } else {
+ RadrootsTransportReadinessState::Misconfigured
+ },
+ )
+ .with_profile_id(self.transport_profile_id())
+ .with_endpoint_uri(profile.endpoint_url())
+ .with_publish_usable(auth_configured),
+ ]
+ }
+ }
+ }
+
pub(crate) fn configured_nostr_relay_urls(&self) -> Vec<String> {
match self {
Self::Nostr { profile } => profile.relay_urls(),
@@ -506,6 +565,31 @@ impl TransportProfile {
}
}
+fn nostr_transport_status(profile_id: &str) -> RadrootsTransportStatus {
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Nostr,
+ RadrootsTransportImplementationState::Available,
+ RadrootsTransportReadinessState::Ready,
+ )
+ .with_profile_id(profile_id)
+ .with_publish_usable(true)
+ .with_fetch_usable(true)
+}
+
+fn reticulum_preview_transport_status(
+ profile_id: &str,
+ endpoint_uri: &str,
+) -> RadrootsTransportStatus {
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Reticulum,
+ RadrootsTransportImplementationState::PreviewUnavailable,
+ RadrootsTransportReadinessState::PreviewUnavailable,
+ )
+ .with_profile_id(profile_id)
+ .with_endpoint_uri(endpoint_uri)
+ .with_redacted_message(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE)
+}
+
impl From<RadrootsTransportDeliveryReceipt> for TransportReceipt {
fn from(value: RadrootsTransportDeliveryReceipt) -> Self {
Self {
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -23,8 +23,8 @@ use radroots_outbox::{
#[cfg(feature = "radrootsd-proxy")]
use radroots_sdk::ProxyProfile;
use radroots_sdk::{
- BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest,
- ListingPreparePublishRequest, NostrProfile, NostrRelayUrlPolicy,
+ BackupRequest, HybridProfile, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND,
+ ListingEnqueuePublishRequest, ListingPreparePublishRequest, NostrProfile, NostrRelayUrlPolicy,
PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT,
PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt,
PushOutboxEventState, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind,
@@ -497,6 +497,22 @@ async fn reticulum_preview_directory_sdk(
(tempdir, sdk)
}
+async fn hybrid_directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let sdk = RadrootsClient::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .transport_profile(TransportProfile::hybrid(HybridProfile::new(
+ NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public)
+ .expect("Nostr profile"),
+ ReticulumPreviewProfile::preview_unavailable(),
+ )))
+ .build()
+ .await
+ .expect("sdk");
+ (tempdir, sdk)
+}
+
async fn system_clock_directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) {
let tempdir = tempfile::tempdir().expect("tempdir");
let mut builder = RadrootsClient::builder().directory_storage(tempdir.path().join("sdk"));
@@ -576,7 +592,7 @@ async fn enqueue_listing_with_policy(
}
#[tokio::test]
-async fn sync_status_empty_store_reports_canonical_sources_and_configured_relays() {
+async fn sync_status_empty_store_reports_canonical_sources_and_transport_targets() {
let (_tempdir, sdk) = directory_sdk(&[RELAY_B, RELAY_A]).await;
let receipt = sdk
@@ -598,10 +614,27 @@ async fn sync_status_empty_store_reports_canonical_sources_and_configured_relays
assert_eq!(receipt.outbox.failed_terminal_events, 0);
assert_eq!(receipt.outbox.ready_signed_events, 0);
assert_eq!(receipt.transport_profile.transport_profile_id, "nostr");
- assert_eq!(receipt.transport_profile.configured_nostr_relay_count, 2);
assert_eq!(
- receipt.transport_profile.configured_nostr_relays,
- vec![RELAY_B.to_owned(), RELAY_A.to_owned()]
+ receipt.transport_profile.configured_transport_target_count,
+ 2
+ );
+ assert_eq!(
+ receipt
+ .transport_profile
+ .configured_transport_targets
+ .iter()
+ .map(|target| target.endpoint_uri.as_str())
+ .collect::<Vec<_>>(),
+ vec![RELAY_B, RELAY_A]
+ );
+ assert_eq!(receipt.transport_profile.transport_statuses.len(), 1);
+ assert_eq!(
+ receipt.transport_profile.transport_statuses[0].transport_kind,
+ "nostr"
+ );
+ assert_eq!(
+ receipt.transport_profile.transport_statuses[0].readiness,
+ "ready"
);
assert_eq!(
serde_json::to_value(&receipt).expect("status json"),
@@ -630,14 +663,84 @@ async fn sync_status_empty_store_reports_canonical_sources_and_configured_relays
},
"transport_profile": {
"transport_profile_id": "nostr",
- "configured_nostr_relay_count": 2,
- "configured_nostr_relays": [RELAY_B, RELAY_A]
+ "configured_transport_target_count": 2,
+ "configured_transport_targets": [
+ {
+ "transport_kind": "nostr",
+ "endpoint_uri": RELAY_B,
+ "endpoint_fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05"
+ },
+ {
+ "transport_kind": "nostr",
+ "endpoint_uri": RELAY_A,
+ "endpoint_fingerprint": "fc957b234632cc52e2be19cba88bc85c69966ee5a2df61742b5875ff717fd6fa"
+ }
+ ],
+ "transport_statuses": [{
+ "transport_kind": "nostr",
+ "profile_id": "nostr",
+ "endpoint_uri": null,
+ "implementation_state": "available",
+ "readiness": "ready",
+ "publish_usable": true,
+ "fetch_usable": true,
+ "redacted_message": null
+ }]
}
})
);
}
#[tokio::test]
+async fn sync_status_reports_hybrid_transport_targets_and_statuses() {
+ let (_tempdir, sdk) = hybrid_directory_sdk(&[RELAY_A, RELAY_B]).await;
+
+ let receipt = sdk
+ .sync()
+ .status(SyncStatusRequest::new())
+ .await
+ .expect("status");
+
+ assert_eq!(receipt.transport_profile.transport_profile_id, "hybrid");
+ assert_eq!(
+ receipt.transport_profile.configured_transport_target_count,
+ 3
+ );
+ assert_eq!(
+ receipt
+ .transport_profile
+ .configured_transport_targets
+ .iter()
+ .map(|target| { (target.transport_kind.as_str(), target.endpoint_uri.as_str(),) })
+ .collect::<Vec<_>>(),
+ vec![
+ ("nostr", RELAY_A),
+ ("nostr", RELAY_B),
+ ("reticulum", "reticulum:preview-unavailable")
+ ]
+ );
+ assert_eq!(
+ receipt
+ .transport_profile
+ .transport_statuses
+ .iter()
+ .map(|status| {
+ (
+ status.transport_kind.as_str(),
+ status.readiness.as_str(),
+ status.publish_usable,
+ status.fetch_usable,
+ )
+ })
+ .collect::<Vec<_>>(),
+ vec![
+ ("nostr", "ready", true, true),
+ ("reticulum", "preview_unavailable", false, false)
+ ]
+ );
+}
+
+#[tokio::test]
async fn sync_status_reports_pending_retryable_terminal_and_last_attempt_metadata() {
let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B, RELAY_C]).await;
enqueue_listing(&sdk, LISTING_A_D_TAG, "Retryable Coffee", &[RELAY_A]).await;