lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit a1fd7c79f51357c783f3af5dbb78aa73ebdbc641
parent 2e0ee90383bc42fc5d7b17e0c427b77db455f18d
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 13:55:33 -0700

sdk: add runtime foundation

- add the gated RadrootsSdk builder, storage, clock, error, and receipt primitives
- wire runtime, local-signer, and relay-runtime feature dependencies
- cover memory defaults, directory SQLite paths, fixed clock, timestamp bounds, and sanitized partial errors
- validation: cargo check -p radroots_sdk --features runtime; cargo test -p radroots_sdk --features runtime

Diffstat:
Mcrates/sdk/Cargo.toml | 22++++++++++++++++++++++
Acrates/sdk/src/error.rs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 21+++++++++++++++++++++
Acrates/sdk/src/product_clients.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/receipt.rs | 18++++++++++++++++++
Acrates/sdk/src/runtime.rs | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/runtime_foundation.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 535 insertions(+), 0 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -39,10 +39,32 @@ signer-adapters = [ "dep:radroots_nostr_connect", "dep:radroots_nostr_signer", ] +runtime = [ + "std", + "serde_json", + "dep:radroots_authority", + "dep:radroots_event_store", + "dep:radroots_outbox", + "dep:radroots_relay_transport", + "radroots_authority/std", + "radroots_event_store/sqlite", + "radroots_event_store/runtime-tokio", + "radroots_outbox/sqlite", + "radroots_outbox/runtime-tokio", + "radroots_relay_transport/std", + "radroots_relay_transport/storage", + "radroots_relay_transport/runtime-tokio", +] +local-signer = ["runtime", "radroots_authority/local_signer"] +relay-runtime = ["runtime", "radroots_relay_transport/client"] [dependencies] +radroots_authority = { workspace = true, optional = true, default-features = false } +radroots_event_store = { workspace = true, optional = true, default-features = false } radroots_events = { workspace = true, default-features = false } radroots_events_codec = { workspace = true, default-features = false } +radroots_outbox = { workspace = true, optional = true, default-features = false } +radroots_relay_transport = { workspace = true, optional = true, default-features = false } radroots_trade = { workspace = true, default-features = false } radroots_identity = { workspace = true, optional = true, default-features = false } radroots_nostr = { workspace = true, optional = true, default-features = false } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -0,0 +1,116 @@ +#[cfg(feature = "runtime")] +use std::{fmt, path::PathBuf}; + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsSdkRecoveryAction { + RetryOutboxEnqueue, + InspectLocalStores, + RetryOperationWithSameIdempotencyKey, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsSdkPartialLocalMutationError { + pub stored: bool, + pub queued: bool, + pub recovery: RadrootsSdkRecoveryAction, +} + +#[cfg(feature = "runtime")] +#[derive(Debug)] +pub enum RadrootsSdkError { + Io { path: PathBuf, message: String }, + ClockBeforeUnixEpoch, + TimestampOutOfRange { value: u64 }, + Authority { message: String }, + EventStore { message: String }, + Outbox { message: String }, + RelayTransport { message: String }, + Projection { message: String }, + PartialLocalMutation(RadrootsSdkPartialLocalMutationError), +} + +#[cfg(feature = "runtime")] +impl RadrootsSdkError { + pub fn partial_local_mutation( + stored: bool, + queued: bool, + recovery: RadrootsSdkRecoveryAction, + ) -> Self { + Self::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { + stored, + queued, + recovery, + }) + } +} + +#[cfg(feature = "runtime")] +impl fmt::Display for RadrootsSdkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, message } => { + write!(f, "sdk storage path `{}` failed: {message}", path.display()) + } + Self::ClockBeforeUnixEpoch => f.write_str("sdk clock is before the Unix epoch"), + Self::TimestampOutOfRange { value } => { + write!( + f, + "sdk timestamp {value} exceeds Nostr u32 created_at range" + ) + } + Self::Authority { message } => write!(f, "sdk authority error: {message}"), + Self::EventStore { message } => write!(f, "sdk event store error: {message}"), + Self::Outbox { message } => write!(f, "sdk outbox error: {message}"), + Self::RelayTransport { message } => { + write!(f, "sdk relay transport error: {message}") + } + Self::Projection { message } => write!(f, "sdk projection error: {message}"), + Self::PartialLocalMutation(error) => write!( + f, + "sdk local mutation partially completed: stored={}, queued={}, recovery={:?}", + error.stored, error.queued, error.recovery + ), + } + } +} + +#[cfg(feature = "runtime")] +impl std::error::Error for RadrootsSdkError {} + +#[cfg(feature = "runtime")] +impl From<radroots_authority::RadrootsAuthorityError> for RadrootsSdkError { + fn from(error: radroots_authority::RadrootsAuthorityError) -> Self { + Self::Authority { + message: error.to_string(), + } + } +} + +#[cfg(feature = "runtime")] +impl From<radroots_event_store::RadrootsEventStoreError> for RadrootsSdkError { + fn from(error: radroots_event_store::RadrootsEventStoreError) -> Self { + Self::EventStore { + message: error.to_string(), + } + } +} + +#[cfg(feature = "runtime")] +impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError { + fn from(error: radroots_outbox::RadrootsOutboxError) -> Self { + Self::Outbox { + message: error.to_string(), + } + } +} + +#[cfg(feature = "runtime")] +impl From<radroots_relay_transport::RadrootsRelayTransportError> for RadrootsSdkError { + fn from(error: radroots_relay_transport::RadrootsRelayTransportError) -> Self { + Self::RelayTransport { + message: error.to_string(), + } + } +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -18,12 +18,20 @@ use std::{string::String, vec::Vec}; pub mod adapters; pub mod client; pub mod config; +#[cfg(feature = "runtime")] +mod error; pub mod farm; #[cfg(feature = "identity-models")] pub mod identity; pub mod listing; pub mod order; +#[cfg(feature = "runtime")] +mod product_clients; pub mod profile; +#[cfg(feature = "runtime")] +mod receipt; +#[cfg(feature = "runtime")] +mod runtime; #[cfg(feature = "radrootsd-client")] pub use crate::adapters::radrootsd::{ @@ -55,6 +63,19 @@ pub use crate::config::{ RadrootsdAuth, RadrootsdConfig, RelayConfig, SdkConfigError, SdkEnvironment, SdkTransportMode, SignerConfig, }; +#[cfg(feature = "runtime")] +pub use crate::error::{ + RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkRecoveryAction, +}; +#[cfg(feature = "runtime")] +pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient}; +#[cfg(feature = "runtime")] +pub use crate::receipt::{RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt}; +#[cfg(feature = "runtime")] +pub use crate::runtime::{ + RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, + RadrootsSdkStoragePaths, RadrootsSdkTimestamp, +}; pub use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -0,0 +1,43 @@ +#[cfg(feature = "runtime")] +use crate::RadrootsSdk; +#[cfg(feature = "runtime")] +use core::marker::PhantomData; + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy)] +pub struct ListingsClient<'sdk> { + _sdk: PhantomData<&'sdk RadrootsSdk>, +} + +#[cfg(feature = "runtime")] +impl<'sdk> ListingsClient<'sdk> { + pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self { + Self { _sdk: PhantomData } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy)] +pub struct OrdersClient<'sdk> { + _sdk: PhantomData<&'sdk RadrootsSdk>, +} + +#[cfg(feature = "runtime")] +impl<'sdk> OrdersClient<'sdk> { + pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self { + Self { _sdk: PhantomData } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy)] +pub struct SyncClient<'sdk> { + _sdk: PhantomData<&'sdk RadrootsSdk>, +} + +#[cfg(feature = "runtime")] +impl<'sdk> SyncClient<'sdk> { + pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self { + Self { _sdk: PhantomData } + } +} diff --git a/crates/sdk/src/receipt.rs b/crates/sdk/src/receipt.rs @@ -0,0 +1,18 @@ +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsSdkEventReference { + pub event_id: String, + pub pubkey: String, + pub kind: u32, + pub created_at: u32, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsSdkLocalMutationReceipt { + pub event: RadrootsSdkEventReference, + pub stored: bool, + pub queued: bool, + pub outbox_event_id: Option<i64>, + pub idempotency_key_digest_prefix: Option<String>, +} diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -0,0 +1,231 @@ +#[cfg(feature = "runtime")] +use crate::{ + ListingsClient, OrdersClient, RadrootsSdkError, SyncClient, error::RadrootsSdkRecoveryAction, +}; +#[cfg(feature = "runtime")] +use radroots_event_store::RadrootsEventStore; +#[cfg(feature = "runtime")] +use radroots_outbox::RadrootsOutbox; +#[cfg(feature = "runtime")] +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsSdkStorageConfig { + Memory, + Directory(PathBuf), +} + +#[cfg(feature = "runtime")] +impl Default for RadrootsSdkStorageConfig { + fn default() -> Self { + Self::Memory + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RadrootsSdkTimestamp(u64); + +#[cfg(feature = "runtime")] +impl RadrootsSdkTimestamp { + pub fn from_unix_seconds(seconds: u64) -> Self { + Self(seconds) + } + + pub fn unix_seconds(self) -> u64 { + self.0 + } + + pub fn try_into_nostr_created_at(self) -> Result<u32, RadrootsSdkError> { + u32::try_from(self.0).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: self.0 }) + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsSdkClock { + System, + Fixed(RadrootsSdkTimestamp), +} + +#[cfg(feature = "runtime")] +impl Default for RadrootsSdkClock { + fn default() -> Self { + Self::System + } +} + +#[cfg(feature = "runtime")] +impl RadrootsSdkClock { + pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { + match self { + Self::System => { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)?; + Ok(RadrootsSdkTimestamp::from_unix_seconds(duration.as_secs())) + } + Self::Fixed(timestamp) => Ok(*timestamp), + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsSdkStoragePaths { + pub event_store_path: PathBuf, + pub outbox_path: PathBuf, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +pub struct RadrootsSdkBuilder { + storage: RadrootsSdkStorageConfig, + clock: RadrootsSdkClock, + relay_urls: Vec<String>, +} + +#[cfg(feature = "runtime")] +impl Default for RadrootsSdkBuilder { + fn default() -> Self { + Self { + storage: RadrootsSdkStorageConfig::Memory, + clock: RadrootsSdkClock::System, + relay_urls: Vec::new(), + } + } +} + +#[cfg(feature = "runtime")] +impl RadrootsSdkBuilder { + pub fn storage(mut self, storage: RadrootsSdkStorageConfig) -> Self { + self.storage = storage; + self + } + + pub fn directory_storage(mut self, path: impl Into<PathBuf>) -> Self { + self.storage = RadrootsSdkStorageConfig::Directory(path.into()); + self + } + + pub fn clock(mut self, clock: RadrootsSdkClock) -> Self { + self.clock = clock; + self + } + + pub fn fixed_clock(mut self, timestamp: RadrootsSdkTimestamp) -> Self { + self.clock = RadrootsSdkClock::Fixed(timestamp); + self + } + + pub fn relay_url(mut self, relay_url: impl Into<String>) -> Self { + self.relay_urls.push(relay_url.into()); + self + } + + pub async fn build(self) -> Result<RadrootsSdk, RadrootsSdkError> { + let storage = open_storage(&self.storage).await?; + Ok(RadrootsSdk { + _event_store: storage.event_store, + _outbox: storage.outbox, + storage_paths: storage.paths, + clock: self.clock, + relay_urls: self.relay_urls, + }) + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone)] +pub struct RadrootsSdk { + pub(crate) _event_store: RadrootsEventStore, + pub(crate) _outbox: RadrootsOutbox, + storage_paths: Option<RadrootsSdkStoragePaths>, + clock: RadrootsSdkClock, + relay_urls: Vec<String>, +} + +#[cfg(feature = "runtime")] +impl RadrootsSdk { + pub fn builder() -> RadrootsSdkBuilder { + RadrootsSdkBuilder::default() + } + + pub fn listings(&self) -> ListingsClient<'_> { + ListingsClient::new(self) + } + + pub fn orders(&self) -> OrdersClient<'_> { + OrdersClient::new(self) + } + + pub fn sync(&self) -> SyncClient<'_> { + SyncClient::new(self) + } + + pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { + self.clock.now() + } + + pub fn relay_urls(&self) -> &[String] { + &self.relay_urls + } + + pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> { + self.storage_paths.as_ref() + } +} + +#[cfg(feature = "runtime")] +struct OpenedRuntimeStorage { + event_store: RadrootsEventStore, + outbox: RadrootsOutbox, + paths: Option<RadrootsSdkStoragePaths>, +} + +#[cfg(feature = "runtime")] +async fn open_storage( + storage: &RadrootsSdkStorageConfig, +) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { + match storage { + RadrootsSdkStorageConfig::Memory => Ok(OpenedRuntimeStorage { + event_store: RadrootsEventStore::open_memory().await?, + outbox: RadrootsOutbox::open_memory().await?, + paths: None, + }), + RadrootsSdkStorageConfig::Directory(path) => open_directory_storage(path).await, + } +} + +#[cfg(feature = "runtime")] +async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { + fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let paths = RadrootsSdkStoragePaths { + event_store_path: path.join("event_store.sqlite"), + outbox_path: path.join("outbox.sqlite"), + }; + Ok(OpenedRuntimeStorage { + event_store: RadrootsEventStore::open_file(&paths.event_store_path).await?, + outbox: RadrootsOutbox::open_file(&paths.outbox_path).await?, + paths: Some(paths), + }) +} + +#[cfg(feature = "runtime")] +impl RadrootsSdk { + pub fn partial_local_mutation_error(stored: bool, queued: bool) -> RadrootsSdkError { + RadrootsSdkError::partial_local_mutation( + stored, + queued, + RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + ) + } +} diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -0,0 +1,84 @@ +#![cfg(feature = "runtime")] + +use radroots_sdk::{ + RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkRecoveryAction, + RadrootsSdkStorageConfig, RadrootsSdkTimestamp, +}; + +#[tokio::test] +async fn sdk_builder_defaults_to_memory_storage_and_no_relays() { + let sdk = RadrootsSdk::builder().build().await.expect("sdk"); + + assert!(sdk.relay_urls().is_empty()); + assert!(sdk.storage_paths().is_none()); + let _listings = sdk.listings(); + let _orders = sdk.orders(); + let _sync = sdk.sync(); +} + +#[tokio::test] +async fn sdk_directory_storage_creates_deterministic_sqlite_files() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let sdk = RadrootsSdk::builder() + .storage(RadrootsSdkStorageConfig::Directory( + tempdir.path().join("sdk-runtime"), + )) + .build() + .await + .expect("sdk"); + + let paths = sdk.storage_paths().expect("paths"); + assert_eq!( + paths.event_store_path, + tempdir + .path() + .join("sdk-runtime") + .join("event_store.sqlite") + ); + assert_eq!( + paths.outbox_path, + tempdir.path().join("sdk-runtime").join("outbox.sqlite") + ); + assert!(paths.event_store_path.exists()); + assert!(paths.outbox_path.exists()); +} + +#[tokio::test] +async fn sdk_fixed_clock_is_used_by_runtime() { + let timestamp = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); + let sdk = RadrootsSdk::builder() + .clock(RadrootsSdkClock::Fixed(timestamp)) + .build() + .await + .expect("sdk"); + + assert_eq!(sdk.now().expect("now"), timestamp); +} + +#[test] +fn sdk_timestamp_rejects_values_outside_nostr_created_at_range() { + let valid = RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX)); + assert_eq!(valid.try_into_nostr_created_at().expect("valid"), u32::MAX); + + let invalid = RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1); + assert!(matches!( + invalid.try_into_nostr_created_at(), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); +} + +#[test] +fn sdk_partial_local_mutation_error_is_sanitized() { + let error = RadrootsSdkError::partial_local_mutation( + true, + false, + RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + ); + let message = error.to_string(); + + assert!(message.contains("stored=true")); + assert!(message.contains("queued=false")); + assert!(!message.contains("sig")); + assert!(!message.contains("raw")); + assert!(!message.contains("idempotency-key")); +}