commit 6af9759b576f24f8e6d3a7d3022f2281d01bf54a
parent 5d41aecebd2dd8faa18cc0c7a87f9ad00e67f921
Author: triesap <tyson@radroots.org>
Date: Tue, 5 May 2026 17:49:57 +0000
replica: allow fractional listing economics
- make trade product display amounts decimal-friendly
- preserve fractional listing exact values during ingest
- add fractional replica ingest coverage
- keep exact economics strings as signed order source
Diffstat:
3 files changed, 75 insertions(+), 17 deletions(-)
diff --git a/crates/replica_db/src/query.rs b/crates/replica_db/src/query.rs
@@ -11,7 +11,7 @@ pub struct ReplicaTradeProductSummaryRow {
pub category: String,
pub title: String,
pub summary: String,
- pub qty_amt: i64,
+ pub qty_amt: f64,
pub qty_amt_exact: Option<String>,
pub qty_unit: String,
pub qty_label: Option<String>,
@@ -19,7 +19,7 @@ pub struct ReplicaTradeProductSummaryRow {
pub price_amt: f64,
pub price_amt_exact: Option<String>,
pub price_currency: String,
- pub price_qty_amt: u32,
+ pub price_qty_amt: f64,
pub price_qty_amt_exact: Option<String>,
pub price_qty_unit: String,
pub listing_addr: Option<String>,
diff --git a/crates/replica_db_schema/src/models/trade_product.rs b/crates/replica_db_schema/src/models/trade_product.rs
@@ -18,7 +18,7 @@ pub struct TradeProduct {
pub lot: String,
pub profile: String,
pub year: i64,
- pub qty_amt: i64,
+ pub qty_amt: f64,
pub qty_amt_exact: Option<String>,
pub qty_unit: String,
pub qty_label: Option<String>,
@@ -26,7 +26,7 @@ pub struct TradeProduct {
pub price_amt: f64,
pub price_amt_exact: Option<String>,
pub price_currency: String,
- pub price_qty_amt: u32,
+ pub price_qty_amt: f64,
pub price_qty_amt_exact: Option<String>,
pub price_qty_unit: String,
pub listing_addr: Option<String>,
@@ -45,7 +45,7 @@ pub struct ITradeProductFields {
pub lot: String,
pub profile: String,
pub year: i64,
- pub qty_amt: i64,
+ pub qty_amt: f64,
pub qty_amt_exact: String,
pub qty_unit: String,
#[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
@@ -55,7 +55,7 @@ pub struct ITradeProductFields {
pub price_amt: f64,
pub price_amt_exact: String,
pub price_currency: String,
- pub price_qty_amt: u32,
+ pub price_qty_amt: f64,
pub price_qty_amt_exact: String,
pub price_qty_unit: String,
#[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
@@ -141,7 +141,7 @@ pub struct ITradeProductFieldsFilter {
#[cfg_attr(feature = "ts-rs", ts(optional))]
pub year: Option<i64>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
- pub qty_amt: Option<i64>,
+ pub qty_amt: Option<f64>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
pub qty_amt_exact: Option<String>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
@@ -157,7 +157,7 @@ pub struct ITradeProductFieldsFilter {
#[cfg_attr(feature = "ts-rs", ts(optional))]
pub price_currency: Option<String>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
- pub price_qty_amt: Option<u32>,
+ pub price_qty_amt: Option<f64>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
pub price_qty_amt_exact: Option<String>,
#[cfg_attr(feature = "ts-rs", ts(optional))]
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -543,7 +543,7 @@ fn trade_product_fields_from_listing(
listing_addr: &str,
) -> Result<ITradeProductFields, RadrootsReplicaEventsError> {
let bin = primary_listing_bin(listing)?;
- let qty_amt = decimal_to_i64(&bin.quantity.amount, "listing primary bin quantity")?;
+ let qty_amt = decimal_to_f64(&bin.quantity.amount, "listing primary bin quantity")?;
let qty_amt_exact = bin.quantity.amount.to_string();
let qty_avail = listing
.inventory_available
@@ -560,9 +560,9 @@ fn trade_product_fields_from_listing(
let price_amt_exact = price_source.amount.to_string();
let price_currency = price_source.currency.as_str().to_string();
let price_qty_amt = if bin.display_price.is_some() {
- 1
+ 1.0
} else {
- decimal_to_u32(
+ decimal_to_f64(
&bin.price_per_canonical_unit.quantity.amount,
"listing price quantity",
)?
@@ -653,13 +653,13 @@ fn decimal_to_i64(
.map_err(|_| RadrootsReplicaEventsError::InvalidData(format!("{field} exceeds i64 range")))
}
-fn decimal_to_u32(
+fn decimal_to_f64(
value: &RadrootsCoreDecimal,
field: &str,
-) -> Result<u32, RadrootsReplicaEventsError> {
- let value = decimal_to_u64(value, field)?;
- u32::try_from(value)
- .map_err(|_| RadrootsReplicaEventsError::InvalidData(format!("{field} exceeds u32 range")))
+) -> Result<f64, RadrootsReplicaEventsError> {
+ value.to_f64_lossy().ok_or_else(|| {
+ RadrootsReplicaEventsError::InvalidData(format!("{field} exceeds f64 range"))
+ })
}
fn decimal_to_u64(
@@ -2357,12 +2357,13 @@ mod tests {
);
assert_eq!(search_rows[0].title, "Pasture Eggs");
assert_eq!(search_rows[0].primary_bin_id.as_deref(), Some("bin-a"));
- assert_eq!(search_rows[0].qty_amt, 12);
+ assert_eq!(search_rows[0].qty_amt, 12.0);
assert_eq!(search_rows[0].qty_amt_exact.as_deref(), Some("12"));
assert_eq!(search_rows[0].qty_avail, Some(5));
assert_eq!(search_rows[0].price_amt, 6.0);
assert_eq!(search_rows[0].price_amt_exact.as_deref(), Some("6"));
assert_eq!(search_rows[0].price_currency, "USD");
+ assert_eq!(search_rows[0].price_qty_amt, 1.0);
assert_eq!(search_rows[0].price_qty_amt_exact.as_deref(), Some("1"));
assert!(
search_rows[0]
@@ -2459,6 +2460,63 @@ mod tests {
}
#[test]
+ fn ingest_listing_preserves_fractional_exact_economics() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let seller_pubkey = "s".repeat(64);
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAg";
+ let listing_addr = format!("{}:{}:{}", KIND_LISTING, seller_pubkey, listing_d_tag);
+
+ let mut active = listing_event(
+ 600,
+ &seller_pubkey,
+ 10,
+ listing_d_tag,
+ "active",
+ "Half Gram Greens",
+ );
+ for tag in &mut active.tags {
+ if tag.first().is_some_and(|name| name == "radroots:bin") {
+ tag[2] = "0.5".to_string();
+ tag[3] = "g".to_string();
+ tag[4] = "0.5".to_string();
+ tag[5] = "g".to_string();
+ tag[6] = "half gram".to_string();
+ }
+ if tag.first().is_some_and(|name| name == "radroots:price") {
+ tag[2] = "3.25".to_string();
+ tag[3] = "USD".to_string();
+ tag[4] = "1".to_string();
+ tag[5] = "g".to_string();
+ tag[6] = "3.25".to_string();
+ tag[7] = "g".to_string();
+ }
+ }
+
+ assert_eq!(
+ radroots_replica_ingest_event(&exec, &active).expect("fractional active ingest"),
+ RadrootsReplicaIngestOutcome::Applied
+ );
+
+ let replica = ReplicaSql::new(&exec);
+ let search_rows = replica
+ .trade_product_search(&["greens".to_string()])
+ .expect("search");
+ assert_eq!(search_rows.len(), 1);
+ assert_eq!(
+ search_rows[0].listing_addr.as_deref(),
+ Some(listing_addr.as_str())
+ );
+ assert_eq!(search_rows[0].qty_amt, 0.5);
+ assert_eq!(search_rows[0].qty_amt_exact.as_deref(), Some("0.5"));
+ assert_eq!(search_rows[0].price_amt, 3.25);
+ assert_eq!(search_rows[0].price_amt_exact.as_deref(), Some("3.25"));
+ assert_eq!(search_rows[0].price_qty_amt, 1.0);
+ assert_eq!(search_rows[0].price_qty_amt_exact.as_deref(), Some("1"));
+ }
+
+ #[test]
fn upsert_location_none_paths_are_ok() {
let exec = SqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");