cli

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

commit 97f2e0a3711deaccb8e7b316b4982e63d9ed1509
parent b191be105621e95818b890cfda12f5e96f1f721b
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 09:44:11 +0000

listing: dedupe current app records

- list app-authored records by change sequence and current identity
- expose change sequence and superseded counts in app record rows
- block stale app record exports with current-record guidance
- cover unresolved pubkey records as non-exportable app drafts

Diffstat:
Msrc/domain/runtime.rs | 4+++-
Msrc/runtime/listing.rs | 250++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/runtime/local_events.rs | 2+-
Mtests/support/mod.rs | 2+-
Mtests/target_cli.rs | 260++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
5 files changed, 447 insertions(+), 71 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2591,6 +2591,8 @@ impl ListingAppRecordListView { pub struct ListingAppRecordSummaryView { 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, @@ -2645,7 +2647,7 @@ impl ListingAppRecordExportView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "missing" => CommandDisposition::NotFound, - "invalid" | "unsupported" => CommandDisposition::ValidationFailed, + "invalid" | "stale" | "unsupported" => CommandDisposition::ValidationFailed, "error" => CommandDisposition::InternalError, _ => CommandDisposition::Success, } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -668,17 +669,9 @@ 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)? + let records = current_app_record_entries(app_local_records(config)?) .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)) + .map(|entry| app_record_summary(&entry.record, entry.superseded_count)) .collect::<Vec<_>>(); let state = if records.is_empty() { "empty" } else { "ready" }; let actions = if records.is_empty() { @@ -728,6 +721,49 @@ pub fn app_record_export( }); }; + if let Some(current_record) = current_app_record_for(config, &record)? + && current_record.record_id != record.record_id + { + let (listing_id, title, farm_d_tag) = app_listing_display_parts(&record); + let current_action = format!("radroots listing app export {}", current_record.record_id); + return Ok(ListingAppRecordExportView { + state: "stale".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, + 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: farm_d_tag.or(record.farm_id.clone()), + issues: vec![ListingValidationIssueView { + field: "record_id".to_owned(), + message: format!( + "app-authored local record `{}` was superseded by `{}`", + record.record_id, current_record.record_id + ), + line: None, + }], + reason: Some(format!( + "app-authored local record `{}` was superseded by current record `{}`{}", + record.record_id, + current_record.record_id, + title + .as_deref() + .map(|value| format!(" for `{value}`")) + .unwrap_or_default() + )), + actions: vec![current_action, "radroots listing app list".to_owned()], + }); + } + let draft = match app_listing_draft_from_record(&record) { Ok(draft) => draft, Err(reason) => { @@ -1120,18 +1156,93 @@ fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> L } } -fn app_record_summary(record: &LocalEventRecord) -> ListingAppRecordSummaryView { +#[derive(Debug, Clone)] +struct AppRecordListEntry { + record: LocalEventRecord, + superseded_count: usize, +} + +fn app_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> { + Ok(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) + ) + }) + .collect()) +} + +fn current_app_record_entries(mut records: Vec<LocalEventRecord>) -> Vec<AppRecordListEntry> { + 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<AppRecordListEntry> = Vec::new(); + let mut seen = HashMap::<String, usize>::new(); + for record in records { + let key = app_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(AppRecordListEntry { + record, + superseded_count: 0, + }); + } + } + entries +} + +fn current_app_record_for( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Option<LocalEventRecord>, RuntimeError> { + let key = app_record_current_key(record); + Ok(app_local_records(config)? + .into_iter() + .filter(|candidate| app_record_current_key(candidate) == key) + .max_by(|left, right| { + left.change_seq + .cmp(&right.change_seq) + .then_with(|| left.seq.cmp(&right.seq)) + })) +} + +fn app_record_summary( + record: &LocalEventRecord, + superseded_count: usize, +) -> 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", _) => ( + let (listing_id, title, exportable, reason) = match record_kind.as_str() { + DRAFT_KIND => { + if let Some(reason) = app_record_exportability_reason(record) { + let (listing_id, title, _) = app_listing_display_parts(record); + (listing_id, title, false, Some(reason)) + } else { + match app_listing_draft_from_record(record) { + Ok(draft) => ( + non_empty(draft.listing.d_tag), + non_empty(draft.product.title), + true, + None, + ), + Err(reason) => { + let (listing_id, title, _) = app_listing_display_parts(record); + (listing_id, title, false, Some(reason)) + } + } + } + } + "farm_config_v1" => ( None, record .local_work_json @@ -1157,6 +1268,8 @@ fn app_record_summary(record: &LocalEventRecord) -> ListingAppRecordSummaryView ListingAppRecordSummaryView { 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(), @@ -1172,6 +1285,98 @@ fn app_record_summary(record: &LocalEventRecord) -> ListingAppRecordSummaryView } } +fn app_record_current_key(record: &LocalEventRecord) -> String { + match local_record_kind(record).as_deref() { + Some(DRAFT_KIND) => { + if let Some(listing_addr) = record + .listing_addr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("listing_addr:{listing_addr}"); + } + let (listing_id, _, farm_d_tag) = app_listing_display_parts(record); + if let Some(listing_id) = listing_id { + let farm_key = farm_d_tag.or(record.farm_id.clone()).unwrap_or_default(); + return format!("listing:{farm_key}:{listing_id}"); + } + } + Some("farm_config_v1") => { + if let Some(farm_id) = record + .farm_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("farm:{farm_id}"); + } + if let Some(farm_id) = record + .local_work_json + .as_ref() + .and_then(|payload| payload["document"]["farm"]["d_tag"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("farm:{farm_id}"); + } + } + _ => {} + } + format!("record:{}", record.record_id) +} + +fn app_listing_display_parts( + record: &LocalEventRecord, +) -> (Option<String>, Option<String>, Option<String>) { + let document = record + .local_work_json + .as_ref() + .and_then(|payload| payload.get("document")); + let listing_id = document + .and_then(|document| document["listing"]["d_tag"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let title = document + .and_then(|document| document["product"]["title"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let farm_d_tag = document + .and_then(|document| document["listing"]["farm_d_tag"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + (listing_id, title, farm_d_tag) +} + +fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> { + let exportability = record + .local_work_json + .as_ref() + .and_then(|payload| payload.get("exportability"))?; + let state = exportability + .get("state") + .and_then(Value::as_str) + .unwrap_or_default(); + if state.is_empty() || state == "exportable" { + return None; + } + let reason = exportability + .get("reason") + .and_then(Value::as_str) + .unwrap_or_default(); + Some(match (state, reason) { + ("identity_unresolved", "canonical_hex_pubkey_required") => { + "canonical hex pubkey required before export".to_owned() + } + ("identity_unresolved", _) => "app record identity is unresolved".to_owned(), + (_, "") => format!("app record exportability state `{state}` is not exportable"), + (_, reason) => format!("app record exportability state `{state}`: {reason}"), + }) +} + fn app_listing_draft_from_record( record: &LocalEventRecord, ) -> Result<ListingDraftDocument, String> { @@ -1195,6 +1400,9 @@ fn app_listing_draft_from_record( if record_kind != DRAFT_KIND { return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}")); } + if let Some(reason) = app_record_exportability_reason(record) { + return Err(reason); + } let document = payload .get("document") .cloned() diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -175,7 +175,7 @@ pub fn list_shared_records( } let executor = SqliteExecutor::open(database_path)?; let store = LocalEventsStore::new(executor); - Ok(store.list_records_after(0, limit)?) + Ok(store.list_records_changed_after(0, limit)?) } pub fn get_shared_record( diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -127,7 +127,7 @@ impl RadrootsCliSandbox { let store = LocalEventsStore::new(executor); store.migrate_up().expect("migrate local events db"); store - .list_records_after(0, 200) + .list_records_after_seq(0, 200) .expect("list local event records") } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -288,7 +288,79 @@ fn seed_app_listing_record( farm_d_tag: &str, listing_d_tag: &str, ) -> String { - let record_id = format!("app:local_work:listing:{listing_d_tag}:test"); + seed_app_listing_record_variant( + sandbox, + account_id, + Some(seller_pubkey), + farm_d_tag, + listing_d_tag, + "test", + "App Eggs", + None, + ) +} + +fn seed_app_listing_record_variant( + sandbox: &RadrootsCliSandbox, + account_id: &str, + seller_pubkey: Option<&str>, + farm_d_tag: &str, + listing_d_tag: &str, + record_suffix: &str, + title: &str, + exportability: Option<serde_json::Value>, +) -> String { + let record_id = format!("app:local_work:listing:{listing_d_tag}:{record_suffix}"); + let seller_pubkey_json = seller_pubkey + .map(|value| json!(value)) + .unwrap_or_else(|| json!(null)); + let mut payload = 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_json, + "source": "farm_config", + }, + "product": { + "key": listing_d_tag, + "title": title, + "category": "eggs", + "summary": "Fresh app eggs", + }, + "primary_bin": { + "bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "dozen", + "price_amount": "7.50", + "price_currency": "USD", + "price_per_amount": "1", + "price_per_unit": "dozen", + }, + "inventory": { + "available": "12", + }, + "availability": { + "kind": "local", + "status": "draft", + }, + "delivery": { + "method": "pickup", + }, + "location": { + "primary": "farmstand", + }, + }, + }); + if let Some(exportability) = exportability { + payload["exportability"] = exportability; + } append_app_local_record( LocalEventRecordInput { record_id: record_id.clone(), @@ -298,53 +370,11 @@ fn seed_app_listing_record( 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()), + owner_pubkey: seller_pubkey.map(str::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": "dozen", - "price_amount": "7.50", - "price_currency": "USD", - "price_per_amount": "1", - "price_per_unit": "dozen", - }, - "inventory": { - "available": "12", - }, - "availability": { - "kind": "local", - "status": "draft", - }, - "delivery": { - "method": "pickup", - }, - "location": { - "primary": "farmstand", - }, - }, - })), + listing_addr: seller_pubkey + .map(|seller_pubkey| format!("30402:{seller_pubkey}:{listing_d_tag}")), + local_work_json: Some(payload), event_id: None, event_kind: None, event_pubkey: None, @@ -3620,6 +3650,142 @@ fn listing_app_records_list_and_export_to_valid_cli_draft() { } #[test] +fn listing_app_records_list_current_records_and_blocks_stale_export() { + 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 stale_record_id = seed_app_listing_record_variant( + &sandbox, + account_id, + Some(seller_pubkey), + farm_d_tag, + listing_d_tag, + "stale", + "Old App Eggs", + None, + ); + let current_record_id = seed_app_listing_record_variant( + &sandbox, + account_id, + Some(seller_pubkey), + farm_d_tag, + listing_d_tag, + "current", + "Current App Eggs", + None, + ); + + let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); + assert_eq!(list["result"]["count"], 2); + let records = list["result"]["records"].as_array().expect("records"); + assert!( + records + .iter() + .all(|record| record["record_id"] != stale_record_id) + ); + let listing_row = records + .iter() + .find(|record| record["record_id"] == current_record_id) + .expect("current listing row"); + assert_eq!(listing_row["title"], "Current App Eggs"); + assert_eq!(listing_row["superseded_count"], 1); + assert_eq!(listing_row["exportable"], true); + assert!( + listing_row["change_seq"] + .as_i64() + .expect("listing change seq") + > records[1]["change_seq"].as_i64().expect("farm change seq") + ); + + let export_path = sandbox.root().join("stale-app-eggs.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (output, stale_export) = sandbox.json_output(&[ + "--format", + "json", + "listing", + "app", + "export", + stale_record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!output.status.success()); + 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!( + stale_export["errors"][0]["message"] + .as_str() + .expect("stale reason") + .contains(current_record_id.as_str()) + ); + assert!(!export_path.exists()); +} + +#[test] +fn listing_app_records_mark_unresolved_pubkey_records_non_exportable() { + let sandbox = RadrootsCliSandbox::new(); + let account_id = "acct_unresolved"; + let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + let record_id = seed_app_listing_record_variant( + &sandbox, + account_id, + None, + farm_d_tag, + listing_d_tag, + "unresolved", + "Unresolved App Eggs", + Some(json!({ + "state": "identity_unresolved", + "reason": "canonical_hex_pubkey_required" + })), + ); + + let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); + assert_eq!(list["result"]["count"], 1); + let listing_row = &list["result"]["records"][0]; + assert_eq!(listing_row["record_id"], record_id); + assert_eq!(listing_row["title"], "Unresolved App Eggs"); + assert_eq!(listing_row["exportable"], false); + assert_eq!( + listing_row["reason"], + "canonical hex pubkey required before export" + ); + assert!(listing_row.get("listing_addr").is_none()); + + let export_path = sandbox.root().join("unresolved-app-eggs.toml"); + let export_path_arg = export_path.to_string_lossy(); + let (output, export) = sandbox.json_output(&[ + "--format", + "json", + "listing", + "app", + "export", + record_id.as_str(), + "--output", + export_path_arg.as_ref(), + ]); + assert!(!output.status.success()); + assert_eq!(export["result"], Value::Null); + assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); + assert_eq!( + export["errors"][0]["message"], + "canonical hex pubkey required before export" + ); + assert!(!export_path.exists()); +} + +#[test] fn farm_publish_writes_acknowledged_signed_outbox_records() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]);