sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit f987cde525c7752152579bbed03a818d4658e171
parent 01e989020cdb3632400b4a22e4702494146033f1
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Jul 2026 03:40:33 +0000

sync: preserve direct Nostr target metadata

- map SDK direct push receipts from target-aware outbox publish receipts
- preserve logical target scope and label metadata in public push target receipts
- cover scoped duplicate endpoint success and adapter transport failure receipts
- guard the direct Nostr receipt mapper against URL-level relay receipt regressions

Diffstat:
Mcrates/sdk/src/sync_runtime.rs | 33+++++++++++++++++----------------
Mcrates/sdk/tests/source_boundary.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/sync_runtime.rs | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 65+++++++++++++++++++++++++++++++++++++++++++----------------------
4 files changed, 321 insertions(+), 39 deletions(-)

diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -43,8 +43,8 @@ use radroots_transport::{RadrootsTransportSatisfactionClass, RadrootsTransportSa use radroots_transport_nostr::RadrootsNostrClientPublishAdapter; #[cfg(feature = "runtime")] use radroots_transport_nostr::{ - RadrootsOutboxPublishPolicy, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, - RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, publish_claimed_outbox_event, + RadrootsOutboxPublishPolicy, RadrootsOutboxPublishReceipt, RadrootsOutboxPublishTargetReceipt, + RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, publish_claimed_outbox_event, }; #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] use radroots_transport_publish_protocol::{ @@ -821,10 +821,11 @@ impl<'sdk> SyncClient<'sdk> { publish_now_ms, ) .await?; + let final_state = push_event_final_state(&publish); receipt.push_attempted_event(push_event_receipt( claimed.outbox_event_id, - push_event_final_state(&publish.publish), - publish.publish, + final_state, + publish, )?); } Ok(receipt) @@ -1780,7 +1781,7 @@ fn push_outbox_claim_token() -> String { } #[cfg(feature = "runtime")] -fn push_event_final_state(publish: &RadrootsRelayPublishReceipt) -> PushOutboxEventState { +fn push_event_final_state(publish: &RadrootsOutboxPublishReceipt) -> PushOutboxEventState { if publish.quorum_met { PushOutboxEventState::Published } else if publish.retryable_count > 0 { @@ -1794,11 +1795,11 @@ fn push_event_final_state(publish: &RadrootsRelayPublishReceipt) -> PushOutboxEv fn push_event_receipt( outbox_event_id: i64, final_state: PushOutboxEventState, - publish: RadrootsRelayPublishReceipt, + publish: RadrootsOutboxPublishReceipt, ) -> Result<PushOutboxEventReceipt, RadrootsSdkError> { let event_id = push_receipt_event_id( publish.event_id.as_str(), - "relay transport publish receipt event id", + "direct Nostr outbox publish receipt event id", )?; Ok(PushOutboxEventReceipt { event_id, @@ -1811,7 +1812,7 @@ fn push_event_receipt( quorum: publish.quorum, quorum_met: publish.quorum_met, targets: publish - .relays + .target_receipts .into_iter() .map(push_target_receipt) .collect(), @@ -1826,16 +1827,16 @@ fn push_receipt_event_id(value: &str, field: &str) -> Result<RadrootsEventId, Ra } #[cfg(feature = "runtime")] -fn push_target_receipt(relay: RadrootsRelayPublishRelayReceipt) -> PushOutboxTargetReceipt { +fn push_target_receipt(target: RadrootsOutboxPublishTargetReceipt) -> PushOutboxTargetReceipt { PushOutboxTargetReceipt { transport_kind: RadrootsTransportKind::Nostr.canonical_label(), - endpoint_uri: relay.relay_url, - target_scope: None, - target_label: None, - outcome_kind: relay.outcome.kind.into(), - transport_outcome_kind: Some(relay.outcome.kind.transport_outcome_kind().into()), - attempted: relay.attempted, - message: relay.outcome.message, + endpoint_uri: target.endpoint_uri, + target_scope: target.target_scope, + target_label: target.target_label, + outcome_kind: target.outcome.kind.into(), + transport_outcome_kind: Some(target.outcome.kind.transport_outcome_kind().into()), + attempted: target.attempted, + message: target.outcome.message, } } diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1556,6 +1556,66 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { } #[test] +fn sdk_direct_nostr_push_receipts_preserve_outbox_target_metadata() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); + + for required in [ + "RadrootsOutboxPublishReceipt", + "RadrootsOutboxPublishTargetReceipt", + "push_event_final_state(&publish)", + ".target_receipts", + "target_scope: target.target_scope", + "target_label: target.target_label", + ] { + assert!( + sync_runtime_source.contains(required), + "src/sync_runtime.rs must preserve direct Nostr target-aware receipt witness `{required}`" + ); + } + + let direct_event_receipt_source = source_between( + sync_runtime_source.as_str(), + "fn push_event_receipt", + "fn push_receipt_event_id", + ); + assert!( + !direct_event_receipt_source.contains(".relays"), + "push_event_receipt must not derive SDK direct publish targets from URL-level relay receipts" + ); + + let direct_target_receipt_source = source_between( + sync_runtime_source.as_str(), + "fn push_target_receipt", + "#[cfg(all(test", + ); + for forbidden in [ + "target_scope: None", + "target_label: None", + "RadrootsRelayPublishRelayReceipt", + ] { + assert!( + !direct_target_receipt_source.contains(forbidden), + "push_target_receipt must not hard-code or relay-derive direct Nostr metadata token `{forbidden}`" + ); + } + + let sync_runtime_test_source = + read_source(manifest_dir.join("tests/sync_runtime.rs").as_path()); + for required in [ + "push_outbox_with_adapter_preserves_scoped_duplicate_target_metadata", + "push_outbox_adapter_transport_failure_preserves_scoped_target_metadata", + "target.target_scope.as_deref() == Some(\"farm.a\")", + "target.target_scope.as_deref() == Some(\"farm.b\")", + ] { + assert!( + sync_runtime_test_source.contains(required), + "tests/sync_runtime.rs must retain SDK direct Nostr metadata coverage `{required}`" + ); + } +} + +#[test] fn sdk_public_outcome_label_contracts_are_explicit() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -33,7 +33,10 @@ use radroots_sdk::{ ReticulumPreviewTryNowRequest, SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource, TargetPolicy, TransportProfile, }; -use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; +use radroots_transport::{ + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel, +}; use radroots_transport_nostr::{ RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome, RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError, @@ -531,6 +534,36 @@ async fn enqueue_listing(sdk: &RadrootsClient, d_tag: &str, title: &str, relays: enqueue_listing_with_policy(sdk, d_tag, title, relays, NostrRelayUrlPolicy::Public).await } +async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, title: &str) -> i64 { + let plan = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor(), + listing(d_tag, title), + )) + .expect("prepared listing"); + let signer = FixtureSigner::new(SELLER); + let signed_event = signer + .sign_frozen_draft(&plan.frozen_draft) + .expect("signed listing"); + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + outbox + .enqueue_signed_operation(RadrootsOutboxSignedOperationInput::new( + LISTING_PUBLISH_OPERATION_KIND, + plan.frozen_draft, + signed_event, + scoped_duplicate_relay_delivery_plan(RELAY_A), + true, + 1_700_000_000_000, + 1_700_000_000_000, + )) + .await + .expect("scoped duplicate enqueue") + .outbox_event_id +} + async fn assert_local_import_observation(sdk: &RadrootsClient, outbox_event_id: i64) { let paths = sdk.storage_paths().expect("paths"); let outbox = RadrootsOutbox::open_file(&paths.outbox_path) @@ -578,6 +611,30 @@ where ) } +fn scoped_duplicate_relay_delivery_plan( + relay: &str, +) -> radroots_outbox::RadrootsOutboxDeliveryPlanInput { + radroots_outbox::RadrootsOutboxDeliveryPlanInput::new( + "explicit.scoped", + 2, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![ + scoped_nostr_target(relay, "farm.a", "Farm A"), + scoped_nostr_target(relay, "farm.b", "Farm B"), + ], + ) +} + +fn scoped_nostr_target(relay: &str, scope: &str, label: &str) -> RadrootsTransportTarget { + RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Nostr, + relay, + Some(RadrootsTransportMeshScopeId::parse(scope).expect("target scope")), + Some(RadrootsTransportTargetLabel::parse(label).expect("target label")), + ) + .expect("scoped Nostr target") +} + async fn backup_source(sdk: &RadrootsClient, root: &Path, name: &str) -> PathBuf { let source = root.join(name); sdk.backup(BackupRequest::new(source.clone())) @@ -2392,6 +2449,149 @@ async fn push_outbox_with_adapter_uses_queued_targets_without_builder_relays() { } #[tokio::test] +async fn push_outbox_with_adapter_preserves_scoped_duplicate_target_metadata() { + let (_tempdir, sdk) = directory_sdk(&[]).await; + let outbox_event_id = + enqueue_scoped_duplicate_listing(&sdk, LISTING_A_D_TAG, "Scoped Coffee").await; + let adapter = RecordingPublishAdapter::new(Duration::ZERO); + + let receipt = sdk + .sync() + .push_outbox_with_adapter(&adapter, PushOutboxRequest::new().with_limit(1)) + .await + .expect("push"); + + assert_eq!(receipt.attempted_events, 1); + assert_eq!(receipt.published_events, 1); + assert_eq!(adapter.relay_batches(), vec![vec![RELAY_A.to_owned()]]); + let event = &receipt.events[0]; + assert_eq!(event.outbox_event_id, outbox_event_id); + assert_eq!(event.final_state, PushOutboxEventState::Published); + assert_eq!(event.attempted_count, 2); + assert_eq!(event.accepted_count, 2); + assert_eq!(event.retryable_count, 0); + assert_eq!(event.terminal_count, 0); + assert_eq!(event.quorum, 2); + assert!(event.quorum_met); + assert_eq!(event.targets.len(), 2); + assert!(event.targets.iter().all(|target| { + target.transport_kind == "nostr" + && target.endpoint_uri == RELAY_A + && target.attempted + && target.outcome_kind == PushOutboxTargetOutcomeKind::Accepted + })); + assert!(event.targets.iter().any(|target| { + target.target_scope.as_deref() == Some("farm.a") + && target.target_label.as_deref() == Some("Farm A") + })); + assert!(event.targets.iter().any(|target| { + target.target_scope.as_deref() == Some("farm.b") + && target.target_label.as_deref() == Some("Farm B") + })); + + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let stored = outbox + .get_event(outbox_event_id) + .await + .expect("stored") + .expect("stored"); + assert_eq!(stored.state, RadrootsOutboxEventState::Published); + let targets = outbox + .delivery_targets(outbox_event_id) + .await + .expect("targets"); + assert_eq!(targets.len(), 2); + assert!(targets.iter().all(|target| { + target.endpoint_uri.as_str() == RELAY_A + && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted + })); + assert!(targets.iter().any(|target| { + target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a") + && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A") + })); + assert!(targets.iter().any(|target| { + target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b") + && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B") + })); +} + +#[tokio::test] +async fn push_outbox_adapter_transport_failure_preserves_scoped_target_metadata() { + let (_tempdir, sdk) = directory_sdk(&[]).await; + let outbox_event_id = + enqueue_scoped_duplicate_listing(&sdk, LISTING_B_D_TAG, "Scoped Retry Coffee").await; + + let receipt = sdk + .sync() + .push_outbox_with_adapter( + &TransportFailurePublishAdapter, + PushOutboxRequest::new().with_limit(1), + ) + .await + .expect("push"); + + assert_eq!(receipt.attempted_events, 1); + assert_eq!(receipt.published_events, 0); + assert_eq!(receipt.retryable_events, 1); + assert_eq!(receipt.terminal_events, 0); + let event = &receipt.events[0]; + assert_eq!(event.outbox_event_id, outbox_event_id); + assert_eq!(event.final_state, PushOutboxEventState::PublishRetryable); + assert_eq!(event.attempted_count, 2); + assert_eq!(event.accepted_count, 0); + assert_eq!(event.retryable_count, 2); + assert_eq!(event.terminal_count, 0); + assert_eq!(event.quorum, 2); + assert!(!event.quorum_met); + assert_eq!(event.targets.len(), 2); + assert!(event.targets.iter().all(|target| { + target.transport_kind == "nostr" + && target.endpoint_uri == RELAY_A + && target.attempted + && target.outcome_kind == PushOutboxTargetOutcomeKind::ConnectionFailed + && target.message.as_deref() == Some("adapter boundary unavailable") + })); + assert!(event.targets.iter().any(|target| { + target.target_scope.as_deref() == Some("farm.a") + && target.target_label.as_deref() == Some("Farm A") + })); + assert!(event.targets.iter().any(|target| { + target.target_scope.as_deref() == Some("farm.b") + && target.target_label.as_deref() == Some("Farm B") + })); + + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let stored = outbox + .get_event(outbox_event_id) + .await + .expect("stored") + .expect("stored"); + assert_eq!(stored.state, RadrootsOutboxEventState::PublishRetryable); + assert!(stored.claim_token.is_none()); + let targets = outbox + .delivery_targets(outbox_event_id) + .await + .expect("targets"); + assert_eq!(targets.len(), 2); + assert!(targets.iter().all(|target| { + target.endpoint_uri.as_str() == RELAY_A + && target.status == RadrootsOutboxDeliveryTargetStatus::FailedRetryable + })); + assert!(targets.iter().any(|target| { + target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a") + && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm A") + })); + assert!(targets.iter().any(|target| { + target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b") + && target.target_label.as_ref().map(|label| label.as_str()) == Some("Farm B") + })); +} + +#[tokio::test] async fn push_outbox_with_adapter_recovers_expired_publishing_claim_before_selecting_work() { let tempdir = tempfile::tempdir().expect("tempdir"); let storage = tempdir.path().join("sdk"); diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -42,15 +42,18 @@ use radroots_outbox::{ RadrootsOutboxDeliveryTargetRecord, RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxOperationInput, RadrootsOutboxSignedOperationInput, }; -use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; +use radroots_outbox::{ + RadrootsOutboxEventState, RadrootsOutboxEventStoreIngestReceipt, RadrootsOutboxStatusSummary, +}; #[cfg(feature = "radrootsd-proxy")] use radroots_transport::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel, }; use radroots_transport_nostr::{ - RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, - RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError, + RadrootsOutboxPublishReceipt, RadrootsOutboxPublishTargetReceipt, RadrootsRelayOutcomeKind, + RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, + RadrootsRelayTransportError, }; #[cfg(feature = "radrootsd-proxy")] use radroots_transport_publish_protocol::{ @@ -379,7 +382,7 @@ fn push_event_receipt_parses_typed_event_id() { let receipt = push_event_receipt( 1, PushOutboxEventState::Published, - relay_publish_receipt(event_id.as_str()).with_relay(), + outbox_publish_receipt(event_id.as_str()).with_target(), ) .expect("receipt"); @@ -390,6 +393,14 @@ fn push_event_receipt_parses_typed_event_id() { assert_eq!(receipt.targets.len(), 1); assert_eq!(receipt.targets[0].transport_kind, "nostr"); assert_eq!(receipt.targets[0].endpoint_uri, "wss://relay.example.com"); + assert_eq!( + receipt.targets[0].target_scope.as_deref(), + Some("farm.local") + ); + assert_eq!( + receipt.targets[0].target_label.as_deref(), + Some("Farm relay") + ); assert!(receipt.targets[0].attempted); } @@ -398,19 +409,19 @@ fn push_event_receipt_returns_typed_error_for_invalid_internal_event_id() { let error = push_event_receipt( 1, PushOutboxEventState::Published, - relay_publish_receipt("not-a-valid-event-id"), + outbox_publish_receipt("not-a-valid-event-id"), ) .expect_err("invalid event id"); assert!(matches!( error, RadrootsSdkError::InvalidRequest { message } - if message.contains("relay transport publish receipt event id is invalid") + if message.contains("direct Nostr outbox publish receipt event id is invalid") )); } #[test] fn push_event_final_state_follows_publish_quorum_and_retryability() { - let published = relay_publish_receipt("a".repeat(64).as_str()) + let published = outbox_publish_receipt("a".repeat(64).as_str()) .with_quorum_met(true) .with_retryable_count(1); assert_eq!( @@ -418,13 +429,13 @@ fn push_event_final_state_follows_publish_quorum_and_retryability() { PushOutboxEventState::Published ); - let retryable = relay_publish_receipt("b".repeat(64).as_str()).with_retryable_count(1); + let retryable = outbox_publish_receipt("b".repeat(64).as_str()).with_retryable_count(1); assert_eq!( push_event_final_state(&retryable), PushOutboxEventState::PublishRetryable ); - let terminal = relay_publish_receipt("c".repeat(64).as_str()); + let terminal = outbox_publish_receipt("c".repeat(64).as_str()); assert_eq!( push_event_final_state(&terminal), PushOutboxEventState::FailedTerminal @@ -1656,8 +1667,14 @@ fn push_proxy_event_receipt_returns_typed_error_for_invalid_daemon_event_id() { )); } -fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt { - RadrootsRelayPublishReceipt { +fn outbox_publish_receipt(event_id: &str) -> RadrootsOutboxPublishReceipt { + RadrootsOutboxPublishReceipt { + local_ingest: RadrootsOutboxEventStoreIngestReceipt { + outbox_event_id: 1, + event_id: event_id.to_owned(), + already_ingested: false, + event_store_inserted: true, + }, event_id: event_id.to_owned(), attempted_count: 0, accepted_count: 0, @@ -1665,24 +1682,28 @@ fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt { terminal_count: 0, quorum: 0, quorum_met: false, - relays: Vec::new(), + target_receipts: Vec::new(), + relay_receipts: Vec::new(), } } -trait RelayReceiptFixture { - fn with_relay(self) -> Self; +trait OutboxPublishReceiptFixture { + fn with_target(self) -> Self; fn with_quorum_met(self, quorum_met: bool) -> Self; fn with_retryable_count(self, retryable_count: usize) -> Self; } -impl RelayReceiptFixture for RadrootsRelayPublishReceipt { - fn with_relay(mut self) -> Self { - self.relays.push( - radroots_transport_nostr::RadrootsRelayPublishRelayReceipt::attempted( - "wss://relay.example.com", - radroots_transport_nostr::RadrootsRelayOutcome::accepted(), - ), - ); +impl OutboxPublishReceiptFixture for RadrootsOutboxPublishReceipt { + fn with_target(mut self) -> Self { + self.target_receipts + .push(RadrootsOutboxPublishTargetReceipt { + delivery_target_id: 10, + endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: Some("farm.local".to_owned()), + target_label: Some("Farm relay".to_owned()), + attempted: true, + outcome: radroots_transport_nostr::RadrootsRelayOutcome::accepted(), + }); self }