lib

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

commit 652c23d6068c67e859eed256ade32684125db0e4
parent cb5ff2e27e42c261ab3e122d8d85d75a108ce6e3
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 10:25:25 +0000

sdk: compose canonical lower interfaces in ClientBuilder

- inject storage signer source sink and sync capabilities explicitly
- provide deterministic memory composition without hidden side effects
- reject missing storage and signer-without-sink configurations
- prove memory SQLite Nostr and directional capability contracts

Diffstat:
Mcrates/sdk/src/client.rs | 274++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/src/error.rs | 17+++++++++++------
Mcrates/sdk/tests/package_boundary.rs | 16++++++++++++++++
3 files changed, 291 insertions(+), 16 deletions(-)

diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -1,19 +1,273 @@ //! Client construction and lifecycle. +use std::sync::Arc; + +use radroots_signing::Signer; +use radroots_storage::Storage; +#[cfg(feature = "memory")] +use radroots_storage::{event::SourceGeneration, memory::MemoryStorage}; +use radroots_transport::{EventSink, EventSource}; + +use crate::{Error, Result}; + /// Cloneable handle to a composed Radroots client. -/// -/// Construction is intentionally introduced by the ordered builder -/// composition step; this type establishes the durable root identity only. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Client { - _private: (), + inner: Arc<ClientInner>, } /// Explicit composition boundary for a [`Client`]. -/// -/// The lower capability fields and construction methods are added by the -/// ordered composition step. -#[derive(Debug)] +#[derive(Default)] pub struct ClientBuilder { - _private: (), + storage: Option<Arc<dyn Storage>>, + signer: Option<Arc<dyn Signer>>, + source: Option<Arc<dyn EventSource>>, + sink: Option<Arc<dyn EventSink>>, + #[cfg(feature = "sync")] + sync: Option<radroots_sync::Engine>, +} + +struct ClientInner { + storage: Arc<dyn Storage>, + signer: Option<Arc<dyn Signer>>, + source: Option<Arc<dyn EventSource>>, + sink: Option<Arc<dyn EventSink>>, + #[cfg(feature = "sync")] + sync: Option<radroots_sync::Engine>, +} + +impl ClientBuilder { + /// Creates an empty builder with no hidden storage, network, signing, or + /// runtime side effects. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a builder backed by deterministic in-process memory storage. + #[cfg(feature = "memory")] + #[must_use] + pub fn memory(generation: SourceGeneration) -> Self { + Self::new().storage(Arc::new(MemoryStorage::new(generation))) + } + + /// Injects the canonical storage capability. + #[must_use] + pub fn storage(mut self, storage: Arc<dyn Storage>) -> Self { + self.storage = Some(storage); + self + } + + /// Injects an optional canonical signer capability. + #[must_use] + pub fn signer(mut self, signer: Arc<dyn Signer>) -> Self { + self.signer = Some(signer); + self + } + + /// Injects an optional inbound event source. + #[must_use] + pub fn source(mut self, source: Arc<dyn EventSource>) -> Self { + self.source = Some(source); + self + } + + /// Injects an optional outbound event sink. + #[must_use] + pub fn sink(mut self, sink: Arc<dyn EventSink>) -> Self { + self.sink = Some(sink); + self + } + + /// Injects an explicitly composed synchronization engine. + #[cfg(feature = "sync")] + #[must_use] + pub fn sync_engine(mut self, sync: radroots_sync::Engine) -> Self { + self.sync = Some(sync); + self + } + + /// Validates the selected capabilities and creates a client handle. + pub fn build(self) -> Result<Client> { + let storage = self.storage.ok_or(Error::MissingStorage)?; + if self.signer.is_some() && self.sink.is_none() { + return Err(Error::SignerWithoutSink); + } + Ok(Client { + inner: Arc::new(ClientInner { + storage, + signer: self.signer, + source: self.source, + sink: self.sink, + #[cfg(feature = "sync")] + sync: self.sync, + }), + }) + } +} + +impl Client { + /// Returns the injected canonical storage capability. + #[must_use] + pub fn storage(&self) -> &dyn Storage { + self.inner.storage.as_ref() + } + + /// Returns the injected signer, when outbound authoring is enabled. + #[must_use] + pub fn signer(&self) -> Option<&dyn Signer> { + self.inner.signer.as_deref() + } + + /// Returns the injected inbound source, when pull is enabled. + #[must_use] + pub fn source(&self) -> Option<&dyn EventSource> { + self.inner.source.as_deref() + } + + /// Returns the injected outbound sink, when delivery is enabled. + #[must_use] + pub fn sink(&self) -> Option<&dyn EventSink> { + self.inner.sink.as_deref() + } + + /// Returns the explicit synchronization engine, when configured. + #[cfg(feature = "sync")] + #[must_use] + pub fn sync_engine(&self) -> Option<&radroots_sync::Engine> { + self.inner.sync.as_ref() + } +} + +impl std::fmt::Debug for Client { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Client") + .field("signer", &self.inner.signer.is_some()) + .field("source", &self.inner.source.is_some()) + .field("sink", &self.inner.sink.is_some()) + .finish_non_exhaustive() + } +} + +impl std::fmt::Debug for ClientBuilder { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClientBuilder") + .field("storage", &self.storage.is_some()) + .field("signer", &self.signer.is_some()) + .field("source", &self.source.is_some()) + .field("sink", &self.sink.is_some()) + .finish_non_exhaustive() + } +} + +#[cfg(all(test, feature = "memory"))] +mod tests { + use super::*; + use radroots_signing::{ + Error as SigningError, SignReceipt, SignRequest, SignerStatus, error::Kind, + signer::BoxFuture as SigningFuture, + }; + use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest, + SinkStatus, SourceStatus, source::BoxFuture as TransportFuture, + }; + + struct TestSource; + struct TestSink; + struct TestSigner; + + impl EventSource for TestSource { + fn status(&self) -> TransportFuture<'_, std::result::Result<SourceStatus, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + + fn fetch( + &self, + _request: FetchRequest, + ) -> TransportFuture<'_, std::result::Result<FetchPage, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + impl EventSink for TestSink { + fn status(&self) -> TransportFuture<'_, std::result::Result<SinkStatus, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + + fn deliver( + &self, + _request: DeliveryRequest, + ) -> TransportFuture<'_, std::result::Result<DeliveryReceipt, TransportError>> { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + impl Signer for TestSigner { + fn status(&self) -> SigningFuture<'_, std::result::Result<SignerStatus, SigningError>> { + Box::pin(async { Err(SigningError::new(Kind::InternalError)) }) + } + + fn sign( + &self, + _request: SignRequest, + ) -> SigningFuture<'_, std::result::Result<SignReceipt, SigningError>> { + Box::pin(async { Err(SigningError::new(Kind::InternalError)) }) + } + } + + fn generation() -> SourceGeneration { + SourceGeneration::new([1; 32]).expect("non-zero generation") + } + + #[test] + fn missing_storage_and_signer_without_sink_fail_closed() { + assert!(matches!( + ClientBuilder::new().build(), + Err(Error::MissingStorage) + )); + assert!(matches!( + ClientBuilder::memory(generation()) + .signer(Arc::new(TestSigner)) + .build(), + Err(Error::SignerWithoutSink) + )); + } + + #[test] + fn memory_local_source_only_and_sink_only_compositions_are_explicit() { + let local = ClientBuilder::memory(generation()).build().expect("local"); + assert!(local.source().is_none()); + assert!(local.sink().is_none()); + assert!(local.signer().is_none()); + + let source = ClientBuilder::memory(generation()) + .source(Arc::new(TestSource)) + .build() + .expect("source-only"); + assert!(source.source().is_some()); + assert!(source.sink().is_none()); + + let sink = ClientBuilder::memory(generation()) + .sink(Arc::new(TestSink)) + .build() + .expect("sink-only"); + assert!(sink.source().is_none()); + assert!(sink.sink().is_some()); + } + + #[test] + fn signer_with_sink_is_valid_and_diagnostics_are_capability_only() { + let client = ClientBuilder::memory(generation()) + .sink(Arc::new(TestSink)) + .signer(Arc::new(TestSigner)) + .build() + .expect("outbound client"); + assert!(client.signer().is_some()); + assert_eq!( + format!("{client:?}"), + "Client { signer: true, source: false, sink: true, .. }" + ); + } } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -2,18 +2,23 @@ use std::{error, fmt}; -/// Narrow SDK error boundary. -/// -/// Concrete variants are introduced with the owning operation contracts. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] -pub struct Error { - _private: (), +pub enum Error { + /// No storage capability was supplied. + MissingStorage, + /// A signer cannot be selected without an outbound event sink. + SignerWithoutSink, } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("Radroots SDK operation failed") + match self { + Self::MissingStorage => formatter.write_str("SDK storage capability is missing"), + Self::SignerWithoutSink => { + formatter.write_str("SDK signer requires an outbound event sink") + } + } } } diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -73,6 +73,22 @@ fn root_types_have_the_required_std_contracts() { assert!(result.is_ok()); } +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_backend_implements_the_canonical_storage_capability() { + fn assert_storage<T: radroots_storage::Storage>() {} + assert_storage::<radroots_storage_sqlite::SqliteStorage>(); +} + +#[cfg(feature = "nostr")] +#[test] +fn nostr_adapter_implements_independent_source_and_sink_capabilities() { + fn assert_source<T: radroots_transport::EventSource>() {} + fn assert_sink<T: radroots_transport::EventSink>() {} + assert_source::<radroots_transport_nostr::NostrTransport>(); + assert_sink::<radroots_transport_nostr::NostrTransport>(); +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]")