cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 453e6ec28c8952c65202be67e80182af58b943ec
parent 1c16fd4022875fa757f47ffad5c80a25b6047f55
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 23:50:34 +0000

cli: adopt verified event contracts

- migrate listing workflows to classified Operational Listing types
- delegate unsigned listing semantics to the shared validator
- expose valid-stream counts without conflating raw events
- handle fallible conversion and replica outcomes explicitly

Diffstat:
MCargo.lock | 2+-
Msrc/runtime/farm.rs | 4++--
Msrc/runtime/farm_config.rs | 24+++++++++++++++---------
Msrc/runtime/listing.rs | 194+++++++++++++++++++++++++++++++++++++------------------------------------------
Msrc/runtime/sdk.rs | 1+
Msrc/runtime/signer.rs | 20++++++++++----------
Msrc/runtime/store.rs | 2+-
Msrc/runtime/sync.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/runtime/trade.rs | 14++++----------
Msrc/view/runtime.rs | 46++++++++++++++++++++++++++++++++++------------
10 files changed, 234 insertions(+), 187 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2113,7 +2113,7 @@ version = "1.0.0-alpha.1" dependencies = [ "hex", "radroots_event", - "radroots_nostr", + "radroots_event_codec", "radroots_transport", "serde", "serde_json", diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -6,7 +6,7 @@ use radroots_event::contract::RadrootsActorRole; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation}; use radroots_event::ids::RadrootsAddressableCoordinate; use radroots_event::kinds::{KIND_FARM, KIND_PROFILE}; -use radroots_event::listing::RadrootsListingPublicLocation; +use radroots_event::operational_listing::RadrootsOperationalListingPublicLocation; use radroots_event::profile::{RadrootsAuthoredProfile, RadrootsNip05Identifier}; use radroots_event_codec::d_tag::is_d_tag_base64url; use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; @@ -1781,7 +1781,7 @@ fn init_document( }, listing_defaults: FarmListingDefaults { delivery_method, - location: RadrootsListingPublicLocation { + location: RadrootsOperationalListingPublicLocation { primary: location_primary, city, region, diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs @@ -2,7 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; use radroots_event::farm::RadrootsFarm; -use radroots_event::listing::{RadrootsListingDeliveryMethod, RadrootsListingPublicLocation}; +use radroots_event::operational_listing::{ + RadrootsOperationalListingDeliveryMethod, RadrootsOperationalListingPublicLocation, +}; use radroots_event_codec::d_tag::is_d_tag_base64url; use serde::{Deserialize, Serialize}; @@ -72,11 +74,13 @@ pub struct FarmConfigSelection { #[serde(deny_unknown_fields)] pub struct FarmListingDefaults { pub delivery_method: String, - pub location: RadrootsListingPublicLocation, + pub location: RadrootsOperationalListingPublicLocation, } impl FarmListingDefaults { - pub fn delivery_method_model(&self) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { + pub fn delivery_method_model( + &self, + ) -> Result<RadrootsOperationalListingDeliveryMethod, RuntimeError> { parse_delivery_method(self.delivery_method.as_str()) } } @@ -371,7 +375,9 @@ fn location_geohash(document: &FarmConfigDocument) -> Option<&str> { }) } -fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { +fn parse_delivery_method( + value: &str, +) -> Result<RadrootsOperationalListingDeliveryMethod, RuntimeError> { let method = trimmed(value); if method.is_empty() { return Err(RuntimeError::Config( @@ -379,10 +385,10 @@ fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, R )); } Ok(match method { - "pickup" => RadrootsListingDeliveryMethod::Pickup, - "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, - "shipping" => RadrootsListingDeliveryMethod::Shipping, - other => RadrootsListingDeliveryMethod::Other { + "pickup" => RadrootsOperationalListingDeliveryMethod::Pickup, + "local_delivery" => RadrootsOperationalListingDeliveryMethod::LocalDelivery, + "shipping" => RadrootsOperationalListingDeliveryMethod::Shipping, + other => RadrootsOperationalListingDeliveryMethod::Other { method: other.to_owned(), }, }) @@ -474,7 +480,7 @@ mod tests { }, listing_defaults: FarmListingDefaults { delivery_method: "pickup".to_owned(), - location: RadrootsListingPublicLocation { + location: RadrootsOperationalListingPublicLocation { primary: "San Francisco, CA".to_owned(), city: Some("San Francisco".to_owned()), region: Some("CA".to_owned()), diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -12,17 +12,17 @@ use radroots_core::{ }; use radroots_event::contract::RadrootsActorRole; use radroots_event::farm::RadrootsFarmRef; -use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use radroots_event::kinds::KIND_LISTING; -use radroots_event::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, - RadrootsListingStatus, +use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsPublicKey}; +use radroots_event::kinds::KIND_CLASSIFIED_LISTING; +use radroots_event::operational_listing::{ + RadrootsOperationalListing, RadrootsOperationalListingAvailability, + RadrootsOperationalListingBin, RadrootsOperationalListingDeliveryMethod, + RadrootsOperationalListingProduct, RadrootsOperationalListingPublicLocation, + RadrootsOperationalListingStatus, }; -use radroots_event::trade_validation::RadrootsTradeValidationListingError; -use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts}; +use radroots_event::trade_validation::RadrootsOperationalListingValidationError; use radroots_event_codec::d_tag::is_d_tag_base64url; -use radroots_event_codec::listing::encode::to_wire_parts_with_kind; +use radroots_event_codec::operational_listing::encode::to_wire_parts_with_kind; use radroots_replica_store::ReplicaSql; use radroots_runtime_store::{RuntimeStoreRecord, RuntimeStoreRecordFamily, SourceRuntime}; use radroots_sdk::{ @@ -31,7 +31,9 @@ use radroots_sdk::{ PushOutboxRequest, PushOutboxTargetOutcomeKind, SdkMutationState, }; use radroots_sql_core::SqlxSqliteExecutor; -use radroots_trade::listing::{RadrootsListingEditDocumentV1, validation::validate_listing_event}; +use radroots_trade::operational_listing::{ + RadrootsOperationalListingEditDocumentV1, validate_operational_listing_model, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -230,14 +232,14 @@ struct CanonicalListingDraft { seller_pubkey: String, seller_actor_source: String, farm_d_tag: String, - listing: RadrootsListing, + listing: RadrootsOperationalListing, } #[derive(Debug, Clone)] struct SdkListingPublishInput { canonical: CanonicalListingDraft, actor: RadrootsActorContext, - document: RadrootsListingEditDocumentV1, + document: RadrootsOperationalListingEditDocumentV1, } #[derive(Debug, Clone)] @@ -576,8 +578,8 @@ pub fn validate( match canonicalize_draft(&parsed, &contents, &context) { Ok(canonical) => { - let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) { - Ok(parts) => parts, + match to_wire_parts_with_kind(&canonical.listing, KIND_CLASSIFIED_LISTING) { + Ok(_) => {} Err(error) => { return Ok(invalid_validation_view( args.file.as_path(), @@ -590,7 +592,7 @@ pub fn validate( }, )); } - }; + } if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? { return Ok(invalid_validation_view( args.file.as_path(), @@ -599,22 +601,7 @@ pub fn validate( issue, )); } - let event = match listing_validation_event(&canonical, parts) { - Ok(event) => event, - Err(error) => { - return Ok(invalid_validation_view( - args.file.as_path(), - &parsed, - &context, - ListingValidationIssueView { - field: "listing".to_owned(), - message: format!("invalid listing event envelope: {error}"), - line: None, - }, - )); - } - }; - match validate_listing_event(&event) { + match validate_operational_listing_draft(&canonical) { Ok(_) => Ok(ListingValidateView { state: "valid".to_owned(), source: LISTING_SOURCE.to_owned(), @@ -1072,7 +1059,7 @@ fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> Runtime } fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String { - format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}") + format!("{KIND_CLASSIFIED_LISTING}:{seller_pubkey}:{listing_id}") } fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> { @@ -1160,8 +1147,8 @@ fn listing_ready_issues( canonical: &CanonicalListingDraft, contents: &str, ) -> Vec<ListingValidationIssueView> { - let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) { - Ok(parts) => parts, + match to_wire_parts_with_kind(&canonical.listing, KIND_CLASSIFIED_LISTING) { + Ok(_) => {} Err(error) => { return vec![ListingValidationIssueView { field: "listing".to_owned(), @@ -1169,18 +1156,8 @@ fn listing_ready_issues( line: None, }]; } - }; - let event = match listing_validation_event(canonical, parts) { - Ok(event) => event, - Err(error) => { - return vec![ListingValidationIssueView { - field: "listing".to_owned(), - message: format!("invalid listing event envelope: {error}"), - line: None, - }]; - } - }; - match validate_listing_event(&event) { + } + match validate_operational_listing_draft(canonical) { Ok(_) => Vec::new(), Err(error) => vec![issue_from_trade_validation(error, contents)], } @@ -1853,7 +1830,7 @@ fn sdk_listing_publish_input( [RadrootsActorRole::Seller], ) .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; - let document = RadrootsListingEditDocumentV1::new(canonical.listing.clone()); + let document = RadrootsOperationalListingEditDocumentV1::new(canonical.listing.clone()); Ok(SdkListingPublishInput { canonical, actor, @@ -1880,7 +1857,7 @@ fn sdk_prepared_publish_view( seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, + event_kind: KIND_CLASSIFIED_LISTING, dry_run: true, deduplicated: false, target_transport_endpoints: Vec::new(), @@ -1938,7 +1915,7 @@ fn sdk_enqueued_publish_view( seller_account_id: canonical.seller_account_id.clone(), seller_pubkey: canonical.seller_pubkey.clone(), seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, + event_kind: KIND_CLASSIFIED_LISTING, dry_run: false, deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), target_transport_endpoints, @@ -1972,22 +1949,6 @@ fn sdk_plan_event_view(plan: &ListingPublishPlan) -> ListingMutationEventView { } } -fn listing_validation_event( - canonical: &CanonicalListingDraft, - parts: radroots_event::wire::RadrootsNip01EventWireParts, -) -> Result<RadrootsEventEnvelope, String> { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: "0".repeat(64), - author: canonical.seller_pubkey.clone(), - created_at: 0, - kind: KIND_LISTING, - tags: parts.tags, - content: parts.content, - sig: "0".repeat(128), - }) - .map_err(|error| error.to_string()) -} - fn sdk_push_event_for_listing<'a>( enqueue: &ListingEnqueueReceipt, push: &'a PushOutboxReceipt, @@ -2173,8 +2134,8 @@ fn mutate( ensure_listing_bound_account(config, &canonical, args.file.as_path())?; if let Some(status) = operation.listing_status() { - canonical.listing.availability = Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Other { + canonical.listing.availability = Some(RadrootsOperationalListingAvailability::Status { + status: RadrootsOperationalListingStatus::Other { value: status.to_owned(), }, }); @@ -2199,7 +2160,7 @@ fn mutate_via_sdk_from_canonical( [RadrootsActorRole::Seller], ) .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; - let document = RadrootsListingEditDocumentV1::new(canonical.listing.clone()); + let document = RadrootsOperationalListingEditDocumentV1::new(canonical.listing.clone()); if config.output.dry_run { let session = CliSdkSession::connect_memory(config)?; let plan = session @@ -2444,7 +2405,7 @@ fn canonicalize_draft( }, )?; - let listing = RadrootsListing { + let listing = RadrootsOperationalListing { d_tag: protocol_d_tag(listing_id.as_str(), "listing d_tag").map_err(|error| { issue_for_field( contents, @@ -2457,7 +2418,7 @@ fn canonicalize_draft( pubkey: seller_pubkey.clone(), d_tag: farm_d_tag.clone(), }, - product: RadrootsListingProduct { + product: RadrootsOperationalListingProduct { key: draft.product.key.trim().to_owned(), title: draft.product.title.trim().to_owned(), category: draft.product.category.trim().to_owned(), @@ -2469,7 +2430,7 @@ fn canonicalize_draft( year: None, }, primary_bin_id: primary_bin_id.clone(), - bins: vec![RadrootsListingBin { + bins: vec![RadrootsOperationalListingBin { bin_id: primary_bin_id, quantity, price_per_canonical_unit: price, @@ -2502,7 +2463,7 @@ fn canonicalize_draft( fn build_availability( draft: &ListingDraftDocument, contents: &str, -) -> Result<RadrootsListingAvailability, ListingValidationIssueView> { +) -> Result<RadrootsOperationalListingAvailability, ListingValidationIssueView> { let kind = if draft.availability.kind.trim().is_empty() { if draft.availability.start.is_some() || draft.availability.end.is_some() { "window" @@ -2523,17 +2484,17 @@ fn build_availability( "missing availability status", )); } - Ok(RadrootsListingAvailability::Status { + Ok(RadrootsOperationalListingAvailability::Status { status: match status { - "active" => RadrootsListingStatus::Active, - "sold" => RadrootsListingStatus::Sold, - other => RadrootsListingStatus::Other { + "active" => RadrootsOperationalListingStatus::Active, + "sold" => RadrootsOperationalListingStatus::Sold, + other => RadrootsOperationalListingStatus::Other { value: other.to_owned(), }, }, }) } - "window" => Ok(RadrootsListingAvailability::Window { + "window" => Ok(RadrootsOperationalListingAvailability::Window { start: draft.availability.start, end: draft.availability.end, }), @@ -2548,7 +2509,7 @@ fn build_availability( fn build_delivery_method( draft: &ListingDraftDocument, contents: &str, -) -> Result<RadrootsListingDeliveryMethod, ListingValidationIssueView> { +) -> Result<RadrootsOperationalListingDeliveryMethod, ListingValidationIssueView> { let method = draft.delivery.method.trim(); if method.is_empty() { return Err(issue_for_field( @@ -2559,17 +2520,17 @@ fn build_delivery_method( } Ok(match method { - "pickup" => RadrootsListingDeliveryMethod::Pickup, - "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, - "shipping" => RadrootsListingDeliveryMethod::Shipping, - other => RadrootsListingDeliveryMethod::Other { + "pickup" => RadrootsOperationalListingDeliveryMethod::Pickup, + "local_delivery" => RadrootsOperationalListingDeliveryMethod::LocalDelivery, + "shipping" => RadrootsOperationalListingDeliveryMethod::Shipping, + other => RadrootsOperationalListingDeliveryMethod::Other { method: other.to_owned(), }, }) } -fn build_location(draft: &ListingDraftDocument) -> RadrootsListingPublicLocation { - RadrootsListingPublicLocation { +fn build_location(draft: &ListingDraftDocument) -> RadrootsOperationalListingPublicLocation { + RadrootsOperationalListingPublicLocation { primary: draft.location.primary.trim().to_owned(), city: draft.location.city.clone().and_then(non_empty), region: draft.location.region.clone().and_then(non_empty), @@ -2832,48 +2793,62 @@ fn validate_configured_listing_signer( ) } +fn validate_operational_listing_draft( + canonical: &CanonicalListingDraft, +) -> Result<(), RadrootsOperationalListingValidationError> { + let seller_pubkey = RadrootsPublicKey::parse(canonical.seller_pubkey.as_str()) + .map_err(|_| RadrootsOperationalListingValidationError::InvalidSeller)?; + validate_operational_listing_model(canonical.listing.clone(), &seller_pubkey).map(|_| ()) +} + fn issue_from_trade_validation( - error: RadrootsTradeValidationListingError, + error: RadrootsOperationalListingValidationError, contents: &str, ) -> ListingValidationIssueView { match error { - RadrootsTradeValidationListingError::InvalidSeller => issue_for_field( + RadrootsOperationalListingValidationError::InvalidSeller => issue_for_field( contents, "seller_actor.pubkey", "listing author does not match the farm pubkey", ), - RadrootsTradeValidationListingError::MissingTitle => { + RadrootsOperationalListingValidationError::MissingTitle => { issue_for_field(contents, "product.title", "missing listing title") } - RadrootsTradeValidationListingError::MissingDescription => { + RadrootsOperationalListingValidationError::MissingDescription => { issue_for_field(contents, "product.summary", "missing listing description") } - RadrootsTradeValidationListingError::MissingProductType => { + RadrootsOperationalListingValidationError::MissingProductType => { issue_for_field(contents, "product.category", "missing listing product type") } - RadrootsTradeValidationListingError::MissingBins - | RadrootsTradeValidationListingError::MissingPrimaryBin - | RadrootsTradeValidationListingError::InvalidBin => { + RadrootsOperationalListingValidationError::MissingBins + | RadrootsOperationalListingValidationError::MissingPrimaryBin + | RadrootsOperationalListingValidationError::InvalidBin => { issue_for_field(contents, "primary_bin.bin_id", error.to_string()) } - RadrootsTradeValidationListingError::InvalidPrice => issue_for_field( + RadrootsOperationalListingValidationError::MissingPrice + | RadrootsOperationalListingValidationError::InvalidPrice => issue_for_field( contents, "primary_bin.price_amount", "invalid listing price", ), - RadrootsTradeValidationListingError::MissingInventory - | RadrootsTradeValidationListingError::InvalidInventory => { + RadrootsOperationalListingValidationError::MissingInventory + | RadrootsOperationalListingValidationError::InvalidInventory => { issue_for_field(contents, "inventory.available", error.to_string()) } - RadrootsTradeValidationListingError::MissingAvailability => issue_for_field( + RadrootsOperationalListingValidationError::MissingAvailability => issue_for_field( contents, "availability.status", "missing listing availability", ), - RadrootsTradeValidationListingError::MissingLocation => { - issue_for_field(contents, "location.primary", "missing listing location") + RadrootsOperationalListingValidationError::MissingLocation + | RadrootsOperationalListingValidationError::MissingLocationLocality => { + issue_for_field(contents, "location.primary", error.to_string()) + } + RadrootsOperationalListingValidationError::MissingLocationGeohash + | RadrootsOperationalListingValidationError::InvalidLocationGeohash => { + issue_for_field(contents, "location.geohash", error.to_string()) } - RadrootsTradeValidationListingError::MissingDeliveryMethod => issue_for_field( + RadrootsOperationalListingValidationError::MissingDeliveryMethod => issue_for_field( contents, "delivery.method", "missing listing delivery method", @@ -2976,7 +2951,9 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults Ok(defaults) } -fn draft_location_from_model(location: &RadrootsListingPublicLocation) -> ListingDraftLocation { +fn draft_location_from_model( + location: &RadrootsOperationalListingPublicLocation, +) -> ListingDraftLocation { ListingDraftLocation { primary: location.primary.clone(), city: location.city.clone(), @@ -3296,7 +3273,7 @@ mod tests { } #[test] - fn listing_draft_canonicalization_preserves_discounts() { + fn listing_draft_canonicalization_preserves_discounts_and_validates_semantics() { let seller_pubkey = "a".repeat(64); let document = ListingDraftDocument { version: 1, @@ -3339,8 +3316,8 @@ mod tests { method: "pickup".to_owned(), }, location: super::ListingDraftLocation { - primary: "Asheville".to_owned(), - city: None, + primary: "Farm stand".to_owned(), + city: Some("Asheville".to_owned()), region: None, country: None, geohash: "dnqwy".to_owned(), @@ -3374,6 +3351,17 @@ mod tests { .len(), 1 ); + super::validate_operational_listing_draft(&canonical) + .expect("canonical listing passes operational validation"); + + let mut missing_description = canonical; + missing_description.listing.product.summary = Some(" ".to_owned()); + assert_eq!( + super::validate_operational_listing_draft(&missing_description), + Err( + radroots_event::trade_validation::RadrootsOperationalListingValidationError::MissingDescription + ) + ); } fn sdk_push_event( diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1397,6 +1397,7 @@ mod tests { assert_eq!(session.config().storage_root, config.local.root.join("sdk")); assert_eq!(status.storage, SdkStorageKind::Directory); assert_eq!(status.event_store.total_events, 0); + assert_eq!(status.event_store.valid_stream_events, 0); assert_eq!(status.outbox.total_events, 0); } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -10,8 +10,8 @@ use crate::view::runtime::{ SignerStatusView, SignerWriteKindReadinessView, }; use radroots_event::kinds::{ - KIND_FARM, KIND_LISTING, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, - KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, + KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; @@ -302,15 +302,15 @@ fn cli_write_kinds() -> [CliWriteKind; 9] { }, CliWriteKind { command: "listing.publish", - event_kind: KIND_LISTING, + event_kind: KIND_CLASSIFIED_LISTING, }, CliWriteKind { command: "listing.update", - event_kind: KIND_LISTING, + event_kind: KIND_CLASSIFIED_LISTING, }, CliWriteKind { command: "listing.withdraw", - event_kind: KIND_LISTING, + event_kind: KIND_CLASSIFIED_LISTING, }, CliWriteKind { command: "trade.proposal.submit", @@ -582,9 +582,9 @@ fn sign_event_permission_for_kind(event_kind: u32) -> String { #[cfg(test)] mod tests { use super::{ - KIND_FARM, KIND_LISTING, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, - KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, cli_write_kinds, - myc_managed_account_ref_matches, myc_write_kind_readiness, + KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, + cli_write_kinds, myc_managed_account_ref_matches, myc_write_kind_readiness, myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind, }; use radroots_nostr_connect::prelude::{ @@ -644,7 +644,7 @@ mod tests { for (command, event_kind) in [ ("farm.publish", KIND_FARM), - ("listing.publish", KIND_LISTING), + ("listing.publish", KIND_CLASSIFIED_LISTING), ("trade.proposal.submit", KIND_TRADE_PROPOSAL), ] { let entry = readiness @@ -666,7 +666,7 @@ mod tests { Ok(RadrootsNostrConnectPermissions::from(vec![ RadrootsNostrConnectPermission::with_parameter( RadrootsNostrConnectMethod::SignEvent, - format!("kind:{KIND_LISTING}"), + format!("kind:{KIND_CLASSIFIED_LISTING}"), ), ])), ); diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -410,7 +410,7 @@ fn sdk_event_store_status_view( path, store: sdk_sqlite_status_view(status.store), total_events: status.total_events, - projection_eligible_events: status.projection_eligible_events, + valid_stream_events: status.valid_stream_events, transport_observations: status.transport_observations, last_event_seq: status.last_event_seq, last_event_updated_at_ms: status.last_event_updated_at_ms, diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -2,11 +2,12 @@ use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use radroots_event::kinds::{ - KIND_FARM, KIND_LIST_SET_APP_CURATION, KIND_LIST_SET_BOOKMARK, KIND_LIST_SET_CALENDAR, - KIND_LIST_SET_CURATION, KIND_LIST_SET_EMOJI, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, - KIND_LIST_SET_INTEREST, KIND_LIST_SET_KIND_MUTE, KIND_LIST_SET_MEDIA_STARTER_PACK, - KIND_LIST_SET_PICTURE, KIND_LIST_SET_RELAY, KIND_LIST_SET_RELEASE_ARTIFACT, - KIND_LIST_SET_STARTER_PACK, KIND_LIST_SET_VIDEO, KIND_LISTING, KIND_PLOT, KIND_PROFILE, + KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_LIST_SET_APP_CURATION, KIND_LIST_SET_BOOKMARK, + KIND_LIST_SET_CALENDAR, KIND_LIST_SET_CURATION, KIND_LIST_SET_EMOJI, KIND_LIST_SET_FOLLOW, + KIND_LIST_SET_GENERIC, KIND_LIST_SET_INTEREST, KIND_LIST_SET_KIND_MUTE, + KIND_LIST_SET_MEDIA_STARTER_PACK, KIND_LIST_SET_PICTURE, KIND_LIST_SET_RELAY, + KIND_LIST_SET_RELEASE_ARTIFACT, KIND_LIST_SET_STARTER_PACK, KIND_LIST_SET_VIDEO, KIND_PLOT, + KIND_PROFILE, }; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrTimestamp, radroots_event_from_nostr, radroots_nostr_kind, @@ -61,12 +62,12 @@ const RELAY_FETCH_MAX_PAGES: usize = 5; const MARKET_FRESHNESS_STALE_AFTER_SECONDS: u64 = 15 * 60; const SYNC_PULL_FRESHNESS_STALE_AFTER_SECONDS: u64 = 30 * 60; const SYNC_RUN_TABLE: &str = "radroots_cli_sync_run"; -const MARKET_REFRESH_KINDS: &[u32] = &[KIND_PROFILE, KIND_FARM, KIND_LISTING]; +const MARKET_REFRESH_KINDS: &[u32] = &[KIND_PROFILE, KIND_FARM, KIND_CLASSIFIED_LISTING]; const SYNC_PULL_KINDS: &[u32] = &[ KIND_PROFILE, KIND_FARM, KIND_PLOT, - KIND_LISTING, + KIND_CLASSIFIED_LISTING, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, KIND_LIST_SET_RELAY, @@ -1657,9 +1658,26 @@ fn ingest_events( counts.unsupported_count += 1; continue; } - let event = radroots_event_from_nostr(&event.event); + let event = match radroots_event_from_nostr(&event.event) { + Ok(event) => event, + Err(error) => { + counts.failed_count += 1; + if counts.first_failure_reason.is_none() { + counts.first_failure_reason = Some(error.to_string()); + } + continue; + } + }; match radroots_replica_ingest_event(executor, &event) { Ok(RadrootsReplicaIngestOutcome::Applied) => counts.ingested_count += 1, + Ok(RadrootsReplicaIngestOutcome::Excluded) => counts.unsupported_count += 1, + Ok(RadrootsReplicaIngestOutcome::Rejected) => { + counts.failed_count += 1; + if counts.first_failure_reason.is_none() { + counts.first_failure_reason = + Some("event was rejected by the local replica projection".to_owned()); + } + } Ok(RadrootsReplicaIngestOutcome::Skipped) => counts.skipped_count += 1, Err(error @ RadrootsReplicaEventsError::Sql(_)) => return Err(error.into()), Err(error) => { @@ -1764,12 +1782,14 @@ mod tests { use radroots_event::farm::{RadrootsFarm, RadrootsFarmRef}; use radroots_event::ids::RadrootsEventId; - use radroots_event::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_LISTING, KIND_POST}; + use radroots_event::kinds::{ + KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_LIST_SET_GENERIC, KIND_POST, + }; use radroots_event::list::RadrootsListEntry; use radroots_event::list_set::RadrootsListSet; use radroots_event::plot::RadrootsPlot; use radroots_event::profile::RadrootsAuthoredProfile; - use radroots_event::wire::RadrootsNip01EventWireParts; + use radroots_event::wire::{DEFAULT_CONTENT_MAX_BYTES, RadrootsNip01EventWireParts}; use radroots_event_codec::farm::encode as farm_encode; use radroots_event_codec::list_set::encode as list_set_encode; use radroots_event_codec::plot::encode as plot_encode; @@ -2048,6 +2068,7 @@ mod tests { 0, 0, 0, + 0, None, None, &["wss://relay-a.example.com", "wss://relay-b.example.com"], @@ -2081,6 +2102,7 @@ mod tests { 0, 0, 0, + 0, None, None, &["wss://relay.example.com"], @@ -2157,27 +2179,29 @@ mod tests { } #[test] - fn sync_status_reports_sdk_pending_retryable_and_terminal_outbox_counts() { + fn sync_status_reports_distinct_raw_valid_and_outbox_counts() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); - let view = sdk_sync_status_view( - &config, - sdk_status_receipt( - 3, - 4, - 1, - 1, - 2, - 1, - 0, - 1, - 0, - Some(1_700_000_010_000), - Some("auth-required: login".to_owned()), - &["wss://relay.example.com"], - ), + let receipt = sdk_status_receipt( + 3, + 2, + 4, + 1, + 1, + 2, + 1, + 0, + 1, + 0, + Some(1_700_000_010_000), + Some("auth-required: login".to_owned()), + &["wss://relay.example.com"], ); + assert_eq!(receipt.event_store.total_events, 3); + assert_eq!(receipt.event_store.valid_stream_events, 2); + + let view = sdk_sync_status_view(&config, receipt); assert_eq!(view.state, "ready"); assert_eq!(view.queue.expected_count, 4); @@ -2207,6 +2231,7 @@ mod tests { 1, 1, 1, + 1, 0, 0, 0, @@ -2259,6 +2284,7 @@ mod tests { 0, 0, 0, + 0, None, None, &["wss://relay.example.com"], @@ -2387,6 +2413,7 @@ mod tests { sdk_status_receipt( 2, 2, + 2, 0, 1, 1, @@ -2444,7 +2471,8 @@ mod tests { reason = "test fixture construction sets every storage status field explicitly" )] fn sdk_status_receipt( - total_events: i64, + raw_total_events: i64, + valid_stream_events: i64, outbox_total_events: i64, pending_events: i64, retryable_events: i64, @@ -2465,11 +2493,11 @@ mod tests { source: SyncStatusSource::SdkCanonicalStores, observed_at_ms: 1_700_000_030_000, event_store: SyncEventStoreStatus { - total_events, - projection_eligible_events: total_events, + total_events: raw_total_events, + valid_stream_events, transport_observations: 0, - last_event_seq: (total_events > 0).then_some(total_events), - last_event_updated_at_ms: (total_events > 0).then_some(1_700_000_000_000), + last_event_seq: (raw_total_events > 0).then_some(raw_total_events), + last_event_updated_at_ms: (raw_total_events > 0).then_some(1_700_000_000_000), }, outbox: SyncOutboxStatus { total_events: outbox_total_events, @@ -2522,7 +2550,7 @@ mod tests { observed_at_ms: 1_700_000_030_000, event_store: SyncEventStoreStatus { total_events: 1, - projection_eligible_events: 1, + valid_stream_events: 1, transport_observations: 0, last_event_seq: Some(1), last_event_updated_at_ms: Some(1_700_000_000_000), @@ -2687,7 +2715,7 @@ mod tests { crate::runtime::store::init(&config).expect("store init"); let seller = identity(7); let seller_pubkey = seller.public_key_hex(); - let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{LISTING_D_TAG}"); + let listing_addr = format!("{KIND_CLASSIFIED_LISTING}:{seller_pubkey}:{LISTING_D_TAG}"); let events = vec![ farm_event(&seller), plot_event(&seller), @@ -2927,6 +2955,14 @@ mod tests { signed_event( &seller, RadrootsNip01EventWireParts { + kind: KIND_CLASSIFIED_LISTING, + content: "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1), + tags: Vec::new(), + }, + ), + signed_event( + &seller, + RadrootsNip01EventWireParts { kind: KIND_POST, content: "hello".to_owned(), tags: Vec::new(), @@ -2935,7 +2971,7 @@ mod tests { signed_event( &seller, RadrootsNip01EventWireParts { - kind: KIND_LISTING, + kind: KIND_CLASSIFIED_LISTING, content: "not a listing".to_owned(), tags: Vec::new(), }, @@ -2945,15 +2981,15 @@ mod tests { let view = pull_with_fetcher(&config, fake_fetcher(events)).expect("sync pull ingest"); assert_eq!(view.state, "ready"); - assert_eq!(view.fetched_count, Some(2)); + assert_eq!(view.fetched_count, Some(3)); assert_eq!(view.ingested_count, Some(0)); assert_eq!(view.unsupported_count, Some(1)); - assert_eq!(view.failed_count, Some(1)); + assert_eq!(view.failed_count, Some(2)); assert!( view.reason .as_deref() .expect("failure reason") - .contains("failed ingest") + .contains("event envelope content size") ); } @@ -3087,7 +3123,7 @@ mod tests { created_at: u64, ) -> RadrootsNostrEvent { let mut builder = wire_fixture_builder(RadrootsNip01EventWireParts { - kind: KIND_LISTING, + kind: KIND_CLASSIFIED_LISTING, content: "# Pasture Eggs".to_owned(), tags: vec![ vec!["d".to_owned(), LISTING_D_TAG.to_owned()], diff --git a/src/runtime/trade.rs b/src/runtime/trade.rs @@ -9,10 +9,9 @@ use radroots_core::{ }; use radroots_event::contract::RadrootsActorRole; use radroots_event::ids::{ - RadrootsAddressableCoordinate, RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, - RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, + RadrootsClassifiedListingAddress, RadrootsDTag, RadrootsInventoryBinId, RadrootsPublicKey, + RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, }; -use radroots_event::kinds::KIND_LISTING; use radroots_event::trade::{ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, @@ -378,11 +377,6 @@ fn scaffold_proposal_draft_inner( })?; let product = resolve_product(config, args)?; let parsed_listing = parse_listing_addr(product.listing_addr.as_str())?; - if parsed_listing.kind != KIND_LISTING { - return Err(RuntimeError::Config( - "trade proposal draft requires a public listing address".to_owned(), - )); - } let listing_state = resolve_active_listing_state(config, product.listing_addr.as_str(), &parsed_listing)?; let farm_id = resolve_farm_id(config, parsed_listing.seller_pubkey.as_str())?; @@ -736,7 +730,7 @@ fn candidate_terms( let line = RadrootsTradeCandidateLineV1 { line_id: RadrootsDTag::parse("line-1") .map_err(|error| RuntimeError::Config(format!("invalid line id: {error}")))?, - listing_addr: RadrootsAddressableCoordinate::parse(product.listing_addr.as_str()) + listing_addr: RadrootsClassifiedListingAddress::parse(product.listing_addr.as_str()) .map_err(|error| RuntimeError::Config(format!("invalid listing address: {error}")))?, listing_event_id: listing_state .last_event_id @@ -856,7 +850,7 @@ fn resolve_farm_id( } fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, RuntimeError> { - let parsed = RadrootsListingAddress::parse(raw) + let parsed = RadrootsClassifiedListingAddress::parse(raw) .map_err(|error| RuntimeError::Config(format!("listing address is invalid: {error}")))?; let (kind, rest) = parsed .as_str() diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -1,9 +1,8 @@ use std::process::ExitCode; use radroots_event::farm::RadrootsFarm; -use radroots_event::ids::RadrootsListingAddress; -use radroots_event::kinds::KIND_LISTING; -use radroots_event::listing::RadrootsListingPublicLocation; +use radroots_event::ids::RadrootsClassifiedListingAddress; +use radroots_event::operational_listing::RadrootsOperationalListingPublicLocation; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -614,7 +613,7 @@ pub struct SdkEventStoreStatusView { pub path: Option<String>, pub store: SdkSqliteStatusView, pub total_events: i64, - pub projection_eligible_events: i64, + pub valid_stream_events: i64, pub transport_observations: i64, #[serde(skip_serializing_if = "Option::is_none")] pub last_event_seq: Option<i64>, @@ -622,6 +621,35 @@ pub struct SdkEventStoreStatusView { pub last_event_updated_at_ms: Option<i64>, } +#[cfg(test)] +mod sdk_event_store_status_view_tests { + use super::{SdkEventStoreStatusView, SdkSqliteStatusView}; + + #[test] + fn serializes_valid_stream_events_without_legacy_projection_key() { + let value = serde_json::to_value(SdkEventStoreStatusView { + path: None, + store: SdkSqliteStatusView { + schema_version: 1, + journal_mode: "wal".to_owned(), + foreign_keys_enabled: true, + busy_timeout_ms: 5_000, + integrity_ok: true, + integrity_result: "ok".to_owned(), + }, + total_events: 11, + valid_stream_events: 7, + transport_observations: 3, + last_event_seq: Some(11), + last_event_updated_at_ms: Some(1_700_000_000_000), + }) + .expect("event store status view"); + + assert_eq!(value["valid_stream_events"], 7); + assert!(value.get("projection_eligible_events").is_none()); + } +} + #[derive(Debug, Clone, Serialize)] pub struct SdkOutboxStatusView { #[serde(skip_serializing_if = "Option::is_none")] @@ -1118,7 +1146,7 @@ pub struct FarmSelectionView { #[derive(Debug, Clone, Serialize)] pub struct FarmListingDefaultsView { pub delivery_method: String, - pub location: RadrootsListingPublicLocation, + pub location: RadrootsOperationalListingPublicLocation, } #[derive(Debug, Clone, Serialize)] @@ -1208,13 +1236,7 @@ impl MarketReadinessView { price_per_amount: f64, ) -> Self { let protocol_valid = listing_addr.is_some_and(|listing_addr| { - RadrootsListingAddress::parse(listing_addr).is_ok_and(|parsed| { - parsed - .as_str() - .split_once(':') - .and_then(|(kind, _)| kind.parse::<u32>().ok()) - == Some(KIND_LISTING) - }) + RadrootsClassifiedListingAddress::parse(listing_addr).is_ok() }); let marketplace_eligible = protocol_valid && title.is_some_and(|title| !title.trim().is_empty())