sdk

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

commit 2a77aee91492d052e1b22aa009266cf223dbe7b8
parent 84e4f67d326c8c5d1153f1c709fa52d2cf6dfaa5
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 13:47:39 -0700

sdk: add runtime contract DTOs

- expose stable SDK error codes, classes, retryability, details, and typed recovery actions

- serialize CLI-facing runtime DTOs with redacted idempotency and signer-sensitive fields

- reject boundary whitespace in SDK idempotency keys and split relay caller order from canonical digest order

- validate with SDK fmt, no-default check, cli-runtime tests, all-features tests, workspace check, and xtask check

Diffstat:
Mcrates/sdk/src/error.rs | 219++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/src/idempotency.rs | 20+++++++++++++++++++-
Mcrates/sdk/src/lib.rs | 10++++++----
Mcrates/sdk/src/listings_runtime.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/src/orders_runtime.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/src/relay_targets.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/src/runtime.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/src/sync_runtime.rs | 16++++++++++------
Mcrates/sdk/tests/listings_runtime.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 39++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/runtime_foundation.rs | 328++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/sync_runtime.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
12 files changed, 1034 insertions(+), 59 deletions(-)

diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -2,22 +2,49 @@ use std::{fmt, path::PathBuf}; #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +use serde_json::{Value, json}; + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum RadrootsSdkErrorClass { + Authorization, + Clock, + Configuration, + LocalMutation, + Request, + Storage, + Transport, + Unsupported, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum RadrootsSdkRecoveryAction { RetryOutboxEnqueue, InspectLocalStores, RetryOperationWithSameIdempotencyKey, + ConfigureRelayTargets, + FixRequest, + SelectAuthorizedActor, + RetryAfterTransportFailure, + EnableRequiredFeature, } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum RadrootsSdkPartialLocalMutationFailure { OutboxEnqueue, OutboxIdempotencyConflict, } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct RadrootsSdkPartialLocalMutationError { pub event_id: Option<String>, pub operation_kind: String, @@ -110,6 +137,175 @@ pub enum RadrootsSdkError { #[cfg(feature = "runtime")] impl RadrootsSdkError { + pub fn code(&self) -> &'static str { + match self { + Self::Io { .. } => "io", + Self::ClockBeforeUnixEpoch => "clock_before_unix_epoch", + Self::TimestampOutOfRange { .. } => "timestamp_out_of_range", + Self::UnauthorizedActor { .. } => "unauthorized_actor", + Self::SignerPubkeyMismatch { .. } => "signer_pubkey_mismatch", + Self::EmptyTargetRelays { .. } => "empty_target_relays", + Self::RelayTargetLimitExceeded { .. } => "relay_target_limit_exceeded", + Self::InvalidRelayUrl { .. } => "invalid_relay_url", + Self::IdempotencyConflict { .. } => "idempotency_conflict", + Self::OrderStatusLimitInvalid { .. } => "order_status_limit_invalid", + Self::InvalidOrderId { .. } => "invalid_order_id", + Self::ProductSyncUnsupported { .. } => "product_sync_unsupported", + Self::ProductSyncRelaySetupFailure { .. } => "product_sync_relay_setup_failure", + Self::Authority { .. } => "authority", + Self::EventStore { .. } => "event_store", + Self::InvalidRequest { .. } => "invalid_request", + Self::ListingDraft { .. } => "listing_draft", + Self::ListingMutation { .. } => "listing_mutation", + Self::Outbox { .. } => "outbox", + Self::RelayTransport { .. } => "relay_transport", + Self::Projection { .. } => "projection", + Self::PartialLocalMutation(_) => "partial_local_mutation", + } + } + + pub fn class(&self) -> RadrootsSdkErrorClass { + match self { + Self::Io { .. } + | Self::EventStore { .. } + | Self::Outbox { .. } + | Self::Projection { .. } => RadrootsSdkErrorClass::Storage, + Self::ClockBeforeUnixEpoch | Self::TimestampOutOfRange { .. } => { + RadrootsSdkErrorClass::Clock + } + Self::UnauthorizedActor { .. } + | Self::SignerPubkeyMismatch { .. } + | Self::Authority { .. } => RadrootsSdkErrorClass::Authorization, + Self::EmptyTargetRelays { .. } + | Self::RelayTargetLimitExceeded { .. } + | Self::InvalidRelayUrl { .. } => RadrootsSdkErrorClass::Configuration, + Self::IdempotencyConflict { .. } + | Self::OrderStatusLimitInvalid { .. } + | Self::InvalidOrderId { .. } + | Self::InvalidRequest { .. } + | Self::ListingDraft { .. } + | Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request, + Self::ProductSyncUnsupported { .. } => RadrootsSdkErrorClass::Unsupported, + Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => { + RadrootsSdkErrorClass::Transport + } + Self::PartialLocalMutation(_) => RadrootsSdkErrorClass::LocalMutation, + } + } + + pub fn retryable(&self) -> bool { + matches!( + self, + Self::Io { .. } + | Self::ProductSyncRelaySetupFailure { .. } + | Self::EventStore { .. } + | Self::Outbox { .. } + | Self::RelayTransport { .. } + | Self::Projection { .. } + | Self::PartialLocalMutation(_) + ) + } + + pub fn recovery_actions(&self) -> Vec<RadrootsSdkRecoveryAction> { + match self { + Self::Io { .. } + | Self::EventStore { .. } + | Self::Outbox { .. } + | Self::Projection { .. } => vec![RadrootsSdkRecoveryAction::InspectLocalStores], + Self::UnauthorizedActor { .. } + | Self::SignerPubkeyMismatch { .. } + | Self::Authority { .. } => vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], + Self::EmptyTargetRelays { .. } + | Self::RelayTargetLimitExceeded { .. } + | Self::InvalidRelayUrl { .. } => { + vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets] + } + Self::IdempotencyConflict { .. } => { + vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] + } + Self::ProductSyncUnsupported { .. } => { + vec![RadrootsSdkRecoveryAction::EnableRequiredFeature] + } + Self::ProductSyncRelaySetupFailure { .. } | Self::RelayTransport { .. } => { + vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure] + } + Self::PartialLocalMutation(error) => vec![error.recovery], + Self::ClockBeforeUnixEpoch + | Self::TimestampOutOfRange { .. } + | Self::OrderStatusLimitInvalid { .. } + | Self::InvalidOrderId { .. } + | Self::InvalidRequest { .. } + | Self::ListingDraft { .. } + | Self::ListingMutation { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], + } + } + + pub fn detail_json(&self) -> Value { + let detail = match self { + Self::Io { path, message } => { + json!({ "path": path.display().to_string(), "message": message }) + } + Self::ClockBeforeUnixEpoch => json!({}), + Self::TimestampOutOfRange { value } => json!({ "value": value }), + Self::UnauthorizedActor { operation, reason } => { + json!({ "operation": operation, "reason": reason }) + } + Self::SignerPubkeyMismatch { + operation, + expected_pubkey_prefix, + signer_pubkey_prefix, + } => json!({ + "operation": operation, + "expected_pubkey_prefix": expected_pubkey_prefix, + "signer_pubkey_prefix": signer_pubkey_prefix + }), + Self::EmptyTargetRelays { operation } => json!({ "operation": operation }), + Self::RelayTargetLimitExceeded { max, actual } => { + json!({ "max": max, "actual": actual }) + } + Self::InvalidRelayUrl { url, reason } => json!({ "url": url, "reason": reason }), + Self::IdempotencyConflict { + operation_kind, + expected_pubkey_prefix, + existing_digest_prefix, + new_digest_prefix, + } => json!({ + "operation_kind": operation_kind, + "expected_pubkey_prefix": expected_pubkey_prefix, + "existing_digest_prefix": existing_digest_prefix, + "new_digest_prefix": new_digest_prefix + }), + Self::OrderStatusLimitInvalid { limit, min, max } => { + json!({ "limit": limit, "min": min, "max": max }) + } + Self::InvalidOrderId { value, message } => { + json!({ "value": value, "message": message }) + } + Self::ProductSyncUnsupported { + operation, + required_feature, + } => json!({ "operation": operation, "required_feature": required_feature }), + Self::ProductSyncRelaySetupFailure { message } + | Self::Authority { message } + | Self::EventStore { message } + | Self::InvalidRequest { message } + | Self::ListingDraft { message } + | Self::ListingMutation { message } + | Self::Outbox { message } + | Self::RelayTransport { message } + | Self::Projection { message } => json!({ "message": message }), + Self::PartialLocalMutation(error) => json!(error), + }; + json!({ + "code": self.code(), + "class": self.class(), + "retryable": self.retryable(), + "message": self.to_string(), + "recovery_actions": self.recovery_actions(), + "detail": detail + }) + } + pub fn partial_local_mutation(error: RadrootsSdkPartialLocalMutationError) -> Self { Self::PartialLocalMutation(error) } @@ -412,13 +608,24 @@ fn redacted_prefix(value: &str) -> String { #[cfg(feature = "runtime")] fn redacted_relay_url(value: String) -> String { - let Some((scheme, rest)) = value.split_once("://") else { - return value; + let redacted = redact_query_or_fragment(value.as_str()); + let Some((scheme, rest)) = redacted.split_once("://") else { + return redacted; }; let authority = rest.split('/').next().unwrap_or(rest); let Some((_, after_userinfo)) = authority.rsplit_once('@') else { - return value; + return redacted; }; let path = rest.strip_prefix(authority).unwrap_or_default(); format!("{scheme}://<redacted>@{after_userinfo}{path}") } + +#[cfg(feature = "runtime")] +fn redact_query_or_fragment(value: &str) -> String { + let Some((index, marker)) = value.char_indices().find_map(|(index, character)| { + matches!(character, '?' | '#').then_some((index, character)) + }) else { + return value.to_owned(); + }; + format!("{}{}<redacted>", &value[..index], marker) +} diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs @@ -1,5 +1,6 @@ use crate::RadrootsSdkError; use core::fmt; +use serde::ser::SerializeStruct; use sha2::{Digest, Sha256}; pub const SDK_IDEMPOTENCY_KEY_MAX_LEN: usize = 256; @@ -9,10 +10,15 @@ pub struct SdkIdempotencyKey(String); impl SdkIdempotencyKey { pub fn new(value: impl AsRef<str>) -> Result<Self, RadrootsSdkError> { - let value = value.as_ref().trim(); + let value = value.as_ref(); if value.is_empty() { return Err(invalid_request("idempotency key must not be empty")); } + if value.trim() != value { + return Err(invalid_request( + "idempotency key must not include boundary whitespace", + )); + } if value.len() > SDK_IDEMPOTENCY_KEY_MAX_LEN { return Err(invalid_request(format!( "idempotency key must be at most {SDK_IDEMPOTENCY_KEY_MAX_LEN} bytes" @@ -64,6 +70,18 @@ impl fmt::Debug for SdkIdempotencyKey { } } +impl serde::Serialize for SdkIdempotencyKey { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("SdkIdempotencyKey", 2)?; + state.serialize_field("value", "<redacted>")?; + state.serialize_field("len", &self.0.len())?; + state.end() + } +} + #[derive(serde::Serialize)] struct SdkIdempotencyDerivationInput<'a> { operation_kind: &'static str, diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -39,8 +39,8 @@ mod sync_runtime; #[cfg(feature = "runtime")] pub use crate::error::{ - RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure, - RadrootsSdkRecoveryAction, + RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkPartialLocalMutationError, + RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] pub use crate::idempotency::{SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey}; @@ -63,8 +63,10 @@ pub use crate::relay_targets::{ }; #[cfg(feature = "runtime")] pub use crate::runtime::{ - RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, - RadrootsSdkStoragePaths, RadrootsSdkTimestamp, + BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk, + RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, + RadrootsSdkTimestamp, SdkBackupState, SdkStorageKind, StorageStatusReceipt, + StorageStatusRequest, }; #[cfg(feature = "runtime")] pub use crate::sync_runtime::{ diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -4,12 +4,15 @@ use crate::{ SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, runtime::sdk_now_ms, }; #[cfg(feature = "runtime")] -use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft}; +use radroots_authority::{ + RadrootsActorContext, RadrootsActorSource, RadrootsEventSigner, sign_authorized_draft, +}; #[cfg(feature = "runtime")] use radroots_event_store::RadrootsEventIngest; #[cfg(feature = "runtime")] use radroots_events::{ RadrootsNostrEvent, + contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, ids::{RadrootsEventId, RadrootsListingAddress}, listing::RadrootsListing, @@ -22,6 +25,8 @@ use radroots_trade::listing::{ build_listing_mutation_draft, canonicalize_listing_draft, }; #[cfg(feature = "runtime")] +use serde::ser::SerializeStruct; +#[cfg(feature = "runtime")] use sha2::{Digest, Sha256}; #[cfg(feature = "runtime")] @@ -36,6 +41,20 @@ pub struct ListingPreparePublishRequest { } #[cfg(feature = "runtime")] +impl serde::Serialize for ListingPreparePublishRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("ListingPreparePublishRequest", 3)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("document", &self.document)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] impl ListingPreparePublishRequest { pub fn new(actor: RadrootsActorContext, listing: RadrootsListing) -> Self { Self { @@ -73,6 +92,22 @@ pub struct ListingEnqueuePublishRequest { } #[cfg(feature = "runtime")] +impl serde::Serialize for ListingEnqueuePublishRequest { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("ListingEnqueuePublishRequest", 5)?; + state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; + state.serialize_field("document", &self.document)?; + state.serialize_field("target_relays", &self.target_relays)?; + state.serialize_field("idempotency_key", &self.idempotency_key)?; + state.serialize_field("created_at", &self.created_at)?; + state.end() + } +} + +#[cfg(feature = "runtime")] impl ListingEnqueuePublishRequest { pub fn new( actor: RadrootsActorContext, @@ -133,7 +168,7 @@ impl ListingEnqueuePublishRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingPublishPlan { pub public_listing_addr: RadrootsListingAddress, pub draft_listing_addr: RadrootsListingAddress, @@ -143,7 +178,9 @@ pub struct ListingPublishPlan { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SdkMutationState { StoredAndQueued, AlreadyQueued, @@ -160,7 +197,7 @@ impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingEnqueueReceipt { pub public_listing_addr: RadrootsListingAddress, pub draft_listing_addr: RadrootsListingAddress, @@ -227,7 +264,7 @@ impl<'sdk> ListingsClient<'sdk> { LISTING_PUBLISH_OPERATION_KIND, plan.frozen_draft.expected_event_id.as_str(), plan.frozen_draft.expected_pubkey.as_str(), - target_relays.relays(), + target_relays.canonical_relays(), )?, }; let observed_at_ms = sdk_now_ms(self.sdk)?; @@ -236,9 +273,10 @@ impl<'sdk> ListingsClient<'sdk> { let ingest = RadrootsEventIngest::new(event, observed_at_ms) .with_raw_json(signed_event.raw_json.clone()); let ingest_receipt = self.sdk._event_store.ingest_event(ingest).await?; + let canonical_target_relays = target_relays.canonical_relays().to_vec(); let target_relay_values = target_relays.into_vec(); let partial_failure_digest_prefix = - outbox_idempotency_digest_prefix(&plan, target_relay_values.as_slice())?; + outbox_idempotency_digest_prefix(&plan, canonical_target_relays.as_slice())?; let outbox_input = signed_outbox_input( &plan, signed_event.clone(), @@ -411,3 +449,54 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv sig: signed_event.sig.clone(), } } + +#[cfg(feature = "runtime")] +struct SdkActorContextJson<'a>(&'a RadrootsActorContext); + +#[cfg(feature = "runtime")] +impl serde::Serialize for SdkActorContextJson<'_> { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let roles = self + .0 + .roles() + .iter() + .map(actor_role_code) + .collect::<Vec<_>>(); + let account_id = self.0.account_id().map(|account_id| account_id.as_str()); + let mut state = serializer.serialize_struct("SdkActorContext", 4)?; + state.serialize_field("pubkey", self.0.pubkey().as_str())?; + state.serialize_field("roles", &roles)?; + state.serialize_field("account_id", &account_id)?; + state.serialize_field("source", actor_source_code(self.0.source()))?; + state.end() + } +} + +#[cfg(feature = "runtime")] +fn actor_role_code(role: &RadrootsActorRole) -> &'static str { + match role { + RadrootsActorRole::Any => "any", + RadrootsActorRole::Application => "application", + RadrootsActorRole::Buyer => "buyer", + RadrootsActorRole::Farmer => "farmer", + RadrootsActorRole::Member => "member", + RadrootsActorRole::Moderator => "moderator", + RadrootsActorRole::Relay => "relay", + RadrootsActorRole::Seller => "seller", + RadrootsActorRole::Service => "service", + } +} + +#[cfg(feature = "runtime")] +fn actor_source_code(source: RadrootsActorSource) -> &'static str { + match source { + RadrootsActorSource::LocalAccount => "local_account", + RadrootsActorSource::ExplicitPubkey => "explicit_pubkey", + RadrootsActorSource::RemoteSigner => "remote_signer", + RadrootsActorSource::Service => "service", + RadrootsActorSource::Test => "test", + } +} diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -11,6 +11,8 @@ use radroots_trade::order::{ RadrootsOrderSettlementState, RadrootsOrderStatus, RadrootsOrderStoreQueryError, order_projection_query_for_order_id, }; +#[cfg(feature = "runtime")] +use serde::ser::SerializeStruct; #[cfg(feature = "runtime")] pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500; @@ -18,7 +20,7 @@ pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500; pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000; #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct OrderStatusRequest { pub order_id: RadrootsOrderId, pub limit: u32, @@ -57,7 +59,7 @@ impl OrderStatusRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct OrderStatusReceipt { pub order_id: RadrootsOrderId, pub source: SdkOrderStatusSource, @@ -80,13 +82,17 @@ pub struct OrderStatusReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SdkOrderStatusSource { LocalEventStore, } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum OrderStatusKind { Missing, Requested, @@ -99,7 +105,9 @@ pub enum OrderStatusKind { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum OrderFulfillmentStatusKind { AcceptedNotFulfilled, Preparing, @@ -110,7 +118,9 @@ pub enum OrderFulfillmentStatusKind { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum OrderPaymentStateKind { NotRecorded, Recorded, @@ -120,7 +130,9 @@ pub enum OrderPaymentStateKind { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum OrderSettlementStateKind { NotRequired, Pending, @@ -145,10 +157,30 @@ impl SdkOrderStatusIssue { fn single(kind: SdkOrderStatusIssueKind, event_id: RadrootsEventId) -> Self { Self::new(kind, vec![event_id]) } + + pub fn code(&self) -> String { + self.kind.code() + } +} + +#[cfg(feature = "runtime")] +impl serde::Serialize for SdkOrderStatusIssue { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("SdkOrderStatusIssue", 3)?; + state.serialize_field("code", &self.code())?; + state.serialize_field("kind", &self.kind)?; + state.serialize_field("event_ids", &self.event_ids)?; + state.end() + } } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SdkOrderStatusIssueKind { MissingRequest, MultipleRequests, @@ -274,6 +306,13 @@ pub enum SdkOrderStatusIssueKind { } #[cfg(feature = "runtime")] +impl SdkOrderStatusIssueKind { + pub fn code(self) -> String { + camel_to_snake(format!("{self:?}").as_str()) + } +} + +#[cfg(feature = "runtime")] impl<'sdk> OrdersClient<'sdk> { pub async fn status( &self, @@ -827,3 +866,19 @@ fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError { message: message.to_owned(), } } + +#[cfg(feature = "runtime")] +fn camel_to_snake(value: &str) -> String { + let mut output = String::new(); + for (index, character) in value.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + output.push('_'); + } + output.push(character.to_ascii_lowercase()); + } else { + output.push(character); + } + } + output +} diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs @@ -1,10 +1,13 @@ use crate::RadrootsSdkError; use radroots_relay_transport::{RadrootsRelayUrl, RadrootsRelayUrlPolicy}; +use serde::ser::SerializeStruct; use std::collections::BTreeSet; pub const SDK_RELAY_TARGET_MAX_COUNT: usize = 20; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SdkRelayUrlPolicy { Public, Localhost, @@ -20,6 +23,7 @@ impl SdkRelayUrlPolicy { } #[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum SdkRelayTargetPolicy { Explicit(SdkRelayTargetSet), UseConfiguredRelays, @@ -42,9 +46,32 @@ impl SdkRelayTargetPolicy { } } +impl serde::Serialize for SdkRelayTargetPolicy { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + match self { + Self::Explicit(targets) => { + let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 3)?; + state.serialize_field("kind", "explicit")?; + state.serialize_field("relays", targets.relays())?; + state.serialize_field("canonical_relays", targets.canonical_relays())?; + state.end() + } + Self::UseConfiguredRelays => { + let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 1)?; + state.serialize_field("kind", "use_configured_relays")?; + state.end() + } + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SdkRelayTargetSet { relays: Vec<String>, + canonical_relays: Vec<String>, } impl SdkRelayTargetSet { @@ -53,17 +80,25 @@ impl SdkRelayTargetSet { I: IntoIterator<Item = S>, S: AsRef<str>, { - let mut normalized = BTreeSet::new(); + let mut ordered_relays = Vec::new(); + let mut seen = BTreeSet::new(); for relay in relays { - normalized.insert(normalized_relay_url(relay.as_ref(), policy)?); + let normalized = normalized_relay_url(relay.as_ref(), policy)?; + if seen.insert(normalized.clone()) { + ordered_relays.push(normalized); + } } - Self::from_normalized_set(normalized) + Self::from_normalized_ordered(ordered_relays) } pub fn relays(&self) -> &[String] { self.relays.as_slice() } + pub fn canonical_relays(&self) -> &[String] { + self.canonical_relays.as_slice() + } + pub fn into_vec(self) -> Vec<String> { self.relays } @@ -92,28 +127,48 @@ impl SdkRelayTargetSet { } pub(crate) fn from_normalized_relays(relays: Vec<String>) -> Result<Self, RadrootsSdkError> { - let normalized = relays.into_iter().collect::<BTreeSet<_>>(); - Self::from_normalized_set(normalized) + let mut ordered = Vec::new(); + let mut seen = BTreeSet::new(); + for relay in relays { + if seen.insert(relay.clone()) { + ordered.push(relay); + } + } + Self::from_normalized_ordered(ordered) } - fn from_normalized_set(normalized: BTreeSet<String>) -> Result<Self, RadrootsSdkError> { - if normalized.is_empty() { + fn from_normalized_ordered(relays: Vec<String>) -> Result<Self, RadrootsSdkError> { + if relays.is_empty() { return Err(RadrootsSdkError::empty_target_relays( "sdk relay target set", )); } - if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT { + if relays.len() > SDK_RELAY_TARGET_MAX_COUNT { return Err(RadrootsSdkError::relay_target_limit_exceeded( SDK_RELAY_TARGET_MAX_COUNT, - normalized.len(), + relays.len(), )); } + let canonical_relays = relays.iter().cloned().collect::<BTreeSet<_>>(); Ok(Self { - relays: normalized.into_iter().collect(), + relays, + canonical_relays: canonical_relays.into_iter().collect(), }) } } +impl serde::Serialize for SdkRelayTargetSet { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let mut state = serializer.serialize_struct("SdkRelayTargetSet", 2)?; + state.serialize_field("relays", self.relays())?; + state.serialize_field("canonical_relays", self.canonical_relays())?; + state.end() + } +} + fn normalized_relay_url( value: &str, policy: SdkRelayUrlPolicy, diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -15,7 +15,8 @@ use std::{ }; #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] pub enum RadrootsSdkStorageConfig { Memory, Directory(PathBuf), @@ -29,7 +30,7 @@ impl Default for RadrootsSdkStorageConfig { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] pub struct RadrootsSdkTimestamp(u64); #[cfg(feature = "runtime")] @@ -48,7 +49,9 @@ impl RadrootsSdkTimestamp { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum RadrootsSdkClock { System, Fixed(RadrootsSdkTimestamp), @@ -77,13 +80,70 @@ impl RadrootsSdkClock { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct RadrootsSdkStoragePaths { pub event_store_path: PathBuf, pub outbox_path: PathBuf, } #[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct StorageStatusRequest {} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct StorageStatusReceipt { + pub storage: SdkStorageKind, + pub paths: Option<RadrootsSdkStoragePaths>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SdkStorageKind { + Memory, + Directory, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct BackupRequest { + pub destination: PathBuf, + pub overwrite: bool, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct BackupReceipt { + pub destination: PathBuf, + pub state: SdkBackupState, + pub event_store_path: Option<PathBuf>, + pub outbox_path: Option<PathBuf>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum SdkBackupState { + Planned, + Completed, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct IntegrityRequest {} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct IntegrityReceipt { + pub checked_paths: Vec<PathBuf>, + pub event_store_ok: bool, + pub outbox_ok: bool, +} + +#[cfg(feature = "runtime")] #[derive(Clone, Debug)] pub struct RadrootsSdkBuilder { storage: RadrootsSdkStorageConfig, diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -28,7 +28,7 @@ const CLAIM_TTL_MS: i64 = 30_000; const NEXT_ATTEMPT_DELAY_MS: i64 = 60_000; #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct PushOutboxRequest { pub limit: usize, pub republish_accepted_relays: bool, @@ -71,7 +71,7 @@ impl PushOutboxRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct PushOutboxReceipt { pub attempted_events: usize, pub published_events: usize, @@ -95,7 +95,7 @@ impl PushOutboxReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct PushOutboxEventReceipt { pub event_id: RadrootsEventId, pub outbox_event_id: i64, @@ -110,7 +110,7 @@ pub struct PushOutboxEventReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct PushOutboxRelayReceipt { pub relay_url: String, pub outcome_kind: PushOutboxRelayOutcomeKind, @@ -119,7 +119,9 @@ pub struct PushOutboxRelayReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum PushOutboxEventState { DraftQueued, Signing, @@ -150,7 +152,9 @@ impl From<RadrootsOutboxEventState> for PushOutboxEventState { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum PushOutboxRelayOutcomeKind { Accepted, DuplicateAccepted, diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -392,6 +392,66 @@ fn mutation_state_debug_uses_product_state_names() { } #[tokio::test] +async fn listing_runtime_dtos_serialize_deterministically() { + let (_tempdir, sdk) = directory_sdk().await; + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); + let prepare_request = + ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Coffee")) + .with_created_at(created_at); + let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + + assert_eq!(prepare_json["actor"]["pubkey"], SELLER); + assert_eq!( + prepare_json["actor"]["roles"], + serde_json::json!(["seller"]) + ); + assert_eq!(prepare_json["actor"]["source"], "test"); + assert_eq!(prepare_json["created_at"], 1_700_000_123); + assert_eq!( + prepare_json["document"]["listing"]["product"]["title"], + "Serialized Coffee" + ); + + let enqueue_request = ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_B_D_TAG, "Queued Coffee"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("relay targets") + .try_with_idempotency_key("serialized-idempotency") + .expect("idempotency") + .with_created_at(created_at); + let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + + assert_eq!(enqueue_json["target_relays"]["kind"], "explicit"); + assert_eq!( + enqueue_json["target_relays"]["relays"], + serde_json::json!([RELAY, RELAY_B]) + ); + assert_eq!( + enqueue_json["target_relays"]["canonical_relays"], + serde_json::json!([RELAY_B, RELAY]) + ); + assert_eq!( + enqueue_json["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 22 }) + ); + assert!(!enqueue_json.to_string().contains("serialized-idempotency")); + + let receipt = sdk + .listings() + .enqueue_publish(enqueue_request, &FixtureSigner::new(SELLER)) + .await + .expect("enqueue"); + let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); + + assert_eq!(receipt_json["state"], "stored_and_queued"); + assert_eq!(receipt_json["local_event_seq"], 1); + assert!(receipt_json["idempotency_digest_prefix"].is_string()); +} + +#[tokio::test] async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { let (_prepared_tempdir, prepared_sdk) = directory_sdk().await; let prepared_actor = actor(); @@ -638,4 +698,17 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() { second_receipt.idempotency_digest_prefix ); assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); + + let paths = sdk.storage_paths().expect("paths"); + let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + .await + .expect("outbox"); + let relay_urls = outbox + .relay_statuses(first_receipt.outbox_event_id) + .await + .expect("relay statuses") + .into_iter() + .map(|status| status.relay_url) + .collect::<Vec<_>>(); + assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]); } diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -24,7 +24,7 @@ use radroots_sdk::protocol::wire::WireEventParts; use radroots_sdk::{ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, RadrootsSdk, RadrootsSdkError, - RadrootsSdkTimestamp, SdkOrderStatusIssueKind, SdkOrderStatusSource, + RadrootsSdkTimestamp, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; const BUYER_SECRET_KEY_HEX: &str = @@ -255,6 +255,43 @@ fn order_status_parse_rejects_invalid_order_ids() { } #[tokio::test] +async fn order_status_contract_dtos_serialize_deterministically() { + let (_tempdir, sdk, _store) = directory_sdk_and_store().await; + let request = status_request("order-1").with_limit(25); + let request_json = serde_json::to_value(&request).expect("request json"); + + assert_eq!( + request_json, + serde_json::json!({ + "order_id": "order-1", + "limit": 25 + }) + ); + + let receipt = sdk.orders().status(request).await.expect("status"); + let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); + + assert_eq!(receipt_json["source"], "local_event_store"); + assert_eq!(receipt_json["status"], "missing"); + assert_eq!(receipt_json["payment_state"], "not_recorded"); + assert_eq!(receipt_json["settlement_state"], "not_required"); + + let issue = SdkOrderStatusIssue { + kind: SdkOrderStatusIssueKind::DecisionPayloadInvalid, + event_ids: vec![deterministic_event_id("issue-event")], + }; + assert_eq!(issue.code(), "decision_payload_invalid"); + assert_eq!( + serde_json::to_value(issue).expect("issue json"), + serde_json::json!({ + "code": "decision_payload_invalid", + "kind": "decision_payload_invalid", + "event_ids": [deterministic_event_id("issue-event")] + }) + ); +} + +#[tokio::test] async fn order_status_projects_local_request_and_decision_events() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; let request_event = signed_order_request_event("order-1", 20); diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -1,10 +1,13 @@ #![cfg(feature = "runtime")] use radroots_sdk::{ - RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkStorageConfig, - RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, - SdkIdempotencyKey, SdkRelayTargetSet, SdkRelayUrlPolicy, + BackupRequest, IntegrityRequest, RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, + RadrootsSdkErrorClass, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig, + RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkBackupState, + SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkStorageKind, + StorageStatusReceipt, StorageStatusRequest, }; +use std::path::PathBuf; #[tokio::test] async fn sdk_builder_defaults_to_memory_storage_and_no_relays() { @@ -30,8 +33,8 @@ async fn sdk_builder_validates_configured_relay_targets() { assert_eq!( sdk.relay_urls(), &[ - "wss://relay-a.example.com".to_owned(), - "wss://relay-b.example.com".to_owned() + "wss://relay-b.example.com".to_owned(), + "wss://relay-a.example.com".to_owned() ] ); } @@ -52,15 +55,32 @@ async fn sdk_builder_rejects_ws_relay_without_localhost_policy() { #[test] fn invalid_relay_url_errors_redact_userinfo() { let error = SdkRelayTargetSet::new( - ["wss://user:password@relay.example.com"], + ["wss://user:password@relay.example.com/path?token=secret#frag"], SdkRelayUrlPolicy::Public, ) .expect_err("invalid relay"); let message = error.to_string(); + let detail = error.detail_json(); assert!(matches!(error, RadrootsSdkError::InvalidRelayUrl { .. })); - assert!(message.contains("<redacted>@relay.example.com")); + assert_eq!(error.code(), "invalid_relay_url"); + assert_eq!(error.class(), RadrootsSdkErrorClass::Configuration); + assert!(!error.retryable()); + assert_eq!( + error.recovery_actions(), + vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets] + ); + assert!(message.contains("<redacted>@relay.example.com/path?<redacted>")); assert!(!message.contains("password")); + assert!(!message.contains("token=secret")); + assert!(!message.contains("frag")); + assert_eq!(detail["code"], "invalid_relay_url"); + assert_eq!(detail["class"], "configuration"); + assert_eq!(detail["retryable"], false); + assert_eq!(detail["recovery_actions"][0], "configure_relay_targets"); + assert!(!detail.to_string().contains("password")); + assert!(!detail.to_string().contains("token=secret")); + assert!(!detail.to_string().contains("frag")); } #[tokio::test] @@ -170,7 +190,232 @@ fn sdk_partial_local_mutation_error_is_sanitized() { } #[test] -fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() { +fn sdk_error_contract_methods_cover_all_variants() { + let cases = vec![ + ( + RadrootsSdkError::Io { + path: PathBuf::from("store.sqlite"), + message: "permission denied".to_owned(), + }, + "io", + RadrootsSdkErrorClass::Storage, + true, + vec![RadrootsSdkRecoveryAction::InspectLocalStores], + ), + ( + RadrootsSdkError::ClockBeforeUnixEpoch, + "clock_before_unix_epoch", + RadrootsSdkErrorClass::Clock, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::TimestampOutOfRange { value: u64::MAX }, + "timestamp_out_of_range", + RadrootsSdkErrorClass::Clock, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::UnauthorizedActor { + operation: "listing.prepare_publish".to_owned(), + reason: "missing role".to_owned(), + }, + "unauthorized_actor", + RadrootsSdkErrorClass::Authorization, + false, + vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], + ), + ( + RadrootsSdkError::SignerPubkeyMismatch { + operation: "event signing".to_owned(), + expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(), + signer_pubkey_prefix: "bbbbbbbbbbbb".to_owned(), + }, + "signer_pubkey_mismatch", + RadrootsSdkErrorClass::Authorization, + false, + vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], + ), + ( + RadrootsSdkError::EmptyTargetRelays { + operation: "listing.publish".to_owned(), + }, + "empty_target_relays", + RadrootsSdkErrorClass::Configuration, + false, + vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets], + ), + ( + RadrootsSdkError::RelayTargetLimitExceeded { + max: 20, + actual: 21, + }, + "relay_target_limit_exceeded", + RadrootsSdkErrorClass::Configuration, + false, + vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets], + ), + ( + SdkRelayTargetSet::new(["wss://u:p@relay.example.com"], SdkRelayUrlPolicy::Public) + .expect_err("invalid relay"), + "invalid_relay_url", + RadrootsSdkErrorClass::Configuration, + false, + vec![RadrootsSdkRecoveryAction::ConfigureRelayTargets], + ), + ( + RadrootsSdkError::IdempotencyConflict { + operation_kind: "listing.publish.v1".to_owned(), + expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(), + existing_digest_prefix: "bbbbbbbbbbbb".to_owned(), + new_digest_prefix: "cccccccccccc".to_owned(), + }, + "idempotency_conflict", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey], + ), + ( + RadrootsSdkError::OrderStatusLimitInvalid { + limit: 0, + min: 1, + max: 1000, + }, + "order_status_limit_invalid", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::InvalidOrderId { + value: "bad".to_owned(), + message: "invalid".to_owned(), + }, + "invalid_order_id", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::ProductSyncUnsupported { + operation: "sync.push_outbox", + required_feature: "relay-runtime", + }, + "product_sync_unsupported", + RadrootsSdkErrorClass::Unsupported, + false, + vec![RadrootsSdkRecoveryAction::EnableRequiredFeature], + ), + ( + RadrootsSdkError::ProductSyncRelaySetupFailure { + message: "relay setup".to_owned(), + }, + "product_sync_relay_setup_failure", + RadrootsSdkErrorClass::Transport, + true, + vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure], + ), + ( + RadrootsSdkError::Authority { + message: "authority".to_owned(), + }, + "authority", + RadrootsSdkErrorClass::Authorization, + false, + vec![RadrootsSdkRecoveryAction::SelectAuthorizedActor], + ), + ( + RadrootsSdkError::EventStore { + message: "store".to_owned(), + }, + "event_store", + RadrootsSdkErrorClass::Storage, + true, + vec![RadrootsSdkRecoveryAction::InspectLocalStores], + ), + ( + RadrootsSdkError::InvalidRequest { + message: "bad input".to_owned(), + }, + "invalid_request", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::ListingDraft { + message: "draft".to_owned(), + }, + "listing_draft", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::ListingMutation { + message: "mutation".to_owned(), + }, + "listing_mutation", + RadrootsSdkErrorClass::Request, + false, + vec![RadrootsSdkRecoveryAction::FixRequest], + ), + ( + RadrootsSdkError::Outbox { + message: "outbox".to_owned(), + }, + "outbox", + RadrootsSdkErrorClass::Storage, + true, + vec![RadrootsSdkRecoveryAction::InspectLocalStores], + ), + ( + RadrootsSdkError::RelayTransport { + message: "relay".to_owned(), + }, + "relay_transport", + RadrootsSdkErrorClass::Transport, + true, + vec![RadrootsSdkRecoveryAction::RetryAfterTransportFailure], + ), + ( + RadrootsSdkError::Projection { + message: "projection".to_owned(), + }, + "projection", + RadrootsSdkErrorClass::Storage, + true, + vec![RadrootsSdkRecoveryAction::InspectLocalStores], + ), + ( + RadrootsSdkError::partial_outbox_enqueue_mutation( + "a".repeat(64), + "listing.publish.v1", + "abcdef123456", + ), + "partial_local_mutation", + RadrootsSdkErrorClass::LocalMutation, + true, + vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey], + ), + ]; + + for (error, code, class, retryable, recovery_actions) in cases { + assert_eq!(error.code(), code); + assert_eq!(error.class(), class); + assert_eq!(error.retryable(), retryable); + assert_eq!(error.recovery_actions(), recovery_actions); + let detail = error.detail_json(); + assert_eq!(detail["code"], code); + assert_eq!(detail["retryable"], retryable); + assert!(detail["message"].is_string()); + assert!(detail["detail"].is_object()); + } +} + +#[test] +fn relay_target_set_validates_normalizes_dedupes_preserves_order_and_caps() { let targets = SdkRelayTargetSet::new( [ " wss://relay-b.example.com/ ", @@ -184,10 +429,26 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() { assert_eq!( targets.relays(), &[ + "wss://relay-b.example.com".to_owned(), + "wss://relay-a.example.com".to_owned() + ] + ); + assert_eq!( + targets.canonical_relays(), + &[ "wss://relay-a.example.com".to_owned(), "wss://relay-b.example.com".to_owned() ] ); + assert_eq!( + serde_json::to_value(SdkRelayTargetPolicy::explicit(targets.clone())) + .expect("relay target policy json"), + serde_json::json!({ + "kind": "explicit", + "relays": ["wss://relay-b.example.com", "wss://relay-a.example.com"], + "canonical_relays": ["wss://relay-a.example.com", "wss://relay-b.example.com"] + }) + ); assert!(matches!( SdkRelayTargetSet::new(Vec::<String>::new(), SdkRelayUrlPolicy::Public), @@ -208,16 +469,27 @@ fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() { #[test] fn idempotency_key_validation_is_bounded_and_debug_redacted() { - let key = SdkIdempotencyKey::new(" idem-a ").expect("key"); + let key = SdkIdempotencyKey::new("idem-a").expect("key"); assert_eq!(key.as_str(), "idem-a"); let debug = format!("{key:?}"); assert!(debug.contains("<redacted>")); assert!(!debug.contains("idem-a")); + assert_eq!( + serde_json::to_value(&key).expect("key json"), + serde_json::json!({ "value": "<redacted>", "len": 6 }) + ); assert!(matches!( SdkIdempotencyKey::new(" "), Err(RadrootsSdkError::InvalidRequest { .. }) )); + let untrimmed = SdkIdempotencyKey::new(" idem-a ").expect_err("untrimmed"); + assert!(matches!( + untrimmed, + RadrootsSdkError::InvalidRequest { ref message } + if message == "idempotency key must not include boundary whitespace" + )); + assert!(!untrimmed.to_string().contains("idem-a")); assert!(matches!( SdkIdempotencyKey::new("idem\nbad"), Err(RadrootsSdkError::InvalidRequest { .. }) @@ -229,6 +501,44 @@ fn idempotency_key_validation_is_bounded_and_debug_redacted() { } #[test] +fn storage_backup_and_integrity_contract_dtos_serialize() { + assert_eq!( + serde_json::to_value(StorageStatusRequest::default()).expect("status request"), + serde_json::json!({}) + ); + assert_eq!( + serde_json::to_value(StorageStatusReceipt { + storage: SdkStorageKind::Directory, + paths: None, + }) + .expect("status receipt"), + serde_json::json!({ + "storage": "directory", + "paths": null + }) + ); + assert_eq!( + serde_json::to_value(BackupRequest { + destination: PathBuf::from("backup"), + overwrite: false, + }) + .expect("backup request"), + serde_json::json!({ + "destination": "backup", + "overwrite": false + }) + ); + assert_eq!( + serde_json::to_value(SdkBackupState::Completed).expect("backup state"), + serde_json::json!("completed") + ); + assert_eq!( + serde_json::to_value(IntegrityRequest::default()).expect("integrity request"), + serde_json::json!({}) + ); +} + +#[test] fn outbox_idempotency_conflict_maps_to_structured_sdk_error() { let error = RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::IdempotencyConflict { operation_kind: "listing.publish.v1".to_owned(), diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -12,7 +12,7 @@ use radroots_events::{ contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts}, farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, + ids::{RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId}, listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, }; use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState, RadrootsOutboxOperationInput}; @@ -22,8 +22,9 @@ use radroots_relay_transport::{ }; use radroots_sdk::{ ListingEnqueuePublishRequest, ListingPreparePublishRequest, PUSH_OUTBOX_DEFAULT_LIMIT, - PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, - RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayUrlPolicy, + PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, + PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, PushOutboxRequest, RadrootsSdk, + RadrootsSdkError, RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayUrlPolicy, }; use std::collections::BTreeSet; @@ -226,6 +227,70 @@ async fn push_outbox_empty_queue_returns_zero_counts() { assert!(adapter.captured_raw_events().is_empty()); } +#[test] +fn push_outbox_contract_dtos_serialize_deterministically() { + let request = PushOutboxRequest::new() + .with_limit(2) + .republish_accepted_relays(true); + assert_eq!( + serde_json::to_value(&request).expect("request json"), + serde_json::json!({ + "limit": 2, + "republish_accepted_relays": true + }) + ); + + let receipt = PushOutboxReceipt { + attempted_events: 1, + published_events: 1, + retryable_events: 0, + terminal_events: 0, + events: vec![PushOutboxEventReceipt { + event_id: RadrootsEventId::parse(&"a".repeat(64)).expect("event id"), + outbox_event_id: 7, + final_state: PushOutboxEventState::Published, + attempted_count: 2, + accepted_count: 1, + retryable_count: 1, + terminal_count: 0, + quorum: 1, + quorum_met: true, + relays: vec![PushOutboxRelayReceipt { + relay_url: RELAY_A.to_owned(), + outcome_kind: PushOutboxRelayOutcomeKind::DuplicateAccepted, + attempted: true, + message: Some("duplicate".to_owned()), + }], + }], + }; + assert_eq!( + serde_json::to_value(receipt).expect("receipt json"), + serde_json::json!({ + "attempted_events": 1, + "published_events": 1, + "retryable_events": 0, + "terminal_events": 0, + "events": [{ + "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "outbox_event_id": 7, + "final_state": "published", + "attempted_count": 2, + "accepted_count": 1, + "retryable_count": 1, + "terminal_count": 0, + "quorum": 1, + "quorum_met": true, + "relays": [{ + "relay_url": RELAY_A, + "outcome_kind": "duplicate_accepted", + "attempted": true, + "message": "duplicate" + }] + }] + }) + ); +} + #[cfg(not(feature = "relay-runtime"))] #[tokio::test] async fn product_push_outbox_without_relay_runtime_returns_structured_error() {