commit d27ff693775d5c52e6c3c5c90480869d417e8b1d
parent 3885ce5f284399bafcd63c5ef53a46698c0aa946
Author: triesap <tyson@radroots.org>
Date: Sat, 23 May 2026 08:15:38 +0000
cli: export app-authored listing records
- adds listing app list and export commands for shared app records
- materializes app listing payloads as canonical CLI listing drafts
- keeps publish routed through existing listing publish files
- covers discovery dry-run export and validation with target CLI tests
Diffstat:
10 files changed, 855 insertions(+), 10 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -2568,6 +2568,91 @@ impl ListingListView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct ListingAppRecordListView {
+ pub state: String,
+ pub source: String,
+ pub count: usize,
+ pub local_events_db: String,
+ pub records: Vec<ListingAppRecordSummaryView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl ListingAppRecordListView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ListingAppRecordSummaryView {
+ pub record_id: String,
+ pub seq: i64,
+ 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 listing_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub title: Option<String>,
+ 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 ListingAppRecordExportView {
+ 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 listing_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_actor_source: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_d_tag: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<ListingValidationIssueView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl ListingAppRecordExportView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "invalid" | "unsupported" => CommandDisposition::ValidationFailed,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct ListingSummaryView {
pub id: String,
pub state: String,
diff --git a/src/main.rs b/src/main.rs
@@ -229,6 +229,12 @@ fn execute_request(
TargetOperationRequest::ListingList(request) => {
execute_with(ListingOperationService::new(config), request)
}
+ TargetOperationRequest::ListingAppList(request) => {
+ execute_with(ListingOperationService::new(config), request)
+ }
+ TargetOperationRequest::ListingAppExport(request) => {
+ execute_with(ListingOperationService::new(config), request)
+ }
TargetOperationRequest::ListingUpdate(request) => {
execute_with(ListingOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1284,7 +1284,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
use crate::target_cli::{
AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand,
BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand,
- FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand,
+ FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand,
MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand,
OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand,
OrderSettlementCommand, OrderStatusCommand, TargetCommand, ValidationCommand,
@@ -1378,6 +1378,13 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "discount_currency", &args.discount_currency);
}
ListingCommand::Get(args) => insert_string(&mut input, "key", &args.key),
+ ListingCommand::App(args) => match &args.command {
+ ListingAppCommand::Export(args) => {
+ insert_string(&mut input, "record_id", &args.record_id);
+ insert_path(&mut input, "output", &args.output);
+ }
+ ListingAppCommand::List => {}
+ },
ListingCommand::Update(args)
| ListingCommand::Validate(args)
| ListingCommand::Publish(args)
@@ -1632,6 +1639,8 @@ target_operation_contracts! {
ListingCreate => (ListingCreateRequest, ListingCreateResult, "listing.create"),
ListingGet => (ListingGetRequest, ListingGetResult, "listing.get"),
ListingList => (ListingListRequest, ListingListResult, "listing.list"),
+ ListingAppList => (ListingAppListRequest, ListingAppListResult, "listing.app.list"),
+ ListingAppExport => (ListingAppExportRequest, ListingAppExportResult, "listing.app.export"),
ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"),
ListingValidate => (ListingValidateRequest, ListingValidateResult, "listing.validate"),
ListingRebind => (ListingRebindRequest, ListingRebindResult, "listing.rebind"),
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -3,8 +3,9 @@ use std::path::PathBuf;
use serde::Serialize;
use serde_json::Value;
-use crate::domain::runtime::{CommandDisposition, ListingMutationView};
+use crate::domain::runtime::{CommandDisposition, ListingAppRecordExportView, ListingMutationView};
use crate::operation_adapter::{
+ ListingAppExportRequest, ListingAppExportResult, ListingAppListRequest, ListingAppListResult,
ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult,
ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult,
ListingPublishRequest, ListingPublishResult, ListingRebindRequest, ListingRebindResult,
@@ -15,7 +16,8 @@ use crate::operation_adapter::{
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
- ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs,
+ ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs,
+ ListingRebindArgs, RecordLookupArgs,
};
pub struct ListingOperationService<'a> {
@@ -106,6 +108,44 @@ impl OperationService<ListingListRequest> for ListingOperationService<'_> {
}
}
+impl OperationService<ListingAppListRequest> for ListingOperationService<'_> {
+ type Result = ListingAppListResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<ListingAppListRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::app_record_list(self.config),
+ )?;
+ serialized_operation_result::<ListingAppListResult, _>(&view)
+ }
+}
+
+impl OperationService<ListingAppExportRequest> for ListingOperationService<'_> {
+ type Result = ListingAppExportResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<ListingAppExportRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let args = ListingAppRecordExportArgs {
+ record_id: required_string(&request, "record_id")?,
+ output: optional_path(&request, "output"),
+ };
+ let mut config = self.config.clone();
+ if request.context.dry_run {
+ config.output.dry_run = true;
+ }
+ let view = map_runtime(
+ request.operation_id(),
+ crate::runtime::listing::app_record_export(&config, &args),
+ )?;
+ listing_app_record_export_result::<ListingAppExportResult>(request.operation_id(), &view)
+ }
+}
+
impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> {
type Result = ListingUpdateResult;
@@ -303,6 +343,50 @@ fn listing_relay_unavailable(view: &ListingMutationView) -> bool {
|| !view.failed_relays.is_empty())
}
+fn listing_app_record_export_result<R>(
+ operation_id: &str,
+ view: &ListingAppRecordExportView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_operation_result::<R, _>(view),
+ CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail(
+ operation_id,
+ view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "app-authored local 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 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 record export finished with state `{}`",
+ view.state
+ )
+ }),
+ )),
+ }
+}
+
fn map_runtime<T>(
operation_id: &str,
result: Result<T, RuntimeError>,
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -608,6 +608,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false
),
operation!(
+ "listing.app.list",
+ "radroots listing app list",
+ "listing",
+ "listing_app_list",
+ "ListingAppListRequest",
+ "ListingAppListResult",
+ "List app-authored shared local listing records.",
+ Seller,
+ false,
+ None,
+ Low,
+ false,
+ false
+ ),
+ operation!(
+ "listing.app.export",
+ "radroots listing app export",
+ "listing",
+ "listing_app_export",
+ "ListingAppExportRequest",
+ "ListingAppExportResult",
+ "Export an app-authored shared listing record as a CLI draft.",
+ Seller,
+ true,
+ None,
+ Medium,
+ false,
+ true
+ ),
+ operation!(
"listing.update",
"radroots listing update",
"listing",
@@ -1328,6 +1358,8 @@ mod tests {
"listing.create",
"listing.get",
"listing.list",
+ "listing.app.list",
+ "listing.app.export",
"listing.update",
"listing.validate",
"listing.rebind",
@@ -1388,6 +1420,7 @@ mod tests {
"farm.fulfillment.update",
"farm.publish",
"listing.create",
+ "listing.app.export",
"listing.update",
"listing.rebind",
"listing.publish",
@@ -1422,7 +1455,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 74);
+ assert_eq!(OPERATION_REGISTRY.len(), 76);
}
#[test]
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -20,6 +20,7 @@ use radroots_events::trade::RadrootsTradeListingValidationError;
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
use radroots_events_codec::wire::WireEventParts;
+use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime};
use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr};
use radroots_replica_db::{ReplicaSql, migrations};
use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
@@ -27,10 +28,11 @@ use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::publish::validate_listing_for_seller;
use radroots_trade::listing::validation::validate_listing_event;
use serde::{Deserialize, Serialize};
-use serde_json::json;
+use serde_json::{Value, json};
use crate::domain::runtime::{
- FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView,
+ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView,
+ ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView,
ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView,
ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView,
MarketReadinessView, RelayFailureView,
@@ -46,26 +48,30 @@ use crate::runtime::direct_relay::{
};
use crate::runtime::farm_config;
use crate::runtime::local_events::{
- append_local_work, append_signed_event, mark_signed_event_acknowledged,
- mark_signed_event_failed_for_publish_error,
+ append_local_work, append_signed_event, get_shared_record, list_shared_records,
+ mark_signed_event_acknowledged, mark_signed_event_failed_for_publish_error,
+ shared_local_events_db_path,
};
use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness,
};
use crate::runtime_args::{
- ListingCreateArgs, ListingFileArgs, ListingMutationArgs, ListingRebindArgs, RecordLookupArgs,
+ ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs,
+ ListingRebindArgs, RecordLookupArgs,
};
const DRAFT_KIND: &str = "listing_draft_v1";
const LISTING_SOURCE: &str = "local draft · local first";
const LISTING_READ_SOURCE: &str = "local replica · local first";
+const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app";
const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key";
const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · deferred";
const LISTING_DRAFTS_DIR: &str = "listings/drafts";
const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind";
+const APP_RECORD_LIST_LIMIT: u32 = 500;
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -660,6 +666,163 @@ pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> {
})
}
+pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListView, RuntimeError> {
+ let database_path = shared_local_events_db_path(config)?;
+ let records = list_shared_records(config, APP_RECORD_LIST_LIMIT)?
+ .into_iter()
+ .filter(|record| {
+ record.source_runtime == SourceRuntime::App
+ && record.family == LocalRecordFamily::LocalWork
+ && matches!(
+ local_record_kind(record).as_deref(),
+ Some("farm_config_v1" | DRAFT_KIND)
+ )
+ })
+ .map(|record| app_record_summary(&record))
+ .collect::<Vec<_>>();
+ let state = if records.is_empty() { "empty" } else { "ready" };
+ let actions = if records.is_empty() {
+ vec!["create or save a farm listing in radroots_app".to_owned()]
+ } else {
+ Vec::new()
+ };
+
+ Ok(ListingAppRecordListView {
+ state: state.to_owned(),
+ source: LISTING_APP_RECORD_SOURCE.to_owned(),
+ count: records.len(),
+ local_events_db: database_path.display().to_string(),
+ records,
+ actions,
+ })
+}
+
+pub fn app_record_export(
+ config: &RuntimeConfig,
+ args: &ListingAppRecordExportArgs,
+) -> Result<ListingAppRecordExportView, RuntimeError> {
+ let Some(record) = get_shared_record(config, args.record_id.as_str())? else {
+ return Ok(ListingAppRecordExportView {
+ state: "missing".to_owned(),
+ source: LISTING_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,
+ listing_id: None,
+ listing_addr: None,
+ seller_account_id: None,
+ seller_pubkey: None,
+ seller_actor_source: None,
+ farm_d_tag: None,
+ issues: Vec::new(),
+ reason: Some(format!(
+ "app-authored local record `{}` was not found",
+ args.record_id
+ )),
+ actions: vec!["radroots listing app list".to_owned()],
+ });
+ };
+
+ let draft = match app_listing_draft_from_record(&record) {
+ Ok(draft) => draft,
+ Err(reason) => {
+ return Ok(ListingAppRecordExportView {
+ state: "unsupported".to_owned(),
+ source: LISTING_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,
+ listing_id: None,
+ listing_addr: record.listing_addr.clone(),
+ seller_account_id: record.owner_account_id.clone(),
+ seller_pubkey: record.owner_pubkey.clone(),
+ seller_actor_source: None,
+ farm_d_tag: record.farm_id.clone(),
+ issues: vec![ListingValidationIssueView {
+ field: "local_work_json".to_owned(),
+ message: reason.clone(),
+ line: None,
+ }],
+ reason: Some(reason),
+ actions: vec!["radroots listing app list".to_owned()],
+ });
+ }
+ };
+ let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?;
+ validate_listing_output_target(output_path.as_path())?;
+ let contents = scaffold_contents(&draft)?;
+ let context = validation_context(config)?;
+ let issues = app_listing_export_issues(config, &draft, contents.as_str(), &context)?;
+ let listing_addr_value = app_record_listing_addr(&draft);
+
+ if !issues.is_empty() {
+ return Ok(ListingAppRecordExportView {
+ state: "invalid".to_owned(),
+ source: LISTING_APP_RECORD_SOURCE.to_owned(),
+ record_id: args.record_id.clone(),
+ dry_run: config.output.dry_run,
+ file: output_path.display().to_string(),
+ valid: false,
+ listing_id: non_empty(draft.listing.d_tag.clone()),
+ listing_addr: listing_addr_value,
+ seller_account_id: non_empty(draft.seller_actor.account_id.clone()),
+ seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()),
+ seller_actor_source: non_empty(draft.seller_actor.source.clone()),
+ farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()),
+ issues,
+ reason: Some(format!(
+ "app-authored local record `{}` does not validate as a CLI listing draft",
+ args.record_id
+ )),
+ actions: vec!["radroots listing app list".to_owned()],
+ });
+ }
+
+ if !config.output.dry_run {
+ write_listing_draft(output_path.as_path(), &draft, false)?;
+ }
+
+ Ok(ListingAppRecordExportView {
+ state: if config.output.dry_run {
+ "dry_run"
+ } else {
+ "exported"
+ }
+ .to_owned(),
+ source: LISTING_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,
+ listing_id: Some(draft.listing.d_tag.clone()),
+ listing_addr: app_record_listing_addr(&draft),
+ seller_account_id: Some(draft.seller_actor.account_id.clone()),
+ seller_pubkey: Some(draft.seller_actor.pubkey.clone()),
+ seller_actor_source: Some(draft.seller_actor.source.clone()),
+ farm_d_tag: Some(draft.listing.farm_d_tag.clone()),
+ issues: Vec::new(),
+ reason: Some(if config.output.dry_run {
+ "dry run requested; listing draft was not written".to_owned()
+ } else {
+ "app-authored listing record exported as a CLI listing draft".to_owned()
+ }),
+ actions: vec![
+ format!("radroots listing validate {}", output_path.display()),
+ format!("radroots listing publish {}", output_path.display()),
+ ],
+ })
+}
+
pub fn rebind(
config: &RuntimeConfig,
args: &ListingRebindArgs,
@@ -957,6 +1120,168 @@ fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> L
}
}
+fn app_record_summary(record: &LocalEventRecord) -> ListingAppRecordSummaryView {
+ let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned());
+ let parsed_listing = app_listing_draft_from_record(record);
+ let (listing_id, title, exportable, reason) = match (record_kind.as_str(), parsed_listing) {
+ (DRAFT_KIND, Ok(draft)) => (
+ non_empty(draft.listing.d_tag),
+ non_empty(draft.product.title),
+ true,
+ None,
+ ),
+ (DRAFT_KIND, Err(reason)) => (None, None, false, Some(reason)),
+ ("farm_config_v1", _) => (
+ None,
+ record
+ .local_work_json
+ .as_ref()
+ .and_then(|payload| payload["document"]["farm"]["name"].as_str())
+ .map(str::to_owned),
+ false,
+ Some("farm records provide defaults; export selects listing records".to_owned()),
+ ),
+ _ => (
+ None,
+ None,
+ false,
+ Some(format!("unsupported app record kind `{record_kind}`")),
+ ),
+ };
+ let actions = if exportable {
+ vec![format!("radroots listing app export {}", record.record_id)]
+ } else {
+ Vec::new()
+ };
+
+ ListingAppRecordSummaryView {
+ record_id: record.record_id.clone(),
+ seq: record.seq,
+ 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(),
+ listing_id,
+ title,
+ exportable,
+ reason,
+ actions,
+ }
+}
+
+fn app_listing_draft_from_record(
+ record: &LocalEventRecord,
+) -> Result<ListingDraftDocument, String> {
+ if record.source_runtime != SourceRuntime::App {
+ return Err(format!(
+ "record source_runtime `{}` is not app",
+ record.source_runtime.as_str()
+ ));
+ }
+ if record.family != LocalRecordFamily::LocalWork {
+ return Err(format!(
+ "record family `{}` is not local_work",
+ record.family.as_str()
+ ));
+ }
+ let payload = record
+ .local_work_json
+ .as_ref()
+ .ok_or_else(|| "record has no local_work_json payload".to_owned())?;
+ let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned());
+ if record_kind != DRAFT_KIND {
+ return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}"));
+ }
+ let document = payload
+ .get("document")
+ .cloned()
+ .ok_or_else(|| "record local_work_json.document is missing".to_owned())?;
+ let mut draft = serde_json::from_value::<ListingDraftDocument>(document)
+ .map_err(|error| format!("record listing document is invalid: {error}"))?;
+ if draft.seller_actor.account_id.trim().is_empty()
+ && let Some(account_id) = record.owner_account_id.as_ref()
+ {
+ draft.seller_actor.account_id = account_id.clone();
+ }
+ if draft.seller_actor.pubkey.trim().is_empty()
+ && let Some(pubkey) = record.owner_pubkey.as_ref()
+ {
+ draft.seller_actor.pubkey = pubkey.clone();
+ }
+ if draft.listing.farm_d_tag.trim().is_empty()
+ && let Some(farm_id) = record.farm_id.as_ref()
+ {
+ draft.listing.farm_d_tag = farm_id.clone();
+ }
+ normalize_app_listing_availability(&mut draft)?;
+ Ok(draft)
+}
+
+fn normalize_app_listing_availability(draft: &mut ListingDraftDocument) -> Result<(), String> {
+ let kind = draft.availability.kind.trim();
+ if kind.is_empty() || kind == "local" {
+ draft.availability.kind = "status".to_owned();
+ } else if !matches!(kind, "status" | "window") {
+ return Err(format!(
+ "unsupported app listing availability kind `{kind}`"
+ ));
+ }
+ if draft.availability.kind == "window" {
+ return Ok(());
+ }
+
+ let status = draft.availability.status.trim();
+ draft.availability.status = match status {
+ "" | "active" | "draft" | "published" => "active".to_owned(),
+ "archived" | "paused" | "sold" => {
+ return Err(format!(
+ "app listing status `{status}` is not exportable as a publishable CLI draft"
+ ));
+ }
+ other => return Err(format!("unsupported app listing status `{other}`")),
+ };
+ Ok(())
+}
+
+fn app_listing_export_issues(
+ config: &RuntimeConfig,
+ draft: &ListingDraftDocument,
+ contents: &str,
+ context: &ListingValidationContext,
+) -> Result<Vec<ListingValidationIssueView>, RuntimeError> {
+ let canonical = match canonicalize_draft(draft, contents, context) {
+ Ok(canonical) => canonical,
+ Err(error) => return Ok(vec![error.into_issue()]),
+ };
+ let mut issues = listing_ready_issues(&canonical, contents);
+ if let Some(issue) = listing_bound_account_issue(config, &canonical, contents)? {
+ issues.push(issue);
+ }
+ Ok(issues)
+}
+
+fn app_record_listing_addr(draft: &ListingDraftDocument) -> Option<String> {
+ let seller_pubkey = draft.seller_actor.pubkey.trim();
+ let listing_id = draft.listing.d_tag.trim();
+ if seller_pubkey.is_empty() || listing_id.is_empty() {
+ None
+ } else {
+ Some(listing_addr(seller_pubkey, listing_id))
+ }
+}
+
+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)
+}
+
pub fn get(
config: &RuntimeConfig,
args: &RecordLookupArgs,
diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs
@@ -1,4 +1,5 @@
use std::fs;
+use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -160,6 +161,36 @@ pub fn mark_signed_event_failed_for_publish_error(
)
}
+pub fn shared_local_events_db_path(config: &RuntimeConfig) -> Result<PathBuf, RuntimeError> {
+ Ok(shared_local_events_root(config)?.join(SHARED_LOCAL_EVENTS_DB_FILE))
+}
+
+pub fn list_shared_records(
+ config: &RuntimeConfig,
+ limit: u32,
+) -> Result<Vec<LocalEventRecord>, RuntimeError> {
+ let database_path = shared_local_events_db_path(config)?;
+ if !database_path.exists() {
+ return Ok(Vec::new());
+ }
+ let executor = SqliteExecutor::open(database_path)?;
+ let store = LocalEventsStore::new(executor);
+ Ok(store.list_records_after(0, limit)?)
+}
+
+pub fn get_shared_record(
+ config: &RuntimeConfig,
+ record_id: &str,
+) -> Result<Option<LocalEventRecord>, RuntimeError> {
+ let database_path = shared_local_events_db_path(config)?;
+ if !database_path.exists() {
+ return Ok(None);
+ }
+ let executor = SqliteExecutor::open(database_path)?;
+ let store = LocalEventsStore::new(executor);
+ Ok(store.get_record(record_id)?)
+}
+
fn update_signed_event_outbox(
config: &RuntimeConfig,
record_id: &str,
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -168,6 +168,12 @@ pub struct ListingFileArgs {
}
#[derive(Debug, Clone)]
+pub struct ListingAppRecordExportArgs {
+ pub record_id: String,
+ pub output: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone)]
pub struct ListingRebindArgs {
pub file: PathBuf,
pub selector: String,
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -185,6 +185,10 @@ impl TargetCommand {
ListingCommand::Create(_) => "listing.create",
ListingCommand::Get(_) => "listing.get",
ListingCommand::List => "listing.list",
+ ListingCommand::App(app) => match &app.command {
+ ListingAppCommand::List => "listing.app.list",
+ ListingAppCommand::Export(_) => "listing.app.export",
+ },
ListingCommand::Update(_) => "listing.update",
ListingCommand::Validate(_) => "listing.validate",
ListingCommand::Rebind(_) => "listing.rebind",
@@ -597,6 +601,7 @@ pub enum ListingCommand {
Create(ListingCreateArgs),
Get(LookupArgs),
List,
+ App(ListingAppArgs),
Update(FileArgs),
Validate(FileArgs),
Rebind(ListingRebindArgs),
@@ -654,6 +659,25 @@ pub struct FileArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct ListingAppArgs {
+ #[command(subcommand)]
+ pub command: ListingAppCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum ListingAppCommand {
+ List,
+ Export(ListingAppExportArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct ListingAppExportArgs {
+ pub record_id: Option<String>,
+ #[arg(long)]
+ pub output: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct ListingRebindArgs {
pub file: Option<PathBuf>,
pub selector: Option<String>,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -14,7 +14,8 @@ use radroots_events::trade::{
};
use radroots_events_codec::trade::active_trade_order_request_event_build;
use radroots_local_events::{
- LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime,
+ LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus,
+ PublishOutboxStatus, SourceRuntime,
};
use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event};
use radroots_replica_db::{farm, farm_member_claim, migrations};
@@ -217,6 +218,162 @@ fn sync_push_pending_batch(sandbox: &RadrootsCliSandbox) -> RadrootsReplicaPendi
radroots_replica_pending_publish_batch(&executor).expect("sync push pending batch")
}
+fn seed_app_farm_record(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ seller_pubkey: &str,
+ farm_d_tag: &str,
+) {
+ append_app_local_record(
+ LocalEventRecordInput {
+ record_id: format!("app:local_work:farm:{farm_d_tag}:test"),
+ family: LocalRecordFamily::LocalWork,
+ status: LocalRecordStatus::LocalSaved,
+ source_runtime: SourceRuntime::App,
+ created_at_ms: 1_779_000_001_000,
+ inserted_at_ms: 1_779_000_001_000,
+ owner_account_id: Some(account_id.to_owned()),
+ owner_pubkey: Some(seller_pubkey.to_owned()),
+ farm_id: Some(farm_d_tag.to_owned()),
+ listing_addr: None,
+ local_work_json: Some(json!({
+ "record_kind": "farm_config_v1",
+ "scope": "app",
+ "document": {
+ "version": 1,
+ "selection": {
+ "scope": "app",
+ "account": account_id,
+ "farm_d_tag": farm_d_tag,
+ },
+ "profile": {
+ "name": "App Farm",
+ "display_name": "App Farm",
+ },
+ "farm": {
+ "d_tag": farm_d_tag,
+ "name": "App Farm",
+ "location": {
+ "primary": "farmstand",
+ },
+ },
+ "listing_defaults": {
+ "delivery_method": "pickup",
+ "location": {
+ "primary": "farmstand",
+ },
+ },
+ },
+ })),
+ 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,
+ );
+}
+
+fn seed_app_listing_record(
+ sandbox: &RadrootsCliSandbox,
+ account_id: &str,
+ seller_pubkey: &str,
+ farm_d_tag: &str,
+ listing_d_tag: &str,
+) -> String {
+ let record_id = format!("app:local_work:listing:{listing_d_tag}:test");
+ 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_002_000,
+ inserted_at_ms: 1_779_000_002_000,
+ owner_account_id: Some(account_id.to_owned()),
+ owner_pubkey: Some(seller_pubkey.to_owned()),
+ farm_id: Some(farm_d_tag.to_owned()),
+ listing_addr: Some(format!("30402:{seller_pubkey}:{listing_d_tag}")),
+ local_work_json: Some(json!({
+ "record_kind": "listing_draft_v1",
+ "document": {
+ "version": 1,
+ "kind": "listing_draft_v1",
+ "listing": {
+ "d_tag": listing_d_tag,
+ "farm_d_tag": farm_d_tag,
+ },
+ "seller_actor": {
+ "account_id": account_id,
+ "pubkey": seller_pubkey,
+ "source": "farm_config",
+ },
+ "product": {
+ "key": listing_d_tag,
+ "title": "App Eggs",
+ "category": "eggs",
+ "summary": "Fresh app eggs",
+ },
+ "primary_bin": {
+ "bin_id": "bin-1",
+ "quantity_amount": "1",
+ "quantity_unit": "each",
+ "price_amount": "7.50",
+ "price_currency": "USD",
+ "price_per_amount": "1",
+ "price_per_unit": "each",
+ },
+ "inventory": {
+ "available": "12",
+ },
+ "availability": {
+ "kind": "local",
+ "status": "draft",
+ },
+ "delivery": {
+ "method": "pickup",
+ },
+ "location": {
+ "primary": "farmstand",
+ },
+ },
+ })),
+ 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
+}
+
+fn append_app_local_record(input: LocalEventRecordInput, sandbox: &RadrootsCliSandbox) {
+ let database_path = sandbox.local_events_db_path();
+ fs::create_dir_all(database_path.parent().expect("local events parent"))
+ .expect("local events parent");
+ let executor = SqliteExecutor::open(database_path).expect("open local events");
+ let store = LocalEventsStore::new(executor);
+ store.migrate_up().expect("migrate local events");
+ store
+ .append_record(&input)
+ .expect("append app local event record");
+}
+
#[test]
fn root_help_exposes_only_target_namespaces() {
let output = radroots().arg("--help").output().expect("run root help");
@@ -3374,6 +3531,91 @@ fn seller_local_writes_append_shared_local_work_records() {
}
#[test]
+fn listing_app_records_list_and_export_to_valid_cli_draft() {
+ 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 seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey");
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag);
+ let listing_record_id = seed_app_listing_record(
+ &sandbox,
+ account_id,
+ seller_pubkey,
+ farm_d_tag,
+ listing_d_tag,
+ );
+
+ let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]);
+ assert_eq!(list["operation_id"], "listing.app.list");
+ assert_eq!(list["result"]["state"], "ready");
+ assert_eq!(list["result"]["count"], 2);
+ let listing_row = list["result"]["records"]
+ .as_array()
+ .expect("records")
+ .iter()
+ .find(|record| record["record_id"] == listing_record_id)
+ .expect("listing row");
+ assert_eq!(listing_row["record_kind"], "listing_draft_v1");
+ assert_eq!(listing_row["source_runtime"], "app");
+ assert_eq!(listing_row["exportable"], true);
+ assert_eq!(listing_row["listing_id"], listing_d_tag);
+ assert_eq!(listing_row["title"], "App Eggs");
+
+ let export_path = sandbox.root().join("app-eggs.toml");
+ let export_path_arg = export_path.to_string_lossy();
+ let dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "app",
+ "export",
+ listing_record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(dry_run["operation_id"], "listing.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",
+ "listing",
+ "app",
+ "export",
+ listing_record_id.as_str(),
+ "--output",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(export["operation_id"], "listing.app.export");
+ assert_eq!(export["result"]["state"], "exported");
+ assert_eq!(export["result"]["listing_id"], listing_d_tag);
+ assert_eq!(export["result"]["seller_account_id"], account_id);
+ assert!(export_path.exists());
+
+ let validate = sandbox.json_success(&[
+ "--format",
+ "json",
+ "listing",
+ "validate",
+ export_path_arg.as_ref(),
+ ]);
+ assert_eq!(validate["operation_id"], "listing.validate");
+ assert_eq!(validate["result"]["valid"], true);
+ assert_eq!(validate["result"]["listing_id"], listing_d_tag);
+ assert_eq!(validate["result"]["seller_account_id"], account_id);
+}
+
+#[test]
fn farm_publish_writes_acknowledged_signed_outbox_records() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);