lib

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

commit ba8dc9cae4d0ff645b751a4636f2d2b0c82a307f
parent 0ce314697a6e87c6434d44e785ab7fac352348af
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 09:08:53 +0000

sdk: expose market projections

Diffstat:
Mcrates/sdk/src/error.rs | 16++++++++++++++++
Mcrates/sdk/src/lib.rs | 13+++++++++++--
Acrates/sdk/src/market_runtime.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/orders_runtime.rs | 1+
Mcrates/sdk/src/product_clients.rs | 4++--
Mcrates/sdk/src/sync_runtime.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/sdk/tests/market_runtime.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 2+-
Mcrates/sdk/tests/runtime_foundation.rs | 3+++
Mcrates/sdk/tests/unit/orders_runtime_tests.rs | 8++++++++
10 files changed, 528 insertions(+), 7 deletions(-)

diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -737,6 +737,22 @@ impl From<radroots_trade::listing::RadrootsListingMutationError> for RadrootsSdk } #[cfg(feature = "runtime")] +impl From<radroots_trade::projection::RadrootsTradeProjectionError> for RadrootsSdkError { + fn from(error: radroots_trade::projection::RadrootsTradeProjectionError) -> Self { + match error { + radroots_trade::projection::RadrootsTradeProjectionError::InvalidLimit { max } => { + Self::InvalidRequest { + message: format!("projection query limit must be between 1 and {max}"), + } + } + error => Self::Projection { + message: error.to_string(), + }, + } + } +} + +#[cfg(feature = "runtime")] impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError { fn from(error: radroots_outbox::RadrootsOutboxError) -> Self { match error { diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -27,6 +27,8 @@ mod identity; mod listing; #[cfg(feature = "runtime")] mod listings_runtime; +#[cfg(feature = "runtime")] +mod market_runtime; mod order; #[cfg(feature = "runtime")] mod orders_runtime; @@ -72,6 +74,11 @@ pub use crate::listings_runtime::{ ListingPreparePublishRequest, ListingPublishPlan, SdkMutationState, }; #[cfg(feature = "runtime")] +pub use crate::market_runtime::{ + MARKET_SEARCH_DEFAULT_LIMIT, MarketListingSearchRow, MarketSearchReceipt, MarketSearchRequest, + MarketSearchSource, +}; +#[cfg(feature = "runtime")] pub use crate::orders_runtime::{ ORDER_CANCELLATION_OPERATION_KIND, ORDER_DECISION_OPERATION_KIND, ORDER_REVISION_DECISION_OPERATION_KIND, ORDER_REVISION_PROPOSAL_OPERATION_KIND, @@ -123,6 +130,8 @@ pub use crate::sync_runtime::{ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, - PushOutboxRequest, SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, - SyncRelayTargetSummary, SyncStatusReceipt, SyncStatusRequest, SyncStatusSource, + PushOutboxRequest, SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT, SYNC_PROJECTION_REFRESH_MAX_LIMIT, + SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, SyncProjectionRefreshReceipt, + SyncProjectionRefreshRequest, SyncRelayTargetSummary, SyncStatusReceipt, SyncStatusRequest, + SyncStatusSource, }; diff --git a/crates/sdk/src/market_runtime.rs b/crates/sdk/src/market_runtime.rs @@ -0,0 +1,160 @@ +#[cfg(feature = "runtime")] +use crate::sync_runtime::refresh_product_projections_for_sdk; +#[cfg(feature = "runtime")] +use crate::{ + MarketClient, RadrootsSdkError, SyncProjectionRefreshReceipt, SyncProjectionRefreshRequest, +}; +#[cfg(feature = "runtime")] +use radroots_events::ids::{RadrootsEventId, RadrootsListingAddress, RadrootsPublicKey}; +#[cfg(feature = "runtime")] +use radroots_trade::projection::{ + RadrootsListingProjectionRow, RadrootsListingSearchRequest, search_listing_projection, +}; + +#[cfg(feature = "runtime")] +pub const MARKET_SEARCH_DEFAULT_LIMIT: u32 = 50; + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] +pub struct MarketSearchRequest { + pub query: String, + pub limit: u32, + pub projection_refresh: SyncProjectionRefreshRequest, +} + +#[cfg(feature = "runtime")] +impl MarketSearchRequest { + pub fn new(query: impl Into<String>) -> Self { + Self { + query: query.into(), + limit: MARKET_SEARCH_DEFAULT_LIMIT, + projection_refresh: SyncProjectionRefreshRequest::new(), + } + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + pub fn with_projection_refresh(mut self, refresh: SyncProjectionRefreshRequest) -> Self { + self.projection_refresh = refresh; + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct MarketSearchReceipt { + pub source: MarketSearchSource, + pub refresh: SyncProjectionRefreshReceipt, + pub listings: Vec<MarketListingSearchRow>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum MarketSearchSource { + LocalProjectionFts, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct MarketListingSearchRow { + pub listing_addr: RadrootsListingAddress, + pub listing_event_id: RadrootsEventId, + pub seller_pubkey: RadrootsPublicKey, + pub title: String, + pub description: String, + pub product_type: String, + pub price_amount: String, + pub price_currency: String, + pub inventory_available: String, + pub delivery_method: String, + pub locality_primary: String, + pub locality_city: Option<String>, + pub locality_region: Option<String>, + pub locality_country: Option<String>, + pub geohash5: String, + pub updated_at_ms: i64, +} + +#[cfg(feature = "runtime")] +impl<'sdk> MarketClient<'sdk> { + pub async fn search( + &self, + request: MarketSearchRequest, + ) -> Result<MarketSearchReceipt, RadrootsSdkError> { + let refresh = + refresh_product_projections_for_sdk(self.sdk, request.projection_refresh).await?; + let rows = search_listing_projection( + &self.sdk._event_store, + &RadrootsListingSearchRequest::new(request.query).with_limit(request.limit), + ) + .await?; + let listings = rows + .into_iter() + .map(MarketListingSearchRow::try_from_projection_row) + .collect::<Result<Vec<_>, _>>()?; + Ok(MarketSearchReceipt { + source: MarketSearchSource::LocalProjectionFts, + refresh, + listings, + }) + } +} + +#[cfg(feature = "runtime")] +impl MarketListingSearchRow { + fn try_from_projection_row( + row: RadrootsListingProjectionRow, + ) -> Result<Self, RadrootsSdkError> { + let listing_addr = + RadrootsListingAddress::parse(row.listing_addr.as_str()).map_err(|source| { + RadrootsSdkError::Projection { + message: format!( + "stored listing projection address `{}` is invalid: {source}", + row.listing_addr + ), + } + })?; + let listing_event_id = + RadrootsEventId::parse(row.listing_event_id.as_str()).map_err(|source| { + RadrootsSdkError::Projection { + message: format!( + "stored listing projection event id `{}` is invalid: {source}", + row.listing_event_id + ), + } + })?; + let seller_pubkey = + RadrootsPublicKey::parse(row.seller_pubkey.as_str()).map_err(|source| { + RadrootsSdkError::Projection { + message: format!( + "stored listing projection seller pubkey `{}` is invalid: {source}", + row.seller_pubkey + ), + } + })?; + Ok(Self { + listing_addr, + listing_event_id, + seller_pubkey, + title: row.title, + description: row.description, + product_type: row.product_type, + price_amount: row.price_amount, + price_currency: row.price_currency, + inventory_available: row.inventory_available, + delivery_method: row.delivery_method, + locality_primary: row.locality_primary, + locality_city: row.locality_city, + locality_region: row.locality_region, + locality_country: row.locality_country, + geohash5: row.geohash5, + updated_at_ms: row.updated_at_ms, + }) + } +} diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -3346,6 +3346,7 @@ fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError { RadrootsOrderStoreQueryError::Decode { .. } => { "stored order event could not decode as order record" } + RadrootsOrderStoreQueryError::Projection(error) => return error.into(), }; RadrootsSdkError::Projection { message: message.to_owned(), diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -30,13 +30,13 @@ impl<'client> ListingsClient<'client> { #[cfg(feature = "runtime")] #[derive(Clone, Copy)] pub struct MarketClient<'client> { - pub(crate) _sdk: &'client RadrootsClient, + pub(crate) sdk: &'client RadrootsClient, } #[cfg(feature = "runtime")] impl<'client> MarketClient<'client> { pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { _sdk: sdk } + Self { sdk } } } diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -3,9 +3,12 @@ use crate::adapters::radrootsd::{ RadrootsdError, RadrootsdProxyPublishAdapter, RadrootsdProxyPublishRequest, }; #[cfg(feature = "runtime")] -use crate::{RadrootsSdkError, SdkRelayUrlPolicy, SyncClient, runtime::sdk_now_ms}; +use crate::{ + RadrootsSdkError, SdkRelayUrlPolicy, SyncClient, + runtime::{RadrootsClient, sdk_now_ms}, +}; #[cfg(feature = "runtime")] -use radroots_event_store::RadrootsEventStoreStatusSummary; +use radroots_event_store::{RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStoreStatusSummary}; #[cfg(feature = "runtime")] use radroots_events::ids::RadrootsEventId; #[cfg(all(feature = "runtime", feature = "relay-runtime"))] @@ -23,6 +26,12 @@ use radroots_relay_transport::{ RadrootsOutboxPublishPolicy, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, publish_claimed_outbox_event, }; +#[cfg(feature = "runtime")] +use radroots_trade::projection::{ + RADROOTS_PRODUCT_PROJECTION_ID, RADROOTS_PRODUCT_PROJECTION_VERSION, + RadrootsProjectionRefreshReceipt, RadrootsProjectionRefreshRequest, + refresh_product_projections, +}; #[cfg(feature = "runtime")] pub const PUSH_OUTBOX_DEFAULT_LIMIT: usize = 20; @@ -32,6 +41,10 @@ pub const PUSH_OUTBOX_MAX_LIMIT: usize = 100; pub const PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS: i64 = 30_000; #[cfg(feature = "runtime")] pub const PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS: i64 = 60_000; +#[cfg(feature = "runtime")] +pub const SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT: u32 = RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX; +#[cfg(feature = "runtime")] +pub const SYNC_PROJECTION_REFRESH_MAX_LIMIT: u32 = RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX; #[cfg(feature = "runtime")] const CLAIM_OWNER: &str = "radroots_sdk.sync.push_outbox"; @@ -129,6 +142,65 @@ pub struct SyncRelayTargetSummary { #[cfg(feature = "runtime")] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] +pub struct SyncProjectionRefreshRequest { + pub limit: u32, +} + +#[cfg(feature = "runtime")] +impl Default for SyncProjectionRefreshRequest { + fn default() -> Self { + Self { + limit: SYNC_PROJECTION_REFRESH_DEFAULT_LIMIT, + } + } +} + +#[cfg(feature = "runtime")] +impl SyncProjectionRefreshRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct SyncProjectionRefreshReceipt { + pub projection_id: &'static str, + pub projection_version: u32, + pub refreshed_at_ms: i64, + pub scanned_events: usize, + pub listing_upserts: usize, + pub trade_upserts: usize, + pub validation_receipts: usize, + pub relay_observations: i64, + pub last_event_seq: Option<i64>, +} + +#[cfg(feature = "runtime")] +impl SyncProjectionRefreshReceipt { + fn from_trade(receipt: RadrootsProjectionRefreshReceipt, refreshed_at_ms: i64) -> Self { + Self { + projection_id: RADROOTS_PRODUCT_PROJECTION_ID, + projection_version: RADROOTS_PRODUCT_PROJECTION_VERSION, + refreshed_at_ms, + scanned_events: receipt.scanned_events, + listing_upserts: receipt.listing_upserts, + trade_upserts: receipt.trade_upserts, + validation_receipts: receipt.validation_receipts, + relay_observations: receipt.relay_observations, + last_event_seq: receipt.last_event_seq, + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum SdkRelayAuthPolicy { @@ -356,6 +428,13 @@ impl From<RadrootsRelayOutcomeKind> for PushOutboxRelayOutcomeKind { #[cfg(feature = "runtime")] impl<'sdk> SyncClient<'sdk> { + pub async fn refresh_projections( + &self, + request: SyncProjectionRefreshRequest, + ) -> Result<SyncProjectionRefreshReceipt, RadrootsSdkError> { + refresh_product_projections_for_sdk(self.sdk, request).await + } + pub async fn status( &self, _request: SyncStatusRequest, @@ -499,6 +578,24 @@ impl<'sdk> SyncClient<'sdk> { } } +#[cfg(feature = "runtime")] +pub(crate) async fn refresh_product_projections_for_sdk( + sdk: &RadrootsClient, + request: SyncProjectionRefreshRequest, +) -> Result<SyncProjectionRefreshReceipt, RadrootsSdkError> { + let refreshed_at_ms = sdk_now_ms(sdk)?; + let receipt = refresh_product_projections( + &sdk._event_store, + RadrootsProjectionRefreshRequest::new().with_limit(request.limit), + refreshed_at_ms, + ) + .await?; + Ok(SyncProjectionRefreshReceipt::from_trade( + receipt, + refreshed_at_ms, + )) +} + #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] async fn push_proxy_claimed_outbox_event( sync: &SyncClient<'_>, diff --git a/crates/sdk/tests/market_runtime.rs b/crates/sdk/tests/market_runtime.rs @@ -0,0 +1,227 @@ +#![cfg(feature = "runtime")] + +use radroots_authority::{ + RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, +}; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::{ + contract::RadrootsActorRole, + draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, + RadrootsListingStatus, + }, +}; +use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, +}; +use radroots_sdk::{ + ListingEnqueuePublishRequest, MarketSearchRequest, RadrootsClient, RadrootsSdkError, + RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayUrlPolicy, SyncProjectionRefreshRequest, +}; + +const SELLER_SECRET_KEY_HEX: &str = + "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; +const SELLER_PUBLIC_KEY_HEX: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; +const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; +const LISTING_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; +const RELAY: &str = "wss://relay.radroots.test"; + +#[derive(Clone)] +struct FixtureSigner { + identity: RadrootsSignerIdentity, + keys: RadrootsNostrKeys, +} + +impl FixtureSigner { + fn new(secret_key_hex: &str) -> Self { + let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let pubkey = keys.public_key().to_hex(); + Self { + identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), + keys, + } + } +} + +impl RadrootsEventSigner for FixtureSigner { + fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey { + self.identity.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsFrozenEventDraft, + ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { + radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { + RadrootsSignerError::SigningFailed { + message: error.to_string(), + } + }) + } +} + +fn seller_actor() -> RadrootsActorContext { + RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") +} + +fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().expect("decimal") +} + +fn listing(title: &str) -> RadrootsListing { + RadrootsListing { + d_tag: RadrootsDTag::parse(LISTING_D_TAG).expect("d tag"), + published_at: Some(1_700_000_000), + farm: RadrootsFarmRef { + pubkey: SELLER_PUBLIC_KEY_HEX.to_owned(), + d_tag: FARM_D_TAG.to_owned(), + }, + product: RadrootsListingProduct { + key: "blueberries".to_owned(), + title: title.to_owned(), + category: "fruit".to_owned(), + summary: Some("Fresh field berries".to_owned()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: RadrootsInventoryBinId::parse("pint").expect("bin id"), + bins: vec![RadrootsListingBin { + bin_id: RadrootsInventoryBinId::parse("pint").expect("bin id"), + quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new(decimal("6"), RadrootsCoreCurrency::USD), + quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(decimal("12")), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingPublicLocation { + primary: "Fernwood".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("BC".to_owned()), + country: Some("CA".to_owned()), + geohash: "c2b2q".to_owned(), + }), + images: None, + } +} + +async fn directory_sdk() -> (tempfile::TempDir, RadrootsClient) { + let tempdir = tempfile::tempdir().expect("tempdir"); + let sdk = RadrootsClient::builder() + .directory_storage(tempdir.path().join("sdk")) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .relay_url(RELAY) + .build() + .await + .expect("sdk"); + (tempdir, sdk) +} + +#[tokio::test] +async fn market_search_refreshes_local_projection_and_reads_fts() { + let (_tempdir, sdk) = directory_sdk().await; + let publish = ListingEnqueuePublishRequest::new( + seller_actor(), + listing("Blueberries"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"); + let receipt = sdk + .listings() + .enqueue_publish_with_explicit_signer(publish, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .await + .expect("publish"); + + let search = sdk + .market() + .search(MarketSearchRequest::new("blueberries victoria").with_limit(10)) + .await + .expect("search"); + + assert_eq!( + search.source, + radroots_sdk::MarketSearchSource::LocalProjectionFts + ); + assert_eq!( + search.refresh.projection_id, + "radroots.product_projection.v1" + ); + assert_eq!(search.refresh.projection_version, 1); + assert_eq!(search.refresh.refreshed_at_ms, 1_700_000_000_000); + assert_eq!(search.refresh.scanned_events, 1); + assert_eq!(search.refresh.listing_upserts, 1); + assert_eq!(search.refresh.trade_upserts, 0); + assert_eq!(search.refresh.validation_receipts, 0); + assert_eq!(search.listings.len(), 1); + assert_eq!(search.listings[0].listing_event_id, receipt.signed_event_id); + assert_eq!( + search.listings[0].seller_pubkey.as_str(), + SELLER_PUBLIC_KEY_HEX + ); + assert_eq!(search.listings[0].title, "Blueberries"); + assert_eq!(search.listings[0].product_type, "fruit"); + assert_eq!( + search.listings[0].locality_city.as_deref(), + Some("Victoria") + ); + assert_eq!(search.listings[0].geohash5, "c2b2q"); + + let second = sdk + .market() + .search(MarketSearchRequest::new("").with_limit(10)) + .await + .expect("second search"); + assert_eq!(second.refresh.scanned_events, 0); + assert_eq!(second.listings.len(), 1); +} + +#[tokio::test] +async fn projection_refresh_limits_are_request_errors() { + let (_tempdir, sdk) = directory_sdk().await; + + let sync_error = sdk + .sync() + .refresh_projections(SyncProjectionRefreshRequest::new().with_limit(0)) + .await + .expect_err("sync limit"); + assert!(matches!( + sync_error, + RadrootsSdkError::InvalidRequest { .. } + )); + assert_eq!(sync_error.code(), "invalid_request"); + + let market_error = sdk + .market() + .search(MarketSearchRequest::new("berries").with_limit(0)) + .await + .expect_err("market limit"); + assert!(matches!( + market_error, + RadrootsSdkError::InvalidRequest { .. } + )); +} diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -3584,7 +3584,7 @@ async fn order_status_maps_malformed_local_data_to_sanitized_error() { let message = error.to_string(); assert!(matches!(error, RadrootsSdkError::Projection { .. })); - assert!(message.contains("stored order event tags could not be decoded")); + assert!(message.contains("contains invalid tags_json")); assert!(!message.contains(raw_event_json.as_str())); assert!(!message.contains(request_event.sig.as_str())); assert!(!message.contains("\"tags\"")); diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -154,6 +154,9 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { let event_tables = sqlite_table_names(&paths.event_store_path).await; assert!(event_tables.iter().any(|name| name == "nostr_events")); assert!(event_tables.iter().any(|name| name == "nostr_event_tags")); + assert!(event_tables.iter().any(|name| name == "listing_projection")); + assert!(event_tables.iter().any(|name| name == "trade_projection")); + assert!(event_tables.iter().any(|name| name == "listing_search_fts")); assert!(!event_tables.iter().any(|name| name == "nostr_event")); assert!(!event_tables.iter().any(|name| name == "nostr_event_tag")); let outbox_tables = sqlite_table_names(&paths.outbox_path).await; diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs @@ -19,6 +19,7 @@ use radroots_nostr::prelude::{ }; use radroots_trade::{ order::{RadrootsOrderEventDecodeError, RadrootsOrderIssue}, + projection::RadrootsTradeProjectionError, workflow::RadrootsTradeWorkflowState, }; @@ -1868,6 +1869,13 @@ fn projection_error_maps_store_tag_and_decode_errors() { })), "stored order event could not decode as order record" ); + let invalid_limit = projection_error(RadrootsOrderStoreQueryError::Projection( + RadrootsTradeProjectionError::InvalidLimit { max: 1000 }, + )); + assert!(matches!( + invalid_limit, + RadrootsSdkError::InvalidRequest { .. } + )); } #[test]