sdk

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

commit 4eb56c578968161e1b9ce2f4ae65fae3a4f3fd70
parent 0675889988526ff65e7764971545fb1f4c26d9b5
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:
MCargo.lock | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 4++++
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+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 873 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -120,6 +120,15 @@ dependencies = [ ] [[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] name = "atomic-destructor" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -314,6 +323,15 @@ dependencies = [ ] [[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "config" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -377,6 +395,21 @@ dependencies = [ ] [[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] name = "crossbeam-channel" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -386,6 +419,15 @@ dependencies = [ ] [[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -452,10 +494,19 @@ dependencies = [ ] [[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -483,6 +534,17 @@ dependencies = [ ] [[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] name = "fallible-iterator" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -507,6 +569,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -564,6 +637,17 @@ dependencies = [ ] [[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -687,6 +771,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -715,6 +801,15 @@ dependencies = [ ] [[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1091,6 +1186,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1298,6 +1402,35 @@ dependencies = [ ] [[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1520,6 +1653,7 @@ name = "radroots_authority" version = "0.1.0-alpha.2" dependencies = [ "radroots_events", + "radroots_nostr", ] [[package]] @@ -1540,6 +1674,18 @@ dependencies = [ ] [[package]] +name = "radroots_event_store" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_events", + "radroots_nostr", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", +] + +[[package]] name = "radroots_events" version = "0.1.0-alpha.2" dependencies = [ @@ -1634,6 +1780,8 @@ version = "0.1.0-alpha.2" dependencies = [ "nostr", "nostr-sdk", + "radroots_events", + "radroots_events_codec", "radroots_identity", "serde", "serde_json", @@ -1670,6 +1818,20 @@ dependencies = [ ] [[package]] +name = "radroots_outbox" +version = "0.1.0-alpha.2" +dependencies = [ + "hex", + "radroots_event_store", + "radroots_events", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 1.0.69", +] + +[[package]] name = "radroots_protected_store" version = "0.1.0-alpha.2" dependencies = [ @@ -1682,6 +1844,22 @@ dependencies = [ ] [[package]] +name = "radroots_relay_transport" +version = "0.1.0-alpha.2" +dependencies = [ + "futures", + "nostr", + "radroots_event_store", + "radroots_events", + "radroots_nostr", + "radroots_outbox", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", +] + +[[package]] name = "radroots_replica_db" version = "0.1.0-alpha.2" dependencies = [ @@ -1799,13 +1977,17 @@ version = "0.1.0" dependencies = [ "futures", "nostr", + "radroots_authority", "radroots_core", + "radroots_event_store", "radroots_events", "radroots_events_codec", "radroots_identity", "radroots_nostr", "radroots_nostr_connect", "radroots_nostr_signer", + "radroots_outbox", + "radroots_relay_transport", "radroots_replica_db", "radroots_replica_db_schema", "radroots_replica_sync", @@ -1975,6 +2157,15 @@ dependencies = [ ] [[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2186,6 +2377,12 @@ dependencies = [ ] [[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2368,6 +2565,119 @@ dependencies = [ ] [[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2592,6 +2902,17 @@ dependencies = [ ] [[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] name = "tokio-tungstenite" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2699,7 +3020,9 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] @@ -2717,6 +3040,17 @@ dependencies = [ ] [[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -27,6 +27,8 @@ readme = "README" [workspace.dependencies] radroots_core = { path = "../lib/crates/core", version = "0.1.0-alpha.2", default-features = false } +radroots_authority = { path = "../lib/crates/authority", version = "0.1.0-alpha.2", default-features = false } +radroots_event_store = { path = "../lib/crates/event_store", version = "0.1.0-alpha.2", default-features = false } radroots_events = { path = "../lib/crates/events", version = "0.1.0-alpha.2", default-features = false } radroots_events_codec = { path = "../lib/crates/events_codec", version = "0.1.0-alpha.2", default-features = false } radroots_events_indexed = { path = "../lib/crates/events_indexed", version = "0.1.0-alpha.2", default-features = false } @@ -36,6 +38,8 @@ radroots_identity = { path = "../lib/crates/identity", version = "0.1.0-alpha.2" radroots_nostr = { path = "../lib/crates/nostr", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_connect = { path = "../lib/crates/nostr_connect", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_signer = { path = "../lib/crates/nostr_signer", version = "0.1.0-alpha.2", default-features = false } +radroots_outbox = { path = "../lib/crates/outbox", version = "0.1.0-alpha.2", default-features = false } +radroots_relay_transport = { path = "../lib/crates/relay_transport", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db = { path = "../lib/crates/replica_db", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version = "0.1.0-alpha.2", default-features = false } radroots_replica_sync = { path = "../lib/crates/replica_sync", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -40,10 +40,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")); +}