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