app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 3bf52ddbe2a0136790dbe20e1ff5dfbc7816e6cf
parent 6e2f0527fcb4143eb736d423c263c8fc5a38de6d
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 23:42:57 +0000

sync: add typed app publish work

Diffstat:
MCargo.lock | 41+++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Mcrates/shared/sync/Cargo.toml | 2++
Mcrates/shared/sync/src/lib.rs | 9+++++++++
Acrates/shared/sync/src/publish.rs | 474+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 527 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5159,7 +5159,9 @@ name = "radroots_app_sync" version = "0.1.0" dependencies = [ "radroots_app_models", + "radroots_sdk", "serde", + "serde_json", "thiserror 2.0.18", ] @@ -5191,6 +5193,17 @@ dependencies = [ ] [[package]] +name = "radroots_events_codec" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "radroots_core", + "radroots_events", + "serde", + "serde_json", +] + +[[package]] name = "radroots_identity" version = "0.1.0-alpha.2" dependencies = [ @@ -5326,6 +5339,19 @@ dependencies = [ ] [[package]] +name = "radroots_sdk" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_events", + "radroots_events_codec", + "radroots_identity", + "radroots_nostr", + "radroots_trade", + "serde", + "serde_json", +] + +[[package]] name = "radroots_secret_vault" version = "0.1.0-alpha.2" @@ -5342,6 +5368,21 @@ dependencies = [ ] [[package]] +name = "radroots_trade" +version = "0.1.0-alpha.2" +dependencies = [ + "base64 0.22.1", + "hex", + "radroots_core", + "radroots_events", + "radroots_events_codec", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", +] + +[[package]] name = "rand" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -36,6 +36,7 @@ radroots_nostr_connect = { path = "../lib/crates/nostr_connect" } radroots_protected_store = { path = "../lib/crates/protected_store" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } radroots_secret_vault = { path = "../lib/crates/secret_vault", default-features = false, features = ["std"] } +radroots_sdk = { path = "../lib/crates/sdk", features = ["relay-client", "signing"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } radroots_app_core = { path = "crates/shared/core", version = "0.1.0" } radroots_app_i18n = { path = "crates/shared/i18n", version = "0.1.0" } diff --git a/crates/shared/sync/Cargo.toml b/crates/shared/sync/Cargo.toml @@ -9,7 +9,9 @@ publish = false [dependencies] radroots_app_models.workspace = true +radroots_sdk.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true [lints] diff --git a/crates/shared/sync/src/lib.rs b/crates/shared/sync/src/lib.rs @@ -1,5 +1,14 @@ #![forbid(unsafe_code)] +mod publish; + +pub use publish::{ + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload, + AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet, + AppPublishWorkKind, +}; + use radroots_app_models::{FarmId, FulfillmentWindowId, OrderId, ProductId}; use serde::{Deserialize, Serialize}; use thiserror::Error; diff --git a/crates/shared/sync/src/publish.rs b/crates/shared/sync/src/publish.rs @@ -0,0 +1,474 @@ +use radroots_app_models::{ + FarmId, FarmReadiness, FulfillmentWindowId, OrderId, ProductId, ProductStatus, +}; +use radroots_sdk::SdkTransportMode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{PendingSyncOperation, SyncAggregateRef, SyncOperationKind}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppPublishWorkKind { + FarmProfile, + Listing, + OrderRequest, +} + +impl AppPublishWorkKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::FarmProfile => "farm_profile", + Self::Listing => "listing", + Self::OrderRequest => "order_request", + } + } + + pub const fn sdk_operation(self) -> &'static str { + match self { + Self::FarmProfile => "farm.publish_draft_with_identity", + Self::Listing => "listing.publish_draft_with_identity", + Self::OrderRequest => "trade.publish_order_request_with_identity", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppPublishContext { + pub account_id: String, + pub source: String, + pub source_local_event_id: Option<String>, +} + +impl AppPublishContext { + pub fn new(account_id: impl Into<String>, source: impl Into<String>) -> Self { + Self { + account_id: account_id.into(), + source: source.into(), + source_local_event_id: None, + } + } + + pub fn with_source_local_event_id(mut self, source_local_event_id: impl Into<String>) -> Self { + self.source_local_event_id = Some(source_local_event_id.into()); + self + } + + fn validation_failures(&self, failures: &mut Vec<AppPublishValidationFailure>) { + if self.account_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingAccountId); + } + + if self.source.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingSource); + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppFarmProfilePublishPayload { + pub context: AppPublishContext, + pub farm_id: FarmId, + pub display_name: String, + pub readiness: Option<FarmReadiness>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppListingPublishPayload { + pub context: AppPublishContext, + pub product_id: ProductId, + pub farm_id: Option<FarmId>, + pub title: String, + pub subtitle: Option<String>, + pub unit_label: String, + pub price_minor_units: Option<u32>, + pub price_currency: String, + pub stock_quantity: Option<u32>, + pub availability_window_id: Option<FulfillmentWindowId>, + pub status: ProductStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderRequestItemPayload { + pub product_id: ProductId, + pub quantity: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderRequestPublishPayload { + pub context: AppPublishContext, + pub order_id: OrderId, + pub farm_id: FarmId, + pub status: Option<String>, + pub listing_addr: Option<String>, + pub listing_event_id: Option<String>, + pub listing_relays: Vec<String>, + pub buyer_pubkey: Option<String>, + pub seller_pubkey: Option<String>, + pub items: Vec<AppOrderRequestItemPayload>, + pub currency_code: Option<String>, + pub total_minor_units: Option<u32>, + pub note: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "publish_kind", content = "payload", rename_all = "snake_case")] +pub enum AppPublishPayload { + FarmProfile(AppFarmProfilePublishPayload), + Listing(AppListingPublishPayload), + OrderRequest(AppOrderRequestPublishPayload), +} + +impl AppPublishPayload { + pub const fn work_kind(&self) -> AppPublishWorkKind { + match self { + Self::FarmProfile(_) => AppPublishWorkKind::FarmProfile, + Self::Listing(_) => AppPublishWorkKind::Listing, + Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest, + } + } + + pub const fn sdk_transport_mode(&self) -> SdkTransportMode { + SdkTransportMode::RelayDirect + } + + pub const fn operation_kind(&self) -> SyncOperationKind { + SyncOperationKind::Upsert + } + + pub fn aggregate_ref(&self) -> SyncAggregateRef { + match self { + Self::FarmProfile(payload) => SyncAggregateRef::Farm(payload.farm_id), + Self::Listing(payload) => SyncAggregateRef::Product(payload.product_id), + Self::OrderRequest(payload) => SyncAggregateRef::Order(payload.order_id), + } + } + + pub fn validation_failures(&self) -> Vec<AppPublishValidationFailure> { + let mut failures = Vec::new(); + + match self { + Self::FarmProfile(payload) => { + payload.context.validation_failures(&mut failures); + if payload.display_name.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingFarmDisplayName); + } + } + Self::Listing(payload) => { + payload.context.validation_failures(&mut failures); + if payload.farm_id.is_none() { + failures.push(AppPublishValidationFailure::MissingListingFarmId); + } + if payload.title.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingListingTitle); + } + if payload.unit_label.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingListingUnit); + } + if payload.price_minor_units.is_none_or(|value| value == 0) { + failures.push(AppPublishValidationFailure::MissingListingPrice); + } + if payload.price_currency.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingListingCurrency); + } + if payload.availability_window_id.is_none() { + failures.push(AppPublishValidationFailure::MissingListingAvailability); + } + } + Self::OrderRequest(payload) => { + payload.context.validation_failures(&mut failures); + if payload + .listing_addr + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderListingAddress); + } + if payload + .listing_event_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderListingEventId); + } + if payload + .listing_relays + .iter() + .all(|relay| relay.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderListingRelay); + } + if payload + .buyer_pubkey + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey); + } + if payload + .seller_pubkey + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey); + } + if payload.items.is_empty() || payload.items.iter().any(|item| item.quantity == 0) { + failures.push(AppPublishValidationFailure::MissingOrderItems); + } + if payload + .currency_code + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderCurrency); + } + if payload.total_minor_units.is_none() { + failures.push(AppPublishValidationFailure::MissingOrderTotal); + } + } + } + + failures + } + + pub fn validate(&self) -> Result<(), AppPublishValidationFailureSet> { + let reason_codes = self.validation_failures(); + if reason_codes.is_empty() { + Ok(()) + } else { + Err(AppPublishValidationFailureSet { reason_codes }) + } + } + + pub fn to_payload_json(&self) -> Result<String, AppPublishPayloadJsonError> { + serde_json::to_string(self).map_err(|source| AppPublishPayloadJsonError::Serialize { + message: source.to_string(), + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppPublishValidationFailure { + MissingAccountId, + MissingSource, + MissingFarmDisplayName, + MissingListingFarmId, + MissingListingTitle, + MissingListingUnit, + MissingListingPrice, + MissingListingCurrency, + MissingListingAvailability, + MissingOrderListingAddress, + MissingOrderListingEventId, + MissingOrderListingRelay, + MissingOrderBuyerPubkey, + MissingOrderSellerPubkey, + MissingOrderItems, + MissingOrderCurrency, + MissingOrderTotal, +} + +impl AppPublishValidationFailure { + pub const fn storage_key(self) -> &'static str { + match self { + Self::MissingAccountId => "missing_account_id", + Self::MissingSource => "missing_source", + Self::MissingFarmDisplayName => "missing_farm_display_name", + Self::MissingListingFarmId => "missing_listing_farm_id", + Self::MissingListingTitle => "missing_listing_title", + Self::MissingListingUnit => "missing_listing_unit", + Self::MissingListingPrice => "missing_listing_price", + Self::MissingListingCurrency => "missing_listing_currency", + Self::MissingListingAvailability => "missing_listing_availability", + Self::MissingOrderListingAddress => "missing_order_listing_address", + Self::MissingOrderListingEventId => "missing_order_listing_event_id", + Self::MissingOrderListingRelay => "missing_order_listing_relay", + Self::MissingOrderBuyerPubkey => "missing_order_buyer_pubkey", + Self::MissingOrderSellerPubkey => "missing_order_seller_pubkey", + Self::MissingOrderItems => "missing_order_items", + Self::MissingOrderCurrency => "missing_order_currency", + Self::MissingOrderTotal => "missing_order_total", + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("app publish payload is invalid: {reason_codes:?}")] +pub struct AppPublishValidationFailureSet { + pub reason_codes: Vec<AppPublishValidationFailure>, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum AppPublishPayloadJsonError { + #[error("app publish payload serialization failed: {message}")] + Serialize { message: String }, + #[error("app publish payload json is invalid: {message}")] + Deserialize { message: String }, +} + +impl PendingSyncOperation { + pub fn from_publish_payload( + payload: AppPublishPayload, + created_at: impl Into<String>, + ) -> Result<Self, AppPublishPayloadJsonError> { + let created_at = created_at.into(); + Ok(Self { + aggregate: payload.aggregate_ref(), + operation: payload.operation_kind(), + payload_json: payload.to_payload_json()?, + created_at: created_at.clone(), + available_at: created_at, + attempt_count: 0, + }) + } + + pub fn publish_payload(&self) -> Result<AppPublishPayload, AppPublishPayloadJsonError> { + serde_json::from_str(self.payload_json.as_str()).map_err(|source| { + AppPublishPayloadJsonError::Deserialize { + message: source.to_string(), + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderRequestItemPayload, + AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishValidationFailure, + }; + use crate::{PendingSyncOperation, SyncAggregateRef, SyncOperationKind}; + use radroots_app_models::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus}; + + #[test] + fn publish_payload_serializes_with_stable_kind_and_sdk_target() { + let farm_id = FarmId::new(); + let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { + context: AppPublishContext::new("acct_local", "farm_setup") + .with_source_local_event_id("local-event-1"), + farm_id, + display_name: "North Farm".to_owned(), + readiness: Some(FarmReadiness::Ready), + }); + + assert_eq!(payload.work_kind().storage_key(), "farm_profile"); + assert_eq!( + payload.work_kind().sdk_operation(), + "farm.publish_draft_with_identity" + ); + assert_eq!( + payload.sdk_transport_mode(), + radroots_sdk::SdkTransportMode::RelayDirect + ); + assert_eq!(payload.validation_failures(), Vec::new()); + + let operation = + PendingSyncOperation::from_publish_payload(payload.clone(), "2026-04-20T18:00:00Z") + .expect("typed publish payload should serialize"); + + assert_eq!(operation.aggregate, SyncAggregateRef::Farm(farm_id)); + assert_eq!(operation.operation, SyncOperationKind::Upsert); + assert_eq!(operation.created_at, operation.available_at); + assert_eq!( + operation.publish_payload().expect("payload should parse"), + payload + ); + } + + #[test] + fn listing_publish_payload_reports_stable_validation_reason_codes() { + let payload = AppPublishPayload::Listing(AppListingPublishPayload { + context: AppPublishContext::new("", ""), + product_id: ProductId::new(), + farm_id: None, + title: " ".to_owned(), + subtitle: None, + unit_label: String::new(), + price_minor_units: Some(0), + price_currency: String::new(), + stock_quantity: Some(4), + availability_window_id: None, + status: ProductStatus::Published, + }); + + let reason_codes: Vec<&str> = payload + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + + assert_eq!( + reason_codes, + vec![ + "missing_account_id", + "missing_source", + "missing_listing_farm_id", + "missing_listing_title", + "missing_listing_unit", + "missing_listing_price", + "missing_listing_currency", + "missing_listing_availability", + ] + ); + assert!(payload.validate().is_err()); + } + + #[test] + fn order_request_publish_payload_requires_sdk_publish_inputs() { + let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { + context: AppPublishContext::new("acct_buyer", "place_personal_order"), + order_id: OrderId::new(), + farm_id: FarmId::new(), + status: Some("needs_action".to_owned()), + listing_addr: Some(String::new()), + listing_event_id: None, + listing_relays: vec![], + buyer_pubkey: None, + seller_pubkey: Some(" ".to_owned()), + items: vec![AppOrderRequestItemPayload { + product_id: ProductId::new(), + quantity: 0, + }], + currency_code: None, + total_minor_units: None, + note: None, + }); + + let reason_codes: Vec<&str> = payload + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + + assert_eq!( + reason_codes, + vec![ + "missing_order_listing_address", + "missing_order_listing_event_id", + "missing_order_listing_relay", + "missing_order_buyer_pubkey", + "missing_order_seller_pubkey", + "missing_order_items", + "missing_order_currency", + "missing_order_total", + ] + ); + } + + #[test] + fn existing_raw_payload_outbox_work_remains_local_save_compatible() { + let pending_operation = PendingSyncOperation { + aggregate: SyncAggregateRef::Product(ProductId::new()), + operation: SyncOperationKind::Upsert, + payload_json: "{\"title\":\"greens\"}".to_owned(), + created_at: "2026-04-17T19:32:00Z".to_owned(), + available_at: "2026-04-17T19:32:00Z".to_owned(), + attempt_count: 0, + }; + + assert!(!pending_operation.is_retry()); + assert!(pending_operation.publish_payload().is_err()); + } +}