commit e549d4847a791a5c87e9562aedf7d92f4d7bdd87
parent ab7dd72d0872572f32b76d508cc7ba0e0e03bd49
Author: triesap <tyson@radroots.org>
Date: Sun, 24 May 2026 11:17:44 +0000
cli: consume app-authored order records
Diffstat:
9 files changed, 1368 insertions(+), 82 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1409,6 +1409,106 @@ impl OrderListView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderAppRecordListView {
+ pub state: String,
+ pub source: String,
+ pub count: usize,
+ pub limit: u32,
+ pub has_more: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_before_change_seq: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub next_before_seq: Option<i64>,
+ pub local_events_db: String,
+ pub records: Vec<OrderAppRecordSummaryView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderAppRecordListView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderAppRecordSummaryView {
+ pub record_id: String,
+ pub seq: i64,
+ pub change_seq: i64,
+ pub superseded_count: usize,
+ pub record_kind: String,
+ pub status: String,
+ pub source_runtime: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub owner_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub owner_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub order_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ pub ready_for_submit: bool,
+ pub exportable: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderAppRecordExportView {
+ pub state: String,
+ pub source: String,
+ pub record_id: String,
+ pub dry_run: bool,
+ pub file: String,
+ pub valid: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub order_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_actor_source: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<OrderIssueView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderAppRecordExportView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "invalid" | "stale" | "unsupported" => CommandDisposition::ValidationFailed,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderSubmitView {
pub state: String,
pub source: String,
diff --git a/src/main.rs b/src/main.rs
@@ -298,6 +298,12 @@ fn execute_request(
TargetOperationRequest::OrderList(request) => {
execute_with(OrderOperationService::new(config), request)
}
+ TargetOperationRequest::OrderAppList(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
+ TargetOperationRequest::OrderAppExport(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
TargetOperationRequest::OrderRebind(request) => {
execute_with(OrderOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1285,10 +1285,10 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand,
BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand,
FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand,
- MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand,
- OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand,
- OrderSettlementCommand, OrderStatusCommand, TargetCommand, ValidationCommand,
- ValidationReceiptCommand,
+ MarketListingCommand, MarketProductCommand, OrderAppCommand, OrderCommand,
+ OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand,
+ OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, TargetCommand,
+ ValidationCommand, ValidationReceiptCommand,
};
let mut input = OperationData::new();
@@ -1458,6 +1458,13 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "order_id", &args.order_id);
}
OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id),
+ OrderCommand::App(args) => match &args.command {
+ OrderAppCommand::Export(args) => {
+ insert_string(&mut input, "record_id", &args.record_id);
+ insert_path(&mut input, "output", &args.output);
+ }
+ OrderAppCommand::List => {}
+ },
OrderCommand::Rebind(args) => {
insert_string(&mut input, "order_id", &args.order_id);
insert_string(&mut input, "selector", &args.selector);
@@ -1662,6 +1669,8 @@ target_operation_contracts! {
OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"),
OrderGet => (OrderGetRequest, OrderGetResult, "order.get"),
OrderList => (OrderListRequest, OrderListResult, "order.list"),
+ OrderAppList => (OrderAppListRequest, OrderAppListResult, "order.app.list"),
+ OrderAppExport => (OrderAppExportRequest, OrderAppExportResult, "order.app.export"),
OrderRebind => (OrderRebindRequest, OrderRebindResult, "order.rebind"),
OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"),
OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"),
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -1,15 +1,18 @@
+use std::path::PathBuf;
+
use serde::Serialize;
use serde_json::{Value, json};
use crate::deferred_payment::deferred_payment_message;
use crate::domain::runtime::{
- CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView,
- OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView,
- OrderStatusView, OrderSubmitView,
+ CommandDisposition, OrderAppRecordExportView, OrderCancellationView, OrderDecisionView,
+ OrderFulfillmentView, OrderRebindView, OrderReceiptView, OrderRevisionDecisionView,
+ OrderRevisionProposalView, OrderStatusView, OrderSubmitView,
};
use crate::operation_adapter::{
OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult,
+ OrderAppExportRequest, OrderAppExportResult, OrderAppListRequest, OrderAppListResult,
OrderCancelRequest, OrderCancelResult, OrderDeclineRequest, OrderDeclineResult,
OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult,
OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult,
@@ -24,9 +27,10 @@ use crate::operation_adapter::{
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
- OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderRebindArgs,
- OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs,
- OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs,
+ OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs,
+ OrderFulfillmentArgs, OrderRebindArgs, OrderReceiptArgs, OrderRevisionDecisionArg,
+ OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs,
+ RecordLookupArgs,
};
const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented";
@@ -101,6 +105,40 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> {
}
}
+impl OperationService<OrderAppListRequest> for OrderOperationService<'_> {
+ type Result = OrderAppListResult;
+
+ fn execute(
+ &self,
+ _request: OperationRequest<OrderAppListRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let view = map_runtime(crate::runtime::order::app_record_list(self.config))?;
+ serialized_target_result::<OrderAppListResult, _>(&view)
+ }
+}
+
+impl OperationService<OrderAppExportRequest> for OrderOperationService<'_> {
+ type Result = OrderAppExportResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderAppExportRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let args = OrderAppRecordExportArgs {
+ record_id: required_string_input(&request, "record_id")?,
+ output: optional_path_input(&request, "output"),
+ };
+ let mut config = self.config.clone();
+ if request.context.dry_run {
+ config.output.dry_run = true;
+ }
+ let view = crate::runtime::order::app_record_export(&config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ order_app_record_export_result::<OrderAppExportResult>(request.operation_id(), &view)
+ }
+}
+
impl OperationService<OrderRebindRequest> for OrderOperationService<'_> {
type Result = OrderRebindResult;
@@ -1250,6 +1288,50 @@ where
}
}
+fn order_app_record_export_result<R>(
+ operation_id: &str,
+ view: &OrderAppRecordExportView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_target_result::<R, _>(view),
+ CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail(
+ operation_id,
+ view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "app-authored local order record `{}` was not found",
+ view.record_id
+ )
+ }),
+ serde_json::to_value(view).unwrap_or(Value::Null),
+ )),
+ CommandDisposition::ValidationFailed => {
+ Err(OperationAdapterError::validation_failed_with_detail(
+ operation_id,
+ view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "app-authored local order record `{}` cannot be exported",
+ view.record_id
+ )
+ }),
+ serde_json::to_value(view).unwrap_or(Value::Null),
+ ))
+ }
+ disposition => Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "app-authored local order record export finished with state `{}`",
+ view.state
+ )
+ }),
+ )),
+ }
+}
+
fn order_rebind_result<R>(
operation_id: &str,
view: &OrderRebindView,
@@ -1431,6 +1513,13 @@ where
.map(str::to_owned)
}
+fn optional_path_input<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf>
+where
+ P: OperationRequestPayload + OperationRequestData,
+{
+ string_input(request, key).map(PathBuf::from)
+}
+
fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool>
where
P: OperationRequestPayload + OperationRequestData,
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -953,6 +953,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false
),
operation!(
+ "order.app.list",
+ "radroots order app list",
+ "order",
+ "order_app_list",
+ "OrderAppListRequest",
+ "OrderAppListResult",
+ "List app-authored shared local order records.",
+ Buyer,
+ false,
+ None,
+ Low,
+ false,
+ false
+ ),
+ operation!(
+ "order.app.export",
+ "radroots order app export",
+ "order",
+ "order_app_export",
+ "OrderAppExportRequest",
+ "OrderAppExportResult",
+ "Export an app-authored shared order record as a CLI draft.",
+ Buyer,
+ true,
+ None,
+ Medium,
+ false,
+ true
+ ),
+ operation!(
"order.rebind",
"radroots order rebind",
"order",
@@ -1381,6 +1411,8 @@ mod tests {
"order.submit",
"order.get",
"order.list",
+ "order.app.list",
+ "order.app.export",
"order.rebind",
"order.accept",
"order.decline",
@@ -1434,6 +1466,7 @@ mod tests {
"basket.adjustment.remove",
"basket.quote.create",
"order.submit",
+ "order.app.export",
"order.rebind",
"order.accept",
"order.decline",
@@ -1455,7 +1488,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 76);
+ assert_eq!(OPERATION_REGISTRY.len(), 78);
}
#[test]
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -1,5 +1,6 @@
#![allow(dead_code)]
+use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -46,6 +47,10 @@ use radroots_events_codec::trade::{
active_trade_settlement_decision_from_event,
};
use radroots_events_codec::wire::WireEventParts;
+use radroots_local_events::{
+ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalRecordFamily,
+ LocalRecordStatus, SourceRuntime, validate_buyer_order_request_local_work_payload,
+};
use radroots_nostr::prelude::{
RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag,
radroots_nostr_kind,
@@ -78,9 +83,10 @@ use radroots_trade::order::{
reduce_active_order_events, reduce_listing_inventory_accounting,
};
use serde::{Deserialize, Serialize};
-use serde_json::json;
+use serde_json::{Value, json};
use crate::domain::runtime::{
+ OrderAppRecordExportView, OrderAppRecordListView, OrderAppRecordSummaryView,
OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView,
OrderEventListView, OrderFulfillmentView, OrderGetView, OrderInventoryBinView,
OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView,
@@ -96,20 +102,25 @@ use crate::runtime::direct_relay::{
DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, DirectRelayPublishReceipt,
fetch_events_from_relays, publish_parts_with_identity,
};
+use crate::runtime::local_events::{
+ get_shared_record, list_shared_records_before, list_shared_records_latest,
+ shared_local_events_db_path,
+};
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope, freshness_requires_refresh, market_refresh,
};
use crate::runtime_args::{
- OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs,
- OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, OrderReceiptArgs,
- OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs,
- OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs,
- RecordLookupArgs,
+ OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs,
+ OrderDraftCreateArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs,
+ OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs,
+ OrderRevisionProposeArgs, OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs,
+ OrderSubmitArgs, RecordLookupArgs,
};
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
const ORDER_SOURCE: &str = "local order drafts · local first";
+const ORDER_APP_RECORD_SOURCE: &str = "app-authored shared local order records";
const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key";
const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key";
const ORDER_REVISION_PROPOSAL_SOURCE: &str =
@@ -127,6 +138,7 @@ const ORDER_EVENT_LIST_RELAY_ACTION: &str =
"radroots --relay wss://relay.example.com order event list";
const ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
const ORDER_BUYER_ACTOR_SOURCE_REBIND: &str = "order_rebind";
+const ORDER_APP_RECORD_LIST_LIMIT: u32 = 500;
const ORDER_ACTOR_CONTEXT_ORDER_DRAFT: &str = "order_draft";
const ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT: &str = "resolved_account";
const ORDER_ACTOR_CONTEXT_NETWORK_ONLY: &str = "network_only";
@@ -186,6 +198,19 @@ struct LoadedOrderDraft {
}
#[derive(Debug, Clone)]
+struct LoadedAppOrderRecord {
+ record: LocalEventRecord,
+ loaded: LoadedOrderDraft,
+ source_issues: Vec<OrderIssueView>,
+}
+
+#[derive(Debug, Clone)]
+struct AppOrderRecordListEntry {
+ record: LocalEventRecord,
+ superseded_count: usize,
+}
+
+#[derive(Debug, Clone)]
struct ResolvedOrderListing {
listing_addr: String,
listing_event_id: String,
@@ -487,6 +512,13 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi
let lookup = args.key.clone();
let file = draft_lookup_path(config, lookup.as_str());
if !file.exists() {
+ if let Some(app_order) = load_app_order_record_for_lookup(config, lookup.as_str())? {
+ return view_from_loaded_with_source_issues(
+ config,
+ app_order.loaded,
+ app_order.source_issues.as_slice(),
+ );
+ }
return Ok(OrderGetView {
state: "missing".to_owned(),
source: ORDER_SOURCE.to_owned(),
@@ -549,27 +581,34 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi
pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> {
let dir = drafts_dir(config);
- if !dir.exists() {
- return Ok(OrderListView {
- state: "empty".to_owned(),
- source: ORDER_SOURCE.to_owned(),
- count: 0,
- orders: Vec::new(),
- actions: vec!["radroots basket create".to_owned()],
- });
- }
-
let mut orders = Vec::new();
- for entry in fs::read_dir(&dir)? {
- let entry = entry?;
- let path = entry.path();
- if path.extension().and_then(|value| value.to_str()) != Some("toml") {
- continue;
+ let mut local_order_ids = HashSet::new();
+ if dir.exists() {
+ for entry in fs::read_dir(&dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ if path.extension().and_then(|value| value.to_str()) != Some("toml") {
+ continue;
+ }
+ match load_draft(path.as_path()) {
+ Ok(loaded) => {
+ local_order_ids.insert(loaded.document.order.order_id.clone());
+ orders.push(summary_from_loaded(config, &loaded)?);
+ }
+ Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)),
+ }
}
- match load_draft(path.as_path()) {
- Ok(loaded) => orders.push(summary_from_loaded(config, &loaded)?),
- Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)),
+ }
+ for entry in current_app_order_record_entries(app_order_local_records(config)?) {
+ let app_order = load_app_order_record_from_record(config, entry.record.clone())?;
+ if local_order_ids.contains(&app_order.loaded.document.order.order_id) {
+ continue;
}
+ orders.push(summary_from_loaded_with_source_issues(
+ config,
+ &app_order.loaded,
+ app_order.source_issues.as_slice(),
+ )?);
}
orders.sort_by(|left, right| {
@@ -604,12 +643,247 @@ pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> {
})
}
+pub fn app_record_list(config: &RuntimeConfig) -> Result<OrderAppRecordListView, RuntimeError> {
+ let database_path = shared_local_events_db_path(config)?;
+ let mut entries = current_app_order_record_entries(app_order_local_records(config)?);
+ let has_more = entries.len() > ORDER_APP_RECORD_LIST_LIMIT as usize;
+ if has_more {
+ entries.truncate(ORDER_APP_RECORD_LIST_LIMIT as usize);
+ }
+ let next_cursor = if has_more {
+ entries
+ .last()
+ .map(|entry| (entry.record.change_seq, entry.record.seq))
+ } else {
+ None
+ };
+ let records = entries
+ .iter()
+ .map(|entry| app_order_record_summary(config, &entry.record, entry.superseded_count))
+ .collect::<Result<Vec<_>, _>>()?;
+ let state = if records.is_empty() { "empty" } else { "ready" };
+ let actions = if records.is_empty() {
+ vec!["place a buyer order in radroots_app".to_owned()]
+ } else {
+ Vec::new()
+ };
+
+ Ok(OrderAppRecordListView {
+ state: state.to_owned(),
+ source: ORDER_APP_RECORD_SOURCE.to_owned(),
+ count: records.len(),
+ limit: ORDER_APP_RECORD_LIST_LIMIT,
+ has_more,
+ next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq),
+ next_before_seq: next_cursor.map(|(_, seq)| seq),
+ local_events_db: database_path.display().to_string(),
+ records,
+ actions,
+ })
+}
+
+pub fn app_record_export(
+ config: &RuntimeConfig,
+ args: &OrderAppRecordExportArgs,
+) -> Result<OrderAppRecordExportView, RuntimeError> {
+ let Some(record) = get_shared_record(config, args.record_id.as_str())? else {
+ return Ok(OrderAppRecordExportView {
+ state: "missing".to_owned(),
+ source: ORDER_APP_RECORD_SOURCE.to_owned(),
+ record_id: args.record_id.clone(),
+ dry_run: config.output.dry_run,
+ file: args
+ .output
+ .as_ref()
+ .map(|path| path.display().to_string())
+ .unwrap_or_default(),
+ valid: false,
+ order_id: None,
+ listing_addr: None,
+ listing_event_id: None,
+ buyer_account_id: None,
+ buyer_pubkey: None,
+ buyer_actor_source: None,
+ seller_pubkey: None,
+ issues: Vec::new(),
+ reason: Some(format!(
+ "app-authored local order record `{}` was not found",
+ args.record_id
+ )),
+ actions: vec!["radroots order app list".to_owned()],
+ });
+ };
+
+ if let Some(current_record) = current_app_order_record_for(config, &record)?
+ && current_record.record_id != record.record_id
+ {
+ let order_id = app_order_record_order_id(&record);
+ return Ok(OrderAppRecordExportView {
+ state: "stale".to_owned(),
+ source: ORDER_APP_RECORD_SOURCE.to_owned(),
+ record_id: args.record_id.clone(),
+ dry_run: config.output.dry_run,
+ file: args
+ .output
+ .as_ref()
+ .map(|path| path.display().to_string())
+ .unwrap_or_default(),
+ valid: false,
+ order_id: order_id.clone(),
+ listing_addr: record.listing_addr.clone(),
+ listing_event_id: None,
+ buyer_account_id: record.owner_account_id.clone(),
+ buyer_pubkey: record.owner_pubkey.clone(),
+ buyer_actor_source: None,
+ seller_pubkey: None,
+ issues: vec![issue_with_code(
+ "app_order_stale",
+ "record_id",
+ format!(
+ "app-authored local order record `{}` was superseded by `{}`",
+ record.record_id, current_record.record_id
+ ),
+ )],
+ reason: Some(format!(
+ "app-authored local order record `{}` was superseded by current record `{}`",
+ record.record_id, current_record.record_id
+ )),
+ actions: vec![
+ format!("radroots order app export {}", current_record.record_id),
+ "radroots order app list".to_owned(),
+ ],
+ });
+ }
+
+ let app_order = load_app_order_record_from_record(config, record)?;
+ let mut issues = source_and_document_issues(config, &app_order)?;
+ if !issues.is_empty() {
+ let state = app_order_export_failure_state(issues.as_slice());
+ return Ok(OrderAppRecordExportView {
+ state: state.to_owned(),
+ source: ORDER_APP_RECORD_SOURCE.to_owned(),
+ record_id: args.record_id.clone(),
+ dry_run: config.output.dry_run,
+ file: args
+ .output
+ .as_ref()
+ .map(|path| path.display().to_string())
+ .unwrap_or_default(),
+ valid: false,
+ order_id: Some(app_order.loaded.document.order.order_id.clone()),
+ listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()),
+ listing_event_id: non_empty_string(
+ app_order.loaded.document.order.listing_event_id.clone(),
+ ),
+ buyer_account_id: buyer_account_id(&app_order.loaded.document),
+ buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()),
+ buyer_actor_source: buyer_actor_source(&app_order.loaded.document),
+ seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()),
+ issues,
+ reason: Some(format!(
+ "app-authored local order record `{}` is not ready as a CLI order draft",
+ args.record_id
+ )),
+ actions: vec!["radroots order app list".to_owned()],
+ });
+ }
+
+ let output_path = order_export_output_path(
+ config,
+ args.output.as_ref(),
+ app_order.loaded.document.order.order_id.as_str(),
+ );
+ validate_order_export_output_target(output_path.as_path())?;
+ if !config.output.dry_run {
+ save_draft(output_path.as_path(), &app_order.loaded.document)?;
+ }
+ issues.clear();
+
+ Ok(OrderAppRecordExportView {
+ state: if config.output.dry_run {
+ "dry_run"
+ } else {
+ "exported"
+ }
+ .to_owned(),
+ source: ORDER_APP_RECORD_SOURCE.to_owned(),
+ record_id: args.record_id.clone(),
+ dry_run: config.output.dry_run,
+ file: output_path.display().to_string(),
+ valid: true,
+ order_id: Some(app_order.loaded.document.order.order_id.clone()),
+ listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()),
+ listing_event_id: non_empty_string(
+ app_order.loaded.document.order.listing_event_id.clone(),
+ ),
+ buyer_account_id: buyer_account_id(&app_order.loaded.document),
+ buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()),
+ buyer_actor_source: buyer_actor_source(&app_order.loaded.document),
+ seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()),
+ issues,
+ reason: Some(if config.output.dry_run {
+ "dry run requested; order draft was not written".to_owned()
+ } else {
+ "app-authored local order record exported as a CLI order draft".to_owned()
+ }),
+ actions: vec![
+ format!(
+ "radroots order get {}",
+ app_order.loaded.document.order.order_id
+ ),
+ format!(
+ "radroots --relay wss://relay.example.com order submit {}",
+ app_order.loaded.document.order.order_id
+ ),
+ ],
+ })
+}
+
pub fn submit(
config: &RuntimeConfig,
args: &OrderSubmitArgs,
) -> Result<OrderSubmitView, RuntimeError> {
let file = draft_lookup_path(config, args.key.as_str());
- if !file.exists() {
+ let (loaded, source_issues) = if file.exists() {
+ match load_draft(file.as_path()) {
+ Ok(loaded) => (loaded, Vec::new()),
+ Err(reason) => {
+ return Ok(OrderSubmitView {
+ state: "error".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ file: file.display().to_string(),
+ listing_lookup: None,
+ listing_addr: None,
+ listing_event_id: None,
+ buyer_account_id: None,
+ buyer_pubkey: None,
+ buyer_actor_source: None,
+ buyer_custody: None,
+ buyer_write_capable: None,
+ seller_pubkey: None,
+ event_id: None,
+ event_kind: None,
+ dry_run: config.output.dry_run,
+ deduplicated: false,
+ target_relays: Vec::new(),
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: None,
+ signer_session_id: None,
+ requested_signer_session_id: None,
+ reason: Some(reason),
+ job: None,
+ issues: Vec::new(),
+ actions: Vec::new(),
+ });
+ }
+ }
+ } else if let Some(app_order) = load_app_order_record_for_lookup(config, args.key.as_str())? {
+ (app_order.loaded, app_order.source_issues)
+ } else {
return Ok(OrderSubmitView {
state: "missing".to_owned(),
source: ORDER_SOURCE.to_owned(),
@@ -644,46 +918,10 @@ pub fn submit(
"radroots basket create".to_owned(),
],
});
- }
-
- let loaded = match load_draft(file.as_path()) {
- Ok(loaded) => loaded,
- Err(reason) => {
- return Ok(OrderSubmitView {
- state: "error".to_owned(),
- source: ORDER_SOURCE.to_owned(),
- order_id: args.key.clone(),
- file: file.display().to_string(),
- listing_lookup: None,
- listing_addr: None,
- listing_event_id: None,
- buyer_account_id: None,
- buyer_pubkey: None,
- buyer_actor_source: None,
- buyer_custody: None,
- buyer_write_capable: None,
- seller_pubkey: None,
- event_id: None,
- event_kind: None,
- dry_run: config.output.dry_run,
- deduplicated: false,
- target_relays: Vec::new(),
- connected_relays: Vec::new(),
- acknowledged_relays: Vec::new(),
- failed_relays: Vec::new(),
- idempotency_key: args.idempotency_key.clone(),
- signer_mode: None,
- signer_session_id: None,
- requested_signer_session_id: None,
- reason: Some(reason),
- job: None,
- issues: Vec::new(),
- actions: Vec::new(),
- });
- }
};
- let issues = collect_issues(&loaded.document);
+ let mut issues = collect_issues(&loaded.document);
+ issues.extend(source_issues.clone());
if !issues.is_empty() {
let mut actions = actions_for_document(&loaded.document, loaded.file.as_path(), &issues);
actions.push(format!(
@@ -9054,6 +9292,14 @@ fn view_from_loaded(
config: &RuntimeConfig,
loaded: LoadedOrderDraft,
) -> Result<OrderGetView, RuntimeError> {
+ view_from_loaded_with_source_issues(config, loaded, &[])
+}
+
+fn view_from_loaded_with_source_issues(
+ config: &RuntimeConfig,
+ loaded: LoadedOrderDraft,
+ source_issues: &[OrderIssueView],
+) -> Result<OrderGetView, RuntimeError> {
let OrderInspection {
state,
ready_for_submit,
@@ -9063,7 +9309,7 @@ fn view_from_loaded(
buyer_custody,
buyer_write_capable,
issues,
- } = inspect_document(config, &loaded.document)?;
+ } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?;
let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice());
@@ -9107,6 +9353,14 @@ fn summary_from_loaded(
config: &RuntimeConfig,
loaded: &LoadedOrderDraft,
) -> Result<OrderSummaryView, RuntimeError> {
+ summary_from_loaded_with_source_issues(config, loaded, &[])
+}
+
+fn summary_from_loaded_with_source_issues(
+ config: &RuntimeConfig,
+ loaded: &LoadedOrderDraft,
+ source_issues: &[OrderIssueView],
+) -> Result<OrderSummaryView, RuntimeError> {
let OrderInspection {
state,
ready_for_submit,
@@ -9116,7 +9370,7 @@ fn summary_from_loaded(
buyer_custody,
buyer_write_capable,
issues,
- } = inspect_document(config, &loaded.document)?;
+ } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?;
Ok(OrderSummaryView {
id: loaded.document.order.order_id.clone(),
@@ -9166,10 +9420,384 @@ fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView {
}
}
+fn app_order_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> {
+ let mut app_records = Vec::new();
+ let mut before_cursor = None::<(i64, i64)>;
+ loop {
+ let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor {
+ list_shared_records_before(
+ config,
+ before_change_seq,
+ before_seq,
+ ORDER_APP_RECORD_LIST_LIMIT,
+ )?
+ } else {
+ list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)?
+ };
+ let Some(next_cursor) = shared_records
+ .last()
+ .map(|record| (record.change_seq, record.seq))
+ else {
+ break;
+ };
+ let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize;
+ app_records.extend(shared_records.into_iter().filter(is_app_order_local_record));
+ if !has_more {
+ break;
+ }
+ before_cursor = Some(next_cursor);
+ }
+ Ok(app_records)
+}
+
+fn is_app_order_local_record(record: &LocalEventRecord) -> bool {
+ record.source_runtime == SourceRuntime::App
+ && record.family == LocalRecordFamily::LocalWork
+ && local_record_kind(record).as_deref() == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND)
+}
+
+fn current_app_order_record_entries(
+ mut records: Vec<LocalEventRecord>,
+) -> Vec<AppOrderRecordListEntry> {
+ records.sort_by(|left, right| {
+ right
+ .change_seq
+ .cmp(&left.change_seq)
+ .then_with(|| right.seq.cmp(&left.seq))
+ .then_with(|| left.record_id.cmp(&right.record_id))
+ });
+
+ let mut entries = Vec::<AppOrderRecordListEntry>::new();
+ let mut seen = HashMap::<String, usize>::new();
+ for record in records {
+ let key = app_order_record_current_key(&record);
+ if let Some(index) = seen.get(&key).copied() {
+ entries[index].superseded_count += 1;
+ } else {
+ seen.insert(key, entries.len());
+ entries.push(AppOrderRecordListEntry {
+ record,
+ superseded_count: 0,
+ });
+ }
+ }
+ entries
+}
+
+fn current_app_order_record_for(
+ config: &RuntimeConfig,
+ record: &LocalEventRecord,
+) -> Result<Option<LocalEventRecord>, RuntimeError> {
+ let key = app_order_record_current_key(record);
+ Ok(app_order_local_records(config)?
+ .into_iter()
+ .filter(|candidate| app_order_record_current_key(candidate) == key)
+ .max_by(|left, right| {
+ left.change_seq
+ .cmp(&right.change_seq)
+ .then_with(|| left.seq.cmp(&right.seq))
+ }))
+}
+
+fn load_app_order_record_for_lookup(
+ config: &RuntimeConfig,
+ lookup: &str,
+) -> Result<Option<LoadedAppOrderRecord>, RuntimeError> {
+ if let Some(record) = get_shared_record(config, lookup)?
+ && is_app_order_local_record(&record)
+ {
+ return load_app_order_record_from_record(config, record).map(Some);
+ }
+ for entry in current_app_order_record_entries(app_order_local_records(config)?) {
+ if app_order_record_order_id(&entry.record).as_deref() == Some(lookup) {
+ return load_app_order_record_from_record(config, entry.record).map(Some);
+ }
+ }
+ Ok(None)
+}
+
+fn load_app_order_record_from_record(
+ config: &RuntimeConfig,
+ record: LocalEventRecord,
+) -> Result<LoadedAppOrderRecord, RuntimeError> {
+ let mut source_issues = app_order_record_source_issues(config, &record)?;
+ let payload = record.local_work_json.clone().unwrap_or(Value::Null);
+ let document = match payload.get("document").cloned() {
+ Some(value) => match serde_json::from_value::<OrderDraftDocument>(value) {
+ Ok(document) => document,
+ Err(error) => {
+ source_issues.push(issue_with_code(
+ "invalid_app_order_record",
+ "document",
+ format!("app-authored order document cannot be decoded: {error}"),
+ ));
+ placeholder_app_order_document(&record)
+ }
+ },
+ None => {
+ source_issues.push(issue_with_code(
+ "invalid_app_order_record",
+ "document",
+ "app-authored order record is missing document",
+ ));
+ placeholder_app_order_document(&record)
+ }
+ };
+ Ok(LoadedAppOrderRecord {
+ loaded: LoadedOrderDraft {
+ file: PathBuf::from(format!("shared-local-events/{}", record.record_id)),
+ updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(),
+ document,
+ },
+ record,
+ source_issues,
+ })
+}
+
+fn app_order_record_source_issues(
+ config: &RuntimeConfig,
+ record: &LocalEventRecord,
+) -> Result<Vec<OrderIssueView>, RuntimeError> {
+ let mut issues = Vec::new();
+ if record.source_runtime != SourceRuntime::App {
+ issues.push(issue_with_code(
+ "app_order_unsupported",
+ "source_runtime",
+ "order record must come from radroots_app",
+ ));
+ }
+ if record.family != LocalRecordFamily::LocalWork {
+ issues.push(issue_with_code(
+ "app_order_unsupported",
+ "family",
+ "order record must be shared local work",
+ ));
+ }
+ if record.status != LocalRecordStatus::LocalSaved {
+ issues.push(issue_with_code(
+ "app_order_unsupported",
+ "status",
+ format!(
+ "order record status `{}` is not consumable as local saved work",
+ record.status.as_str()
+ ),
+ ));
+ }
+ let Some(payload) = record.local_work_json.as_ref() else {
+ issues.push(issue_with_code(
+ "invalid_app_order_record",
+ "local_work_json",
+ "app-authored order record is missing local work payload",
+ ));
+ return Ok(issues);
+ };
+ let current = payload["currentness"]["current"].as_bool() == Some(true);
+ if !current {
+ issues.push(issue_with_code(
+ "app_order_stale",
+ "currentness.current",
+ "app-authored order record is not marked current",
+ ));
+ }
+ if current && let Err(error) = validate_buyer_order_request_local_work_payload(payload) {
+ issues.push(issue_with_code(
+ "invalid_app_order_record",
+ "local_work_json",
+ error.to_string(),
+ ));
+ }
+ if payload["support_status"]["state"].as_str() != Some("supported") {
+ issues.push(issue_with_code(
+ "app_order_unsupported",
+ "support_status.state",
+ "app-authored order record is not marked supported",
+ ));
+ }
+ if let Some(support_issues) = payload["support_status"]["issues"].as_array() {
+ for support_issue in support_issues {
+ if let Some(support_issue) = support_issue.as_str() {
+ issues.push(issue_with_code(
+ "app_order_unsupported",
+ "support_status.issues",
+ format!("app order support issue: {support_issue}"),
+ ));
+ }
+ }
+ }
+ if let Some(current_record) = current_app_order_record_for(config, record)?
+ && current_record.record_id != record.record_id
+ {
+ issues.push(issue_with_code(
+ "app_order_stale",
+ "record_id",
+ format!(
+ "app-authored local order record `{}` was superseded by `{}`",
+ record.record_id, current_record.record_id
+ ),
+ ));
+ }
+ Ok(issues)
+}
+
+fn source_and_document_issues(
+ config: &RuntimeConfig,
+ app_order: &LoadedAppOrderRecord,
+) -> Result<Vec<OrderIssueView>, RuntimeError> {
+ Ok(inspect_document_with_source_issues(
+ config,
+ &app_order.loaded.document,
+ app_order.source_issues.as_slice(),
+ )?
+ .issues)
+}
+
+fn app_order_record_summary(
+ config: &RuntimeConfig,
+ record: &LocalEventRecord,
+ superseded_count: usize,
+) -> Result<OrderAppRecordSummaryView, RuntimeError> {
+ let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned());
+ let app_order = load_app_order_record_from_record(config, record.clone())?;
+ let issues = source_and_document_issues(config, &app_order)?;
+ let exportable = issues.is_empty();
+ let reason = issues.first().map(|issue| issue.message.clone());
+ let document = &app_order.loaded.document;
+ Ok(OrderAppRecordSummaryView {
+ record_id: record.record_id.clone(),
+ seq: record.seq,
+ change_seq: record.change_seq,
+ superseded_count,
+ record_kind,
+ status: record.status.as_str().to_owned(),
+ source_runtime: record.source_runtime.as_str().to_owned(),
+ owner_account_id: record.owner_account_id.clone(),
+ owner_pubkey: record.owner_pubkey.clone(),
+ farm_id: record.farm_id.clone(),
+ listing_addr: record
+ .listing_addr
+ .clone()
+ .or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())),
+ order_id: non_empty_string(document.order.order_id.clone()),
+ buyer_account_id: buyer_account_id(document),
+ buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()),
+ seller_pubkey: non_empty_string(document.order.seller_pubkey.clone()),
+ ready_for_submit: exportable,
+ exportable,
+ reason,
+ actions: if exportable {
+ vec![
+ format!("radroots order get {}", document.order.order_id),
+ format!("radroots order app export {}", record.record_id),
+ format!(
+ "radroots --relay wss://relay.example.com order submit {}",
+ document.order.order_id
+ ),
+ ]
+ } else {
+ Vec::new()
+ },
+ })
+}
+
+fn app_order_record_current_key(record: &LocalEventRecord) -> String {
+ app_order_record_order_id(record)
+ .map(|order_id| format!("order:{order_id}"))
+ .unwrap_or_else(|| format!("record:{}", record.record_id))
+}
+
+fn app_order_record_order_id(record: &LocalEventRecord) -> Option<String> {
+ record
+ .local_work_json
+ .as_ref()
+ .and_then(|payload| payload["document"]["order"]["order_id"].as_str())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned)
+}
+
+fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocument {
+ OrderDraftDocument {
+ version: 0,
+ kind: "invalid_app_order_record".to_owned(),
+ order: OrderDraft {
+ order_id: app_order_record_order_id(record).unwrap_or_else(|| record.record_id.clone()),
+ listing_addr: String::new(),
+ listing_event_id: String::new(),
+ buyer_pubkey: String::new(),
+ seller_pubkey: String::new(),
+ items: Vec::new(),
+ economics: None,
+ },
+ buyer_actor: OrderDraftBuyerActor {
+ account_id: String::new(),
+ pubkey: String::new(),
+ source: String::new(),
+ },
+ listing_lookup: None,
+ }
+}
+
+fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str {
+ if issues.iter().any(|issue| issue.code == "app_order_stale") {
+ "stale"
+ } else if issues.iter().any(|issue| {
+ issue.code == "app_order_unsupported" || issue.code == "invalid_app_order_record"
+ }) {
+ "unsupported"
+ } else {
+ "invalid"
+ }
+}
+
+fn order_export_output_path(
+ config: &RuntimeConfig,
+ output: Option<&PathBuf>,
+ order_id: &str,
+) -> PathBuf {
+ output
+ .cloned()
+ .unwrap_or_else(|| drafts_dir(config).join(format!("{order_id}.toml")))
+}
+
+fn validate_order_export_output_target(output_path: &Path) -> Result<(), RuntimeError> {
+ if output_path.exists() {
+ return Err(RuntimeError::Config(format!(
+ "order draft output {} must not already exist",
+ output_path.display()
+ )));
+ }
+ if let Some(parent) = output_path.parent() {
+ if parent.exists() && !parent.is_dir() {
+ return Err(RuntimeError::Config(format!(
+ "order draft parent {} is not a directory",
+ parent.display()
+ )));
+ }
+ }
+ Ok(())
+}
+
+fn local_record_kind(record: &LocalEventRecord) -> Option<String> {
+ record
+ .local_work_json
+ .as_ref()
+ .and_then(|payload| payload.get("record_kind"))
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+}
+
fn inspect_document(
config: &RuntimeConfig,
document: &OrderDraftDocument,
) -> Result<OrderInspection, RuntimeError> {
+ inspect_document_with_source_issues(config, document, &[])
+}
+
+fn inspect_document_with_source_issues(
+ config: &RuntimeConfig,
+ document: &OrderDraftDocument,
+ source_issues: &[OrderIssueView],
+) -> Result<OrderInspection, RuntimeError> {
let listing_addr = non_empty_string(document.order.listing_addr.clone());
let listing_event_id = non_empty_string(document.order.listing_event_id.clone());
let parsed_listing_addr = listing_addr
@@ -9183,6 +9811,7 @@ fn inspect_document(
let mut issues = collect_issues(document);
let buyer_readiness = inspect_buyer_actor_readiness(config, document)?;
issues.extend(buyer_readiness.issues);
+ issues.extend(source_issues.iter().cloned());
let ready_for_submit = issues.is_empty();
let state = if ready_for_submit {
"ready".to_owned()
@@ -9283,7 +9912,7 @@ fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> {
if !is_valid_order_id(document.order.order_id.as_str()) {
issues.push(issue(
"order.order_id",
- "order_id must look like `ord_<base64url>`",
+ "order_id must look like `ord_<base64url>` or a canonical UUID",
));
}
@@ -11153,10 +11782,26 @@ fn next_revision_id() -> String {
}
fn is_valid_order_id(value: &str) -> bool {
- let Some(encoded) = value.strip_prefix("ord_") else {
+ if let Some(encoded) = value.strip_prefix("ord_") {
+ return encoded.len() == 22 && is_d_tag_base64url(encoded);
+ }
+ is_canonical_uuid(value)
+}
+
+fn is_canonical_uuid(value: &str) -> bool {
+ if value.len() != 36 {
return false;
- };
- encoded.len() == 22 && is_d_tag_base64url(encoded)
+ }
+ for (index, character) in value.chars().enumerate() {
+ if matches!(index, 8 | 13 | 18 | 23) {
+ if character != '-' {
+ return false;
+ }
+ } else if !character.is_ascii_hexdigit() {
+ return false;
+ }
+ }
+ true
}
fn is_valid_event_id(value: &str) -> bool {
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -213,6 +213,12 @@ pub struct OrderSubmitArgs {
}
#[derive(Debug, Clone)]
+pub struct OrderAppRecordExportArgs {
+ pub record_id: String,
+ pub output: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone)]
pub struct OrderRebindArgs {
pub key: String,
pub selector: String,
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -226,6 +226,10 @@ impl TargetCommand {
OrderCommand::Submit(_) => "order.submit",
OrderCommand::Get(_) => "order.get",
OrderCommand::List => "order.list",
+ OrderCommand::App(app) => match &app.command {
+ OrderAppCommand::List => "order.app.list",
+ OrderAppCommand::Export(_) => "order.app.export",
+ },
OrderCommand::Rebind(_) => "order.rebind",
OrderCommand::Accept(_) => "order.accept",
OrderCommand::Decline(_) => "order.decline",
@@ -855,6 +859,7 @@ pub enum OrderCommand {
Submit(OrderSubmitArgs),
Get(OrderKeyArgs),
List,
+ App(OrderAppArgs),
Rebind(OrderRebindArgs),
Accept(OrderKeyArgs),
Decline(OrderDeclineArgs),
@@ -879,6 +884,25 @@ pub struct OrderKeyArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct OrderAppArgs {
+ #[command(subcommand)]
+ pub command: OrderAppCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum OrderAppCommand {
+ List,
+ Export(OrderAppExportArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct OrderAppExportArgs {
+ pub record_id: Option<String>,
+ #[arg(long)]
+ pub output: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderRebindArgs {
pub order_id: Option<String>,
pub selector: Option<String>,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -14,8 +14,8 @@ use radroots_events::trade::{
};
use radroots_events_codec::trade::active_trade_order_request_event_build;
use radroots_local_events::{
- LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus,
- PublishOutboxStatus, SourceRuntime,
+ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore,
+ LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
};
use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event};
use radroots_replica_db::{farm, farm_member_claim, migrations};
@@ -478,6 +478,179 @@ fn append_app_local_record(input: LocalEventRecordInput, sandbox: &RadrootsCliSa
.expect("append app local event record");
}
+fn seed_app_order_record(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ order_id: &str,
+ listing_addr: &str,
+ listing_event_id: &str,
+) -> String {
+ seed_app_order_record_variant(
+ sandbox,
+ account_id,
+ buyer_pubkey,
+ seller_pubkey,
+ order_id,
+ listing_addr,
+ listing_event_id,
+ true,
+ "supported",
+ Vec::new(),
+ )
+}
+
+fn seed_app_order_record_variant(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ order_id: &str,
+ listing_addr: &str,
+ listing_event_id: &str,
+ current: bool,
+ support_state: &str,
+ support_issues: Vec<&str>,
+) -> String {
+ let record_id = format!("app:local_work:order_request:{order_id}");
+ let support_issues = support_issues
+ .into_iter()
+ .map(|issue| Value::String(issue.to_owned()))
+ .collect::<Vec<_>>();
+ let payload = json!({
+ "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND,
+ "scope": "app",
+ "exportability": {
+ "state": "exportable",
+ },
+ "support_status": {
+ "state": support_state,
+ "issues": support_issues,
+ },
+ "currentness": {
+ "current": current,
+ "source": "app_sqlite_order",
+ "record_id": record_id,
+ "order_id": order_id,
+ "order_updated_at": 1_779_000_010,
+ "created_at_ms": 1_779_000_010_000_i64,
+ },
+ "no_payment": {
+ "payment_required": false,
+ "settlement_deferred": true,
+ "payment_state": "not_applicable",
+ },
+ "document": {
+ "version": 1,
+ "kind": "order_draft_v1",
+ "order": {
+ "order_id": order_id,
+ "listing_addr": listing_addr,
+ "listing_event_id": listing_event_id,
+ "buyer_pubkey": buyer_pubkey,
+ "seller_pubkey": seller_pubkey,
+ "items": [
+ {
+ "bin_id": "bin-1",
+ "bin_count": 2,
+ }
+ ],
+ "economics": {
+ "quote_id": format!("app-order:{order_id}"),
+ "quote_version": 1,
+ "pricing_basis": "listing_event",
+ "currency": "USD",
+ "items": [
+ {
+ "bin_id": "bin-1",
+ "bin_count": 2,
+ "quantity_amount": "1",
+ "quantity_unit": "each",
+ "unit_price_amount": "6",
+ "unit_price_currency": "USD",
+ "line_subtotal": {
+ "amount": "12",
+ "currency": "USD",
+ },
+ }
+ ],
+ "discounts": [],
+ "adjustments": [],
+ "subtotal": {
+ "amount": "12",
+ "currency": "USD",
+ },
+ "discount_total": {
+ "amount": "0",
+ "currency": "USD",
+ },
+ "adjustment_total": {
+ "amount": "0",
+ "currency": "USD",
+ },
+ "total": {
+ "amount": "12",
+ "currency": "USD",
+ },
+ },
+ },
+ "buyer_actor": {
+ "account_id": account_id,
+ "pubkey": buyer_pubkey,
+ "source": "resolved_account",
+ },
+ "listing_lookup": listing_addr,
+ },
+ "app_order": {
+ "order_id": order_id,
+ "order_number": 1,
+ "farm_id": "018f47a8-7b2c-7000-8000-0000000000f1",
+ "farm_display_name": "CLI Interop Farm",
+ "farm_key": "pasture-eggs",
+ "status": "placed",
+ "buyer_context_key": "buyer_context",
+ "lines": [
+ {
+ "line_id": format!("{order_id}:product-eggs"),
+ "product_id": "product-eggs",
+ "listing_addr": listing_addr,
+ "listing_event_id": listing_event_id,
+ "seller_pubkey": seller_pubkey,
+ }
+ ],
+ },
+ });
+ append_app_local_record(
+ LocalEventRecordInput {
+ record_id: record_id.clone(),
+ family: LocalRecordFamily::LocalWork,
+ status: LocalRecordStatus::LocalSaved,
+ source_runtime: SourceRuntime::App,
+ created_at_ms: 1_779_000_010_000,
+ inserted_at_ms: 1_779_000_010_000,
+ owner_account_id: Some(account_id.to_owned()),
+ owner_pubkey: Some(buyer_pubkey.to_owned()),
+ farm_id: Some("018f47a8-7b2c-7000-8000-0000000000f1".to_owned()),
+ listing_addr: Some(listing_addr.to_owned()),
+ local_work_json: Some(payload),
+ event_id: None,
+ event_kind: None,
+ event_pubkey: None,
+ event_created_at: None,
+ event_tags_json: None,
+ event_content: None,
+ event_sig: None,
+ raw_event_json: None,
+ outbox_status: PublishOutboxStatus::None,
+ relay_set_fingerprint: None,
+ relay_delivery_json: None,
+ },
+ sandbox,
+ );
+ record_id
+}
+
#[test]
fn root_help_exposes_only_target_namespaces() {
let output = radroots().arg("--help").output().expect("run root help");
@@ -4134,6 +4307,207 @@ fn listing_app_records_export_uses_record_owner_over_body_pubkey() {
}
#[test]
+fn order_app_records_list_export_get_and_submit_supported_app_order() {
+ let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey");
+ let seller_pubkey = identity_public(73).public_key_hex;
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let listing_addr = format!("30402:{seller_pubkey}:{listing_d_tag}");
+ let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str());
+ let order_id = "018f47a8-7b2c-7000-8000-000000000011";
+ let record_id = seed_app_order_record(
+ &sandbox,
+ account_id,
+ buyer_pubkey,
+ seller_pubkey.as_str(),
+ order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ );
+
+ let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]);
+ assert_eq!(app_list["operation_id"], "order.app.list");
+ assert_eq!(app_list["result"]["state"], "ready");
+ assert_eq!(app_list["result"]["count"], 1);
+ assert_eq!(app_list["result"]["records"][0]["record_id"], record_id);
+ assert_eq!(app_list["result"]["records"][0]["order_id"], order_id);
+ assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], true);
+ assert_eq!(app_list["result"]["records"][0]["exportable"], true);
+ assert_no_removed_command_reference(&app_list, &["order", "app", "list"]);
+ assert_no_daemon_runtime_reference(&app_list, &["order", "app", "list"]);
+
+ let orders = sandbox.json_success(&["--format", "json", "order", "list"]);
+ assert_eq!(orders["operation_id"], "order.list");
+ assert_eq!(orders["result"]["state"], "ready");
+ assert_eq!(orders["result"]["count"], 1);
+ assert_eq!(orders["result"]["orders"][0]["id"], order_id);
+ assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], true);
+ assert_eq!(
+ orders["result"]["orders"][0]["listing_event_id"],
+ listing_event_id
+ );
+ assert_eq!(
+ orders["result"]["orders"][0]["buyer_account_id"],
+ account_id
+ );
+ assert_eq!(
+ orders["result"]["orders"][0]["file"],
+ format!("shared-local-events/{record_id}")
+ );
+ assert_no_removed_command_reference(&orders, &["order", "list"]);
+ assert_no_daemon_runtime_reference(&orders, &["order", "list"]);
+
+ let get_by_record =
+ sandbox.json_success(&["--format", "json", "order", "get", record_id.as_str()]);
+ assert_eq!(get_by_record["operation_id"], "order.get");
+ assert_eq!(get_by_record["result"]["state"], "ready");
+ assert_eq!(get_by_record["result"]["order_id"], order_id);
+ assert_eq!(get_by_record["result"]["ready_for_submit"], true);
+
+ let export_path = sandbox.root().join("app-order.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(dry_run["operation_id"], "order.app.export");
+ assert_eq!(dry_run["result"]["state"], "dry_run");
+ assert_eq!(dry_run["result"]["valid"], true);
+ assert!(!export_path.exists());
+
+ let export = sandbox.json_success(&[
+ "--format",
+ "json",
+ "order",
+ "app",
+ "export",
+ record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(export["operation_id"], "order.app.export");
+ assert_eq!(export["result"]["state"], "exported");
+ assert_eq!(export["result"]["order_id"], order_id);
+ assert!(export_path.exists());
+ let exported_contents = fs::read_to_string(&export_path).expect("exported order draft");
+ assert!(exported_contents.contains("kind = \"order_draft_v1\""));
+ assert!(exported_contents.contains(format!("order_id = \"{order_id}\"").as_str()));
+ assert!(exported_contents.contains("source = \"resolved_account\""));
+
+ let (dry_output, submit) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "submit",
+ record_id.as_str(),
+ ]);
+ assert!(!dry_output.status.success());
+ assert_eq!(dry_output.status.code(), Some(8));
+ assert_eq!(submit["operation_id"], "order.submit");
+ assert_eq!(submit["errors"][0]["code"], "network_unavailable");
+ assert!(
+ submit["errors"][0]["message"]
+ .as_str()
+ .expect("submit message")
+ .contains("order submit requires at least one configured relay")
+ );
+}
+
+#[test]
+fn order_app_records_fail_closed_when_not_current_or_supported() {
+ let sandbox = RadrootsCliSandbox::new();
+ let account = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = account["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey");
+ let seller_pubkey = identity_public(74).public_key_hex;
+ let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ");
+ let listing_event_id = "2".repeat(64);
+ let stale_order_id = "018f47a8-7b2c-7000-8000-000000000012";
+ let stale_record_id = seed_app_order_record_variant(
+ &sandbox,
+ account_id,
+ buyer_pubkey,
+ seller_pubkey.as_str(),
+ stale_order_id,
+ listing_addr.as_str(),
+ listing_event_id.as_str(),
+ false,
+ "supported",
+ Vec::new(),
+ );
+
+ let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]);
+ assert_eq!(
+ app_list["result"]["records"][0]["record_id"],
+ stale_record_id
+ );
+ assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false);
+ assert_eq!(app_list["result"]["records"][0]["exportable"], false);
+ assert!(
+ app_list["result"]["records"][0]["reason"]
+ .as_str()
+ .expect("stale reason")
+ .contains("not marked current")
+ );
+
+ let export_path = sandbox.root().join("stale-app-order.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let (output, stale_export) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "order",
+ "app",
+ "export",
+ stale_record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert!(!output.status.success());
+ assert_eq!(stale_export["operation_id"], "order.app.export");
+ assert_eq!(stale_export["result"], Value::Null);
+ assert_eq!(stale_export["errors"][0]["detail"]["state"], "stale");
+ assert_eq!(stale_export["errors"][0]["detail"]["valid"], false);
+ assert!(!export_path.exists());
+
+ let (submit_output, submit) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "submit",
+ stale_record_id.as_str(),
+ ]);
+ assert!(!submit_output.status.success());
+ assert_eq!(submit_output.status.code(), Some(3));
+ assert_eq!(submit["operation_id"], "order.submit");
+ assert_eq!(submit["errors"][0]["code"], "operation_unavailable");
+ assert_eq!(
+ submit["errors"][0]["detail"]["issues"][0]["code"],
+ "app_order_stale"
+ );
+}
+
+#[test]
fn farm_publish_writes_acknowledged_signed_outbox_records() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);