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