lib

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

commit 687e82c0996e05ab40f61b82076a145ac2381dcd
parent abeff0a20f394d15288d360f195fee786e468a7c
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 09:08:46 +0000

trade: materialize product projections

Diffstat:
Mcrates/event_store/migrations/0001_event_store.down.sql | 3+++
Mcrates/event_store/migrations/0001_event_store.up.sql | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/store.rs | 19+++++++++++++++++++
Mcrates/trade/Cargo.toml | 5+++++
Mcrates/trade/src/lib.rs | 2++
Mcrates/trade/src/order.rs | 17+++++------------
Mcrates/trade/src/prelude.rs | 2++
Acrates/trade/src/projection.rs | 1158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1276 insertions(+), 12 deletions(-)

diff --git a/crates/event_store/migrations/0001_event_store.down.sql b/crates/event_store/migrations/0001_event_store.down.sql @@ -1,3 +1,6 @@ +DROP TABLE listing_search_fts; +DROP TABLE trade_projection; +DROP TABLE listing_projection; DROP TABLE projection_cursor; DROP TABLE nostr_event_head; DROP TABLE relay_event_seen; diff --git a/crates/event_store/migrations/0001_event_store.up.sql b/crates/event_store/migrations/0001_event_store.up.sql @@ -81,3 +81,85 @@ CREATE TABLE IF NOT EXISTS projection_cursor ( last_event_seq INTEGER NOT NULL DEFAULT 0, updated_at_ms INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS listing_projection ( + listing_addr TEXT PRIMARY KEY NOT NULL, + listing_event_id TEXT NOT NULL REFERENCES nostr_events(event_id) ON DELETE CASCADE, + seller_pubkey TEXT NOT NULL, + farm_pubkey TEXT NOT NULL, + farm_d_tag TEXT NOT NULL, + listing_d_tag TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + product_type TEXT NOT NULL, + primary_bin_id TEXT NOT NULL, + quantity_amount TEXT NOT NULL, + quantity_unit TEXT NOT NULL, + price_amount TEXT NOT NULL, + price_currency TEXT NOT NULL, + inventory_available TEXT NOT NULL, + availability_status TEXT NOT NULL, + delivery_method TEXT NOT NULL, + locality_primary TEXT NOT NULL, + locality_city TEXT, + locality_region TEXT, + locality_country TEXT, + geohash5 TEXT NOT NULL, + listing_json TEXT NOT NULL, + source_event_seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS listing_projection_seller_idx +ON listing_projection(seller_pubkey, updated_at_ms, listing_addr); + +CREATE INDEX IF NOT EXISTS listing_projection_geohash_idx +ON listing_projection(geohash5, updated_at_ms, listing_addr); + +CREATE VIRTUAL TABLE IF NOT EXISTS listing_search_fts USING fts5( + listing_addr UNINDEXED, + title, + description, + product_type, + locality, + seller_pubkey UNINDEXED, + tokenize = 'unicode61' +); + +CREATE TABLE IF NOT EXISTS trade_projection ( + order_id TEXT PRIMARY KEY NOT NULL, + status TEXT NOT NULL, + lifecycle_terminal INTEGER NOT NULL, + rhi_state TEXT NOT NULL, + listing_addr TEXT, + buyer_pubkey TEXT, + seller_pubkey TEXT, + request_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + decision_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + agreement_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + pending_revision_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + cancellation_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + validation_receipt_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + last_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + expected_listing_event_id TEXT, + current_listing_event_id TEXT REFERENCES nostr_events(event_id) ON DELETE SET NULL, + economics_json TEXT, + pending_inventory_json TEXT NOT NULL, + committed_inventory_json TEXT NOT NULL, + issues_json TEXT NOT NULL, + issue_count INTEGER NOT NULL, + source_event_count INTEGER NOT NULL, + relay_observation_count INTEGER NOT NULL, + last_source_event_seq INTEGER, + updated_at_ms INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS trade_projection_status_idx +ON trade_projection(status, updated_at_ms, order_id); + +CREATE INDEX IF NOT EXISTS trade_projection_listing_idx +ON trade_projection(listing_addr, updated_at_ms, order_id); + +CREATE INDEX IF NOT EXISTS trade_projection_actor_idx +ON trade_projection(buyer_pubkey, seller_pubkey, updated_at_ms, order_id); diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -1017,6 +1017,25 @@ mod tests { } #[tokio::test] + async fn migration_installs_canonical_projection_tables() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let rows = sqlx::query( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'virtual table') ORDER BY name", + ) + .fetch_all(store.pool()) + .await + .expect("tables"); + let names = rows + .into_iter() + .map(|row| row.try_get::<String, _>("name").expect("name")) + .collect::<Vec<_>>(); + + assert!(names.iter().any(|name| name == "listing_projection")); + assert!(names.iter().any(|name| name == "trade_projection")); + assert!(names.iter().any(|name| name == "listing_search_fts")); + } + + #[tokio::test] async fn migration_can_run_down() { let store = RadrootsEventStore::open_memory().await.expect("open"); store.migrate_down().await.expect("down"); diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -31,8 +31,10 @@ event_store = [ "std", "serde_json", "dep:radroots_event_store", + "dep:sqlx", "radroots_event_store/sqlite", "radroots_event_store/runtime-tokio", + "sqlx/sqlite", ] serde = [ "dep:serde", @@ -67,6 +69,9 @@ serde_json = { workspace = true, default-features = false, features = [ "alloc", ], optional = true } sha2 = { workspace = true, default-features = false, optional = true } +sqlx = { workspace = true, default-features = false, features = [ + "derive", +], optional = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs @@ -8,6 +8,8 @@ pub mod dto; pub mod listing; pub mod order; pub mod prelude; +#[cfg(feature = "event_store")] +pub mod projection; #[cfg(feature = "serde_json")] pub mod validation_receipt; pub mod workflow; diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -247,6 +247,8 @@ pub fn order_event_record_from_event( pub enum RadrootsOrderStoreQueryError { #[error("{0}")] Store(#[from] RadrootsEventStoreError), + #[error("{0}")] + Projection(#[from] crate::projection::RadrootsTradeProjectionError), #[error("stored order event {event_id} contains invalid tags_json: {source}")] InvalidStoredTagsJson { event_id: String, @@ -310,18 +312,9 @@ pub async fn order_projection_query_for_order_id( order_id: &RadrootsOrderId, limit: u32, ) -> Result<RadrootsOrderProjectionQueryResult, RadrootsOrderStoreQueryError> { - let records = order_events_for_order_id(store, order_id, limit).await?; - let event_ids = records - .iter() - .map(|record| record.event_id().clone()) - .collect::<Vec<_>>(); - let event_count = records.len(); - Ok(RadrootsOrderProjectionQueryResult { - projection: reduce_order_event_records(order_id, records), - event_count, - limit_applied: limit, - event_ids, - }) + crate::projection::trade_projection_query_for_order_id(store, order_id, limit) + .await + .map_err(Into::into) } #[cfg(feature = "event_store")] diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs @@ -1,5 +1,7 @@ pub use crate::listing::*; pub use crate::order::*; +#[cfg(feature = "event_store")] +pub use crate::projection::*; #[cfg(feature = "serde_json")] pub use crate::validation_receipt::*; pub use crate::workflow::*; diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs @@ -0,0 +1,1158 @@ +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; + +use radroots_event_store::{ + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore, RadrootsEventStoreError, + RadrootsProjectionCursor, RadrootsStoredEvent, +}; +use radroots_events::{ + RadrootsNostrEvent, + ids::{RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId}, + kinds::{KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind}, + listing::{RadrootsListingAvailability, RadrootsListingDeliveryMethod, RadrootsListingStatus}, + order::RadrootsOrderEventType, + tags::TAG_D, +}; +use radroots_events_codec::order::{ + RadrootsOrderEnvelopeParseError, order_event_context_from_tags, +}; +use sqlx::Row; +use thiserror::Error; + +use crate::{ + listing::validation::{RadrootsTradeListing, validate_listing_event}, + order::{ + RadrootsGroupedOrderEventRecords, RadrootsOrderEventDecodeError, RadrootsOrderEventRecord, + RadrootsOrderProjectionQueryResult, order_event_record_from_event, + }, + validation_receipt::{RadrootsValidationReceiptError, validation_receipt_from_event}, + workflow::{ + RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState, + RadrootsTradeWorkflowValidationReceiptRecord, reduce_trade_workflow_records, + }, +}; + +pub const RADROOTS_PRODUCT_PROJECTION_ID: &str = "radroots.product_projection.v1"; +pub const RADROOTS_PRODUCT_PROJECTION_VERSION: u32 = 1; +pub const RADROOTS_TRADE_VALIDATION_RECEIPT_CONTRACT_ID: &str = + "radroots.trade.validation_receipt.v1"; + +const PRODUCT_PROJECTION_CONTRACT_IDS: [&str; 6] = [ + "radroots.order.request.v1", + "radroots.order.decision.v1", + "radroots.order.revision_proposal.v1", + "radroots.order.revision_decision.v1", + "radroots.order.cancellation.v1", + RADROOTS_TRADE_VALIDATION_RECEIPT_CONTRACT_ID, +]; + +#[derive(Debug, Error)] +pub enum RadrootsTradeProjectionError { + #[error("{0}")] + Store(#[from] RadrootsEventStoreError), + #[error("projection sqlite query failed: {0}")] + Sqlite(#[from] sqlx::Error), + #[error("stored event {event_id} contains invalid tags_json: {source}")] + InvalidStoredTagsJson { + event_id: String, + source: serde_json::Error, + }, + #[error("stored listing event {event_id} failed validation: {source}")] + ListingValidation { + event_id: String, + source: radroots_events::trade_validation::RadrootsTradeValidationListingError, + }, + #[error("stored order event {event_id} could not decode as an order record: {source}")] + OrderDecode { + event_id: String, + source: RadrootsOrderEventDecodeError, + }, + #[error("stored order event {event_id} has invalid context tags: {source}")] + OrderContext { + event_id: String, + source: RadrootsOrderEnvelopeParseError, + }, + #[error("stored validation receipt event {event_id} failed validation: {source}")] + ValidationReceipt { + event_id: String, + source: RadrootsValidationReceiptError, + }, + #[error("stored validation receipt event {event_id} has invalid order id: {source}")] + ValidationReceiptOrderId { + event_id: String, + source: RadrootsIdParseError, + }, + #[error("stored validation receipt event {event_id} has invalid event id: {source}")] + ValidationReceiptEventId { + event_id: String, + source: RadrootsIdParseError, + }, + #[error("projection serialization failed for {model}: {source}")] + Serialize { + model: &'static str, + source: serde_json::Error, + }, + #[error("projection query limit must be between 1 and {max}")] + InvalidLimit { max: u32 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsProjectionRefreshRequest { + pub limit: u32, +} + +impl Default for RadrootsProjectionRefreshRequest { + fn default() -> Self { + Self { + limit: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + } + } +} + +impl RadrootsProjectionRefreshRequest { + pub fn new() -> Self { + Self::default() + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + fn validate(self) -> Result<Self, RadrootsTradeProjectionError> { + if self.limit == 0 || self.limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX { + return Err(RadrootsTradeProjectionError::InvalidLimit { + max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + }); + } + Ok(self) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RadrootsProjectionRefreshReceipt { + 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>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingProjectionRow { + pub listing_addr: String, + pub listing_event_id: String, + pub seller_pubkey: String, + 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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsListingSearchRequest { + pub query: String, + pub limit: u32, +} + +impl RadrootsListingSearchRequest { + pub fn new(query: impl Into<String>) -> Self { + Self { + query: query.into(), + limit: 50, + } + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + fn validate(&self) -> Result<(), RadrootsTradeProjectionError> { + if self.limit == 0 || self.limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX { + return Err(RadrootsTradeProjectionError::InvalidLimit { + max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + }); + } + Ok(()) + } +} + +pub async fn refresh_product_projections( + store: &RadrootsEventStore, + request: RadrootsProjectionRefreshRequest, + updated_at_ms: i64, +) -> Result<RadrootsProjectionRefreshReceipt, RadrootsTradeProjectionError> { + let request = request.validate()?; + let events = store + .events_since_cursor(RADROOTS_PRODUCT_PROJECTION_ID, request.limit) + .await?; + let mut receipt = RadrootsProjectionRefreshReceipt { + scanned_events: events.len(), + ..RadrootsProjectionRefreshReceipt::default() + }; + let mut affected_orders = BTreeSet::new(); + + for stored_event in &events { + receipt.last_event_seq = Some(stored_event.seq); + receipt.relay_observations += + relay_observation_count_for_event(store, &stored_event.event_id).await?; + if is_listing_kind(stored_event.kind) { + let event = stored_event_to_nostr_event(stored_event)?; + let listing = validate_listing_event(&event).map_err(|source| { + RadrootsTradeProjectionError::ListingValidation { + event_id: stored_event.event_id.clone(), + source, + } + })?; + upsert_listing_projection(store, stored_event, &listing, updated_at_ms).await?; + receipt.listing_upserts += 1; + } else if is_order_event_kind(stored_event.kind) { + let event = stored_event_to_nostr_event(stored_event)?; + let record = order_event_record_from_event(&event).map_err(|source| { + RadrootsTradeProjectionError::OrderDecode { + event_id: stored_event.event_id.clone(), + source, + } + })?; + affected_orders.insert(record.order_id().clone()); + } else if stored_event.kind == KIND_TRADE_VALIDATION_RECEIPT { + let event = stored_event_to_nostr_event(stored_event)?; + let verified = validation_receipt_from_event(&event).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceipt { + event_id: stored_event.event_id.clone(), + source, + } + })?; + let order_id = + RadrootsOrderId::parse(verified.tags.order_id.as_str()).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceiptOrderId { + event_id: stored_event.event_id.clone(), + source, + } + })?; + affected_orders.insert(order_id); + receipt.validation_receipts += 1; + } + } + + for order_id in affected_orders { + upsert_trade_projection(store, &order_id, request.limit, updated_at_ms).await?; + receipt.trade_upserts += 1; + } + + if let Some(last_event_seq) = receipt.last_event_seq { + store + .update_projection_cursor(&RadrootsProjectionCursor { + projection_id: RADROOTS_PRODUCT_PROJECTION_ID.to_owned(), + projection_version: RADROOTS_PRODUCT_PROJECTION_VERSION, + last_event_seq, + updated_at_ms, + }) + .await?; + } + + Ok(receipt) +} + +pub async fn search_listing_projection( + store: &RadrootsEventStore, + request: &RadrootsListingSearchRequest, +) -> Result<Vec<RadrootsListingProjectionRow>, RadrootsTradeProjectionError> { + request.validate()?; + let rows = if let Some(query) = listing_fts_query(&request.query) { + sqlx::query( + "SELECT p.listing_addr, p.listing_event_id, p.seller_pubkey, p.title, p.description, p.product_type, p.price_amount, p.price_currency, p.inventory_available, p.delivery_method, p.locality_primary, p.locality_city, p.locality_region, p.locality_country, p.geohash5, p.updated_at_ms FROM listing_projection p JOIN listing_search_fts f ON f.listing_addr = p.listing_addr WHERE listing_search_fts MATCH ? ORDER BY bm25(listing_search_fts), p.updated_at_ms DESC, p.listing_addr LIMIT ?", + ) + .bind(query) + .bind(i64::from(request.limit)) + .fetch_all(store.pool()) + .await? + } else { + sqlx::query( + "SELECT listing_addr, listing_event_id, seller_pubkey, title, description, product_type, price_amount, price_currency, inventory_available, delivery_method, locality_primary, locality_city, locality_region, locality_country, geohash5, updated_at_ms FROM listing_projection ORDER BY updated_at_ms DESC, listing_addr LIMIT ?", + ) + .bind(i64::from(request.limit)) + .fetch_all(store.pool()) + .await? + }; + rows.into_iter().map(listing_projection_row).collect() +} + +pub async fn trade_projection_query_for_order_id( + store: &RadrootsEventStore, + order_id: &RadrootsOrderId, + limit: u32, +) -> Result<RadrootsOrderProjectionQueryResult, RadrootsTradeProjectionError> { + if limit == 0 || limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX { + return Err(RadrootsTradeProjectionError::InvalidLimit { + max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + }); + } + let inputs = trade_projection_inputs_for_order_id(store, order_id, limit).await?; + let projection = reduce_trade_workflow_records(order_id, inputs.workflow_records); + Ok(RadrootsOrderProjectionQueryResult { + projection, + event_count: inputs.event_ids.len(), + limit_applied: limit, + event_ids: inputs.event_ids, + }) +} + +async fn upsert_listing_projection( + store: &RadrootsEventStore, + stored_event: &RadrootsStoredEvent, + listing: &RadrootsTradeListing, + updated_at_ms: i64, +) -> Result<(), RadrootsTradeProjectionError> { + let listing_json = serde_json::to_string(&listing.listing).map_err(|source| { + RadrootsTradeProjectionError::Serialize { + model: "listing", + source, + } + })?; + let location = &listing.location; + let locality = listing_locality_search_text(listing); + sqlx::query( + "INSERT INTO listing_projection(listing_addr, listing_event_id, seller_pubkey, farm_pubkey, farm_d_tag, listing_d_tag, title, description, product_type, primary_bin_id, quantity_amount, quantity_unit, price_amount, price_currency, inventory_available, availability_status, delivery_method, locality_primary, locality_city, locality_region, locality_country, geohash5, listing_json, source_event_seq, created_at, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(listing_addr) DO UPDATE SET listing_event_id = excluded.listing_event_id, seller_pubkey = excluded.seller_pubkey, farm_pubkey = excluded.farm_pubkey, farm_d_tag = excluded.farm_d_tag, listing_d_tag = excluded.listing_d_tag, title = excluded.title, description = excluded.description, product_type = excluded.product_type, primary_bin_id = excluded.primary_bin_id, quantity_amount = excluded.quantity_amount, quantity_unit = excluded.quantity_unit, price_amount = excluded.price_amount, price_currency = excluded.price_currency, inventory_available = excluded.inventory_available, availability_status = excluded.availability_status, delivery_method = excluded.delivery_method, locality_primary = excluded.locality_primary, locality_city = excluded.locality_city, locality_region = excluded.locality_region, locality_country = excluded.locality_country, geohash5 = excluded.geohash5, listing_json = excluded.listing_json, source_event_seq = excluded.source_event_seq, created_at = excluded.created_at, updated_at_ms = excluded.updated_at_ms", + ) + .bind(listing.listing_addr.as_str()) + .bind(stored_event.event_id.as_str()) + .bind(listing.seller_pubkey.as_str()) + .bind(listing.listing.farm.pubkey.as_str()) + .bind(listing.listing.farm.d_tag.as_str()) + .bind(listing.listing.d_tag.as_str()) + .bind(listing.title.as_str()) + .bind(listing.description.as_str()) + .bind(listing.product_type.as_str()) + .bind(listing.primary_bin_id.as_str()) + .bind(listing.bin_quantity.amount.to_string()) + .bind(listing.unit.to_string()) + .bind(listing.unit_price.amount.to_string()) + .bind(listing.unit_price.currency.to_string()) + .bind(listing.inventory_available.to_string()) + .bind(listing_availability_label(&listing.availability)) + .bind(listing_delivery_method_label(&listing.delivery_method)) + .bind(location.primary.as_str()) + .bind(location.city.as_deref()) + .bind(location.region.as_deref()) + .bind(location.country.as_deref()) + .bind(location.geohash.as_str()) + .bind(listing_json) + .bind(stored_event.seq) + .bind(i64::from(stored_event.created_at)) + .bind(updated_at_ms) + .execute(store.pool()) + .await?; + + sqlx::query("DELETE FROM listing_search_fts WHERE listing_addr = ?") + .bind(listing.listing_addr.as_str()) + .execute(store.pool()) + .await?; + sqlx::query( + "INSERT INTO listing_search_fts(listing_addr, title, description, product_type, locality, seller_pubkey) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(listing.listing_addr.as_str()) + .bind(listing.title.as_str()) + .bind(listing.description.as_str()) + .bind(listing.product_type.as_str()) + .bind(locality) + .bind(listing.seller_pubkey.as_str()) + .execute(store.pool()) + .await?; + Ok(()) +} + +async fn upsert_trade_projection( + store: &RadrootsEventStore, + order_id: &RadrootsOrderId, + limit: u32, + updated_at_ms: i64, +) -> Result<(), RadrootsTradeProjectionError> { + let inputs = trade_projection_inputs_for_order_id(store, order_id, limit).await?; + let event_ids = inputs.event_ids.clone(); + let source_event_count = event_ids.len(); + let relay_observation_count = relay_observation_count_for_events(store, &event_ids).await?; + let expected_listing_event_id = inputs.expected_listing_event_id.clone(); + let current_listing_event_id = inputs.current_listing_event_id.clone(); + let projection = reduce_trade_workflow_records(order_id, inputs.workflow_records); + let economics_json = projection + .economics + .as_ref() + .map(|economics| { + serde_json::to_string(economics).map_err(|source| { + RadrootsTradeProjectionError::Serialize { + model: "trade_economics", + source, + } + }) + }) + .transpose()?; + let pending_inventory_json = serde_json::to_string(&projection.pending_inventory_reservations) + .map_err(|source| RadrootsTradeProjectionError::Serialize { + model: "pending_inventory", + source, + })?; + let committed_inventory_json = + serde_json::to_string(&projection.committed_inventory_reservations).map_err(|source| { + RadrootsTradeProjectionError::Serialize { + model: "committed_inventory", + source, + } + })?; + let issue_labels = projection + .issues + .iter() + .map(|issue| format!("{issue:?}")) + .collect::<Vec<_>>(); + let issues_json = serde_json::to_string(&issue_labels).map_err(|source| { + RadrootsTradeProjectionError::Serialize { + model: "trade_issues", + source, + } + })?; + + sqlx::query( + "INSERT INTO trade_projection(order_id, status, lifecycle_terminal, rhi_state, listing_addr, buyer_pubkey, seller_pubkey, request_event_id, decision_event_id, agreement_event_id, pending_revision_event_id, cancellation_event_id, validation_receipt_event_id, last_event_id, expected_listing_event_id, current_listing_event_id, economics_json, pending_inventory_json, committed_inventory_json, issues_json, issue_count, source_event_count, relay_observation_count, last_source_event_seq, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(order_id) DO UPDATE SET status = excluded.status, lifecycle_terminal = excluded.lifecycle_terminal, rhi_state = excluded.rhi_state, listing_addr = excluded.listing_addr, buyer_pubkey = excluded.buyer_pubkey, seller_pubkey = excluded.seller_pubkey, request_event_id = excluded.request_event_id, decision_event_id = excluded.decision_event_id, agreement_event_id = excluded.agreement_event_id, pending_revision_event_id = excluded.pending_revision_event_id, cancellation_event_id = excluded.cancellation_event_id, validation_receipt_event_id = excluded.validation_receipt_event_id, last_event_id = excluded.last_event_id, expected_listing_event_id = excluded.expected_listing_event_id, current_listing_event_id = excluded.current_listing_event_id, economics_json = excluded.economics_json, pending_inventory_json = excluded.pending_inventory_json, committed_inventory_json = excluded.committed_inventory_json, issues_json = excluded.issues_json, issue_count = excluded.issue_count, source_event_count = excluded.source_event_count, relay_observation_count = excluded.relay_observation_count, last_source_event_seq = excluded.last_source_event_seq, updated_at_ms = excluded.updated_at_ms", + ) + .bind(order_id.as_str()) + .bind(trade_workflow_status_label(&projection.status)) + .bind(bool_i64(projection.lifecycle_terminal)) + .bind(trade_rhi_state_label(&projection.status, projection.validation_receipt_event_id.as_ref())) + .bind(projection.listing_addr.as_ref().map(RadrootsListingAddress::as_str)) + .bind(projection.buyer_pubkey.as_ref().map(|value| value.as_str())) + .bind(projection.seller_pubkey.as_ref().map(|value| value.as_str())) + .bind(projection.request_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.decision_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.agreement_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.pending_revision_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.cancellation_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.validation_receipt_event_id.as_ref().map(|value| value.as_str())) + .bind(projection.last_event_id.as_ref().map(|value| value.as_str())) + .bind(expected_listing_event_id.as_ref().map(|value| value.as_str())) + .bind(current_listing_event_id.as_ref().map(|value| value.as_str())) + .bind(economics_json) + .bind(pending_inventory_json) + .bind(committed_inventory_json) + .bind(issues_json) + .bind(i64::try_from(projection.issues.len()).unwrap_or(i64::MAX)) + .bind(i64::try_from(source_event_count).unwrap_or(i64::MAX)) + .bind(relay_observation_count) + .bind(inputs.last_source_event_seq) + .bind(updated_at_ms) + .execute(store.pool()) + .await?; + Ok(()) +} + +struct TradeProjectionInputs { + workflow_records: RadrootsTradeWorkflowRecords, + event_ids: Vec<RadrootsEventId>, + expected_listing_event_id: Option<RadrootsEventId>, + current_listing_event_id: Option<RadrootsEventId>, + last_source_event_seq: Option<i64>, +} + +async fn trade_projection_inputs_for_order_id( + store: &RadrootsEventStore, + order_id: &RadrootsOrderId, + limit: u32, +) -> Result<TradeProjectionInputs, RadrootsTradeProjectionError> { + let stored_events = store + .events_by_contract_and_tag( + &PRODUCT_PROJECTION_CONTRACT_IDS, + TAG_D, + order_id.as_str(), + limit, + ) + .await?; + let mut workflow_records = RadrootsTradeWorkflowRecords::default(); + let mut event_ids = Vec::with_capacity(stored_events.len()); + let mut expected_listing_event_id = None; + let mut listing_addr = None; + let mut last_source_event_seq = None; + + for stored_event in stored_events { + last_source_event_seq = Some(stored_event.seq); + let event = stored_event_to_nostr_event(&stored_event)?; + if stored_event.kind == KIND_TRADE_VALIDATION_RECEIPT { + let verified = validation_receipt_from_event(&event).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceipt { + event_id: stored_event.event_id.clone(), + source, + } + })?; + let receipt_order_id = RadrootsOrderId::parse(verified.tags.order_id.as_str()) + .map_err( + |source| RadrootsTradeProjectionError::ValidationReceiptOrderId { + event_id: stored_event.event_id.clone(), + source, + }, + )?; + let receipt_event_id = + RadrootsEventId::parse(stored_event.event_id.as_str()).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceiptEventId { + event_id: stored_event.event_id.clone(), + source, + } + })?; + event_ids.push(receipt_event_id.clone()); + workflow_records.validation_receipts.push( + RadrootsTradeWorkflowValidationReceiptRecord { + event_id: receipt_event_id, + order_id: receipt_order_id, + receipt: verified.receipt, + tags: verified.tags, + }, + ); + continue; + } + + let record = order_event_record_from_event(&event).map_err(|source| { + RadrootsTradeProjectionError::OrderDecode { + event_id: stored_event.event_id.clone(), + source, + } + })?; + event_ids.push(record.event_id().clone()); + if let RadrootsOrderEventRecord::Request(request) = &record { + listing_addr.get_or_insert_with(|| request.payload.listing_addr.clone()); + if expected_listing_event_id.is_none() { + expected_listing_event_id = request_listing_event_id(&event)?; + } + } + push_order_record(&mut workflow_records.order_events, record); + } + + workflow_records.expected_listing_event_id = expected_listing_event_id.clone(); + let current_listing_event_id = match listing_addr.as_ref() { + Some(listing_addr) => current_listing_event_id(store, listing_addr.as_str()).await?, + None => None, + }; + workflow_records.current_listing_event_id = current_listing_event_id.clone(); + + Ok(TradeProjectionInputs { + workflow_records, + event_ids, + expected_listing_event_id, + current_listing_event_id, + last_source_event_seq, + }) +} + +fn push_order_record( + records: &mut RadrootsGroupedOrderEventRecords, + record: RadrootsOrderEventRecord, +) { + match record { + RadrootsOrderEventRecord::Request(record) => records.requests.push(record), + RadrootsOrderEventRecord::Decision(record) => records.decisions.push(record), + RadrootsOrderEventRecord::RevisionProposal(record) => { + records.revision_proposals.push(record) + } + RadrootsOrderEventRecord::RevisionDecision(record) => { + records.revision_decisions.push(record) + } + RadrootsOrderEventRecord::Cancellation(record) => records.cancellations.push(record), + } +} + +fn request_listing_event_id( + event: &RadrootsNostrEvent, +) -> Result<Option<RadrootsEventId>, RadrootsTradeProjectionError> { + let context = + order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) + .map_err(|source| RadrootsTradeProjectionError::OrderContext { + event_id: event.id.clone(), + source, + })?; + context + .listing_event + .map(|listing_event| { + RadrootsEventId::parse(listing_event.id.as_str()).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceiptEventId { + event_id: event.id.clone(), + source, + } + }) + }) + .transpose() +} + +async fn current_listing_event_id( + store: &RadrootsEventStore, + listing_addr: &str, +) -> Result<Option<RadrootsEventId>, RadrootsTradeProjectionError> { + let row = sqlx::query("SELECT listing_event_id FROM listing_projection WHERE listing_addr = ?") + .bind(listing_addr) + .fetch_optional(store.pool()) + .await?; + row.map(|row| { + let value: String = row.try_get("listing_event_id")?; + RadrootsEventId::parse(value).map_err(|source| { + RadrootsTradeProjectionError::ValidationReceiptEventId { + event_id: listing_addr.to_owned(), + source, + } + }) + }) + .transpose() +} + +fn stored_event_to_nostr_event( + stored_event: &RadrootsStoredEvent, +) -> Result<RadrootsNostrEvent, RadrootsTradeProjectionError> { + let tags = serde_json::from_str(&stored_event.tags_json).map_err(|source| { + RadrootsTradeProjectionError::InvalidStoredTagsJson { + event_id: stored_event.event_id.clone(), + source, + } + })?; + Ok(RadrootsNostrEvent { + id: stored_event.event_id.clone(), + author: stored_event.pubkey.clone(), + created_at: stored_event.created_at, + kind: stored_event.kind, + tags, + content: stored_event.content.clone(), + sig: stored_event.sig.clone(), + }) +} + +async fn relay_observation_count_for_events( + store: &RadrootsEventStore, + event_ids: &[RadrootsEventId], +) -> Result<i64, RadrootsTradeProjectionError> { + let mut count = 0; + for event_id in event_ids { + count += relay_observation_count_for_event(store, event_id.as_str()).await?; + } + Ok(count) +} + +async fn relay_observation_count_for_event( + store: &RadrootsEventStore, + event_id: &str, +) -> Result<i64, RadrootsTradeProjectionError> { + let row = sqlx::query("SELECT COUNT(*) AS count FROM relay_event_seen WHERE event_id = ?") + .bind(event_id) + .fetch_one(store.pool()) + .await?; + Ok(row.try_get("count")?) +} + +fn listing_projection_row( + row: sqlx::sqlite::SqliteRow, +) -> Result<RadrootsListingProjectionRow, RadrootsTradeProjectionError> { + Ok(RadrootsListingProjectionRow { + listing_addr: row.try_get("listing_addr")?, + listing_event_id: row.try_get("listing_event_id")?, + seller_pubkey: row.try_get("seller_pubkey")?, + title: row.try_get("title")?, + description: row.try_get("description")?, + product_type: row.try_get("product_type")?, + price_amount: row.try_get("price_amount")?, + price_currency: row.try_get("price_currency")?, + inventory_available: row.try_get("inventory_available")?, + delivery_method: row.try_get("delivery_method")?, + locality_primary: row.try_get("locality_primary")?, + locality_city: row.try_get("locality_city")?, + locality_region: row.try_get("locality_region")?, + locality_country: row.try_get("locality_country")?, + geohash5: row.try_get("geohash5")?, + updated_at_ms: row.try_get("updated_at_ms")?, + }) +} + +fn listing_availability_label(availability: &RadrootsListingAvailability) -> String { + match availability { + RadrootsListingAvailability::Window { .. } => "window".to_owned(), + RadrootsListingAvailability::Status { status } => match status { + RadrootsListingStatus::Active => "active".to_owned(), + RadrootsListingStatus::Sold => "sold".to_owned(), + RadrootsListingStatus::Other { value } => value.trim().to_owned(), + }, + } +} + +fn listing_delivery_method_label(delivery_method: &RadrootsListingDeliveryMethod) -> String { + match delivery_method { + RadrootsListingDeliveryMethod::Pickup => "pickup".to_owned(), + RadrootsListingDeliveryMethod::LocalDelivery => "local_delivery".to_owned(), + RadrootsListingDeliveryMethod::Shipping => "shipping".to_owned(), + RadrootsListingDeliveryMethod::Other { method } => method.trim().to_owned(), + } +} + +fn listing_locality_search_text(listing: &RadrootsTradeListing) -> String { + [ + Some(listing.location.primary.as_str()), + listing.location.city.as_deref(), + listing.location.region.as_deref(), + listing.location.country.as_deref(), + ] + .into_iter() + .flatten() + .filter(|value| !value.trim().is_empty()) + .collect::<Vec<_>>() + .join(" ") +} + +fn listing_fts_query(query: &str) -> Option<String> { + let terms = query + .split(|character: char| !character.is_alphanumeric()) + .map(str::trim) + .filter(|term| !term.is_empty()) + .map(|term| format!("\"{}\"", term.replace('"', "\"\""))) + .collect::<Vec<_>>(); + if terms.is_empty() { + None + } else { + Some(terms.join(" ")) + } +} + +fn trade_workflow_status_label(status: &RadrootsTradeWorkflowState) -> &'static str { + match status { + RadrootsTradeWorkflowState::Missing => "missing", + RadrootsTradeWorkflowState::Requested => "requested", + RadrootsTradeWorkflowState::RevisionProposed => "revision_proposed", + RadrootsTradeWorkflowState::AgreedPendingRhi => "agreed_pending_rhi", + RadrootsTradeWorkflowState::Committed => "committed", + RadrootsTradeWorkflowState::Declined => "declined", + RadrootsTradeWorkflowState::Cancelled => "cancelled", + RadrootsTradeWorkflowState::Invalid => "invalid", + } +} + +fn trade_rhi_state_label( + status: &RadrootsTradeWorkflowState, + validation_receipt_event_id: Option<&RadrootsEventId>, +) -> &'static str { + match status { + RadrootsTradeWorkflowState::AgreedPendingRhi => "pending", + RadrootsTradeWorkflowState::Committed => "final", + RadrootsTradeWorkflowState::Invalid if validation_receipt_event_id.is_some() => "invalid", + RadrootsTradeWorkflowState::Invalid => "invalid", + _ => "not_required", + } +} + +fn bool_i64(value: bool) -> i64 { + if value { 1 } else { 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_event_store::{RadrootsEventIngest, RadrootsRelayObservation}; + use radroots_events::{ + RadrootsNostrEventPtr, + farm::RadrootsFarmRef, + ids::RadrootsOrderQuoteId, + kinds::KIND_LISTING, + listing::{ + RadrootsListing, RadrootsListingBin, RadrootsListingProduct, + RadrootsListingPublicLocation, + }, + order::{ + RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, + RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem, + RadrootsOrderPricingBasis, RadrootsOrderRequest, + }, + }; + use radroots_events_codec::order::{order_decision_event_build, order_request_event_build}; + use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, + radroots_event_from_nostr, radroots_nostr_build_event, + }; + + use crate::validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, + RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, + RadrootsValidationReceiptStatement, RadrootsValidationReceiptType, + validation_receipt_event_build, validation_receipt_public_values_hash_hex, + }; + + const SELLER_SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + const SELLER: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BUYER_SECRET: &str = "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; + const BUYER: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + fn keys(secret: &str) -> RadrootsNostrKeys { + RadrootsNostrKeys::new(RadrootsNostrSecretKey::from_hex(secret).expect("secret")) + } + + fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().expect("decimal") + } + + fn listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("d tag"), + published_at: Some(1_700_000_000), + farm: RadrootsFarmRef { + pubkey: SELLER.to_owned(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + }, + product: RadrootsListingProduct { + key: "pea-shoots".to_owned(), + title: "Pea shoots".to_owned(), + category: "greens".to_owned(), + summary: Some("Tender early greens".to_owned()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("bin"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin"), + quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new(decimal("5"), 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("9")), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingPublicLocation { + primary: "Old Town".to_owned(), + city: Some("Victoria".to_owned()), + region: Some("BC".to_owned()), + country: Some("CA".to_owned()), + geohash: "c2b2q".to_owned(), + }), + images: None, + } + } + + fn signed_listing_event() -> RadrootsNostrEvent { + let parts = radroots_events_codec::listing::encode::to_wire_parts(&listing()) + .expect("listing parts"); + sign_parts( + parts.kind, + parts.content, + parts.tags, + 1_700_000_000, + &keys(SELLER_SECRET), + ) + } + + fn listing_addr(event: &RadrootsNostrEvent) -> RadrootsListingAddress { + RadrootsListingAddress::parse(format!( + "{}:{}:{}", + KIND_LISTING, + event.author, + listing().d_tag + )) + .expect("listing address") + } + + fn order_id() -> RadrootsOrderId { + RadrootsOrderId::parse("projection-order").expect("order id") + } + + fn economics() -> RadrootsOrderEconomics { + let currency = RadrootsCoreCurrency::USD; + RadrootsOrderEconomics { + quote_id: RadrootsOrderQuoteId::parse("quote-1").expect("quote"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin"), + bin_count: 1, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: currency, + line_subtotal: RadrootsCoreMoney::new(decimal("5"), currency), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: RadrootsCoreMoney::new(decimal("5"), currency), + discount_total: RadrootsCoreMoney::zero(currency), + adjustment_total: RadrootsCoreMoney::zero(currency), + total: RadrootsCoreMoney::new(decimal("5"), currency), + } + } + + fn order_request(listing_event: &RadrootsNostrEvent) -> RadrootsOrderRequest { + RadrootsOrderRequest { + order_id: order_id(), + listing_addr: listing_addr(listing_event), + buyer_pubkey: BUYER.parse().expect("buyer"), + seller_pubkey: SELLER.parse().expect("seller"), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin"), + bin_count: 1, + }], + economics: economics(), + } + } + + fn signed_order_request_event(listing_event: &RadrootsNostrEvent) -> RadrootsNostrEvent { + let parts = order_request_event_build( + &RadrootsNostrEventPtr { + id: listing_event.id.clone(), + relays: Some("wss://relay.example.test".to_owned()), + }, + &order_request(listing_event), + ) + .expect("request parts"); + sign_parts( + parts.kind, + parts.content, + parts.tags, + 1_700_000_010, + &keys(BUYER_SECRET), + ) + } + + fn signed_order_decision_event( + request: &RadrootsNostrEvent, + listing_event: &RadrootsNostrEvent, + ) -> RadrootsNostrEvent { + let decision = RadrootsOrderDecision { + order_id: order_id(), + listing_addr: listing_addr(listing_event), + buyer_pubkey: BUYER.parse().expect("buyer"), + seller_pubkey: SELLER.parse().expect("seller"), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin"), + bin_count: 1, + }], + }, + }; + let root = RadrootsEventId::parse(request.id.as_str()).expect("root"); + let parts = order_decision_event_build(&root, &root, &decision).expect("decision parts"); + sign_parts( + parts.kind, + parts.content, + parts.tags, + 1_700_000_020, + &keys(SELLER_SECRET), + ) + } + + fn signed_receipt_event( + listing_event: &RadrootsNostrEvent, + request: &RadrootsNostrEvent, + decision: &RadrootsNostrEvent, + result: RadrootsValidationReceiptResult, + ) -> RadrootsNostrEvent { + let request_id = RadrootsEventId::parse(request.id.as_str()).expect("request"); + let listing_event_id = RadrootsEventId::parse(listing_event.id.as_str()).expect("listing"); + let decision_id = RadrootsEventId::parse(decision.id.as_str()).expect("decision"); + let receipt = RadrootsTradeValidationReceipt { + changed_records_root: hash32('a'), + domain: "radroots.receipt".to_owned(), + error_bitmap: match result { + RadrootsValidationReceiptResult::Valid => { + "0x00000000000000000000000000000000".to_owned() + } + RadrootsValidationReceiptResult::Invalid => { + "0x00000000000000000000000000000001".to_owned() + } + }, + event_set_root: hash32('b'), + new_state_root: hash32('c'), + previous_state_root: hash32('d'), + proof: RadrootsValidationReceiptProof { + inline_proof_base64: None, + mode: None, + program_hash: None, + proof_reference: None, + system: RadrootsValidationReceiptProofSystem::None, + verifying_key_hash: None, + }, + public_values_hash: validation_receipt_public_values_hash_hex( + format!("{}:{}", request_id.as_str(), decision_id.as_str()).as_bytes(), + ), + receipt_type: RadrootsValidationReceiptType::TradeTransition, + result, + statement: RadrootsValidationReceiptStatement { + listing_event_id: listing_event_id.into_string(), + root_event_id: request_id.into_string(), + target_event_id: decision_id.into_string(), + statement_type: RadrootsValidationReceiptType::TradeTransition, + }, + version: 1, + }; + let parts = validation_receipt_event_build(order_id().as_str(), &receipt).expect("receipt"); + sign_parts( + parts.kind, + parts.content, + parts.tags, + 1_700_000_030, + &keys(SELLER_SECRET), + ) + } + + fn sign_parts( + kind: u32, + content: String, + tags: Vec<Vec<String>>, + created_at: u32, + keys: &RadrootsNostrKeys, + ) -> RadrootsNostrEvent { + let raw_event = radroots_nostr_build_event(kind, content, tags) + .expect("builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) + .sign_with_keys(keys) + .expect("signed"); + radroots_event_from_nostr(&raw_event) + } + + fn hash32(character: char) -> String { + format!( + "0x{}", + core::iter::repeat_n(character, 64).collect::<String>() + ) + } + + #[tokio::test] + async fn refresh_materializes_listing_search_and_receipt_aware_trade_projection() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let listing_event = signed_listing_event(); + let request_event = signed_order_request_event(&listing_event); + let decision_event = signed_order_decision_event(&request_event, &listing_event); + let receipt_event = signed_receipt_event( + &listing_event, + &request_event, + &decision_event, + RadrootsValidationReceiptResult::Valid, + ); + + store + .ingest_event(RadrootsEventIngest::new(listing_event.clone(), 10)) + .await + .expect("listing"); + store + .ingest_event( + RadrootsEventIngest::new(request_event.clone(), 20).with_observation( + RadrootsRelayObservation::new( + "wss://relay.example.test", + radroots_event_store::RadrootsRelayObservationType::Import, + 20, + ), + ), + ) + .await + .expect("request"); + store + .ingest_event(RadrootsEventIngest::new(decision_event.clone(), 30)) + .await + .expect("decision"); + store + .ingest_event(RadrootsEventIngest::new(receipt_event.clone(), 40)) + .await + .expect("receipt"); + + let refresh = + refresh_product_projections(&store, RadrootsProjectionRefreshRequest::new(), 50) + .await + .expect("refresh"); + assert_eq!(refresh.scanned_events, 4); + assert_eq!(refresh.listing_upserts, 1); + assert_eq!(refresh.trade_upserts, 1); + assert_eq!(refresh.validation_receipts, 1); + assert_eq!(refresh.relay_observations, 1); + + let rows = search_listing_projection( + &store, + &RadrootsListingSearchRequest::new("pea victoria").with_limit(10), + ) + .await + .expect("search"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].title, "Pea shoots"); + assert_eq!(rows[0].geohash5, "c2b2q"); + + let status = trade_projection_query_for_order_id(&store, &order_id(), 100) + .await + .expect("status"); + assert_eq!( + status.projection.status, + RadrootsTradeWorkflowState::Committed + ); + assert_eq!( + status.projection.validation_receipt_event_id, + Some(RadrootsEventId::parse(receipt_event.id).expect("receipt")) + ); + + let trade_row = sqlx::query( + "SELECT status, rhi_state, relay_observation_count FROM trade_projection WHERE order_id = ?", + ) + .bind(order_id().as_str()) + .fetch_one(store.pool()) + .await + .expect("trade row"); + assert_eq!( + trade_row.try_get::<String, _>("status").unwrap(), + "committed" + ); + assert_eq!( + trade_row.try_get::<String, _>("rhi_state").unwrap(), + "final" + ); + assert_eq!( + trade_row + .try_get::<i64, _>("relay_observation_count") + .unwrap(), + 1 + ); + } + + #[tokio::test] + async fn refresh_rejects_out_of_range_limits_without_advancing_cursor() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let error = refresh_product_projections( + &store, + RadrootsProjectionRefreshRequest::new().with_limit(0), + 1, + ) + .await + .expect_err("limit"); + assert!(matches!( + error, + RadrootsTradeProjectionError::InvalidLimit { .. } + )); + assert!( + store + .get_projection_cursor(RADROOTS_PRODUCT_PROJECTION_ID) + .await + .expect("cursor") + .is_none() + ); + } +}