commit 436d5958715f3b68e179b2dec05325d4bfc5d061
parent b8838cb197532e955d77521a1779f3b659033e6c
Author: triesap <tyson@radroots.org>
Date: Fri, 26 Jun 2026 09:08:53 +0000
sdk: expose market projections
Diffstat:
11 files changed, 529 insertions(+), 7 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2146,6 +2146,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "sqlx",
"thiserror 1.0.69",
]
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]