cli

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

commit 136b16ec1a1f6c30130fb1e10d913ebf0a4cae58
parent 97f2e0a3711deaccb8e7b316b4982e63d9ed1509
Author: triesap <tyson@radroots.org>
Date:   Sat, 23 May 2026 10:42:11 +0000

listing: make app records newest-first

- read shared app records from newest change pages
- expose app-record list pagination metadata
- dedupe fallback listing identity by owner pubkey
- cover newer records behind older local work

Diffstat:
Msrc/domain/runtime.rs | 6++++++
Msrc/runtime/listing.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/runtime/local_events.rs | 19+++++++++++++++++--
Mtests/target_cli.rs | 44++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 146 insertions(+), 22 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2572,6 +2572,12 @@ pub struct ListingAppRecordListView { 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<ListingAppRecordSummaryView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -49,9 +49,9 @@ use crate::runtime::direct_relay::{ }; use crate::runtime::farm_config; use crate::runtime::local_events::{ - 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, + append_local_work, append_signed_event, get_shared_record, list_shared_records_before, + list_shared_records_latest, 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::{ @@ -669,8 +669,20 @@ 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 = current_app_record_entries(app_local_records(config)?) - .into_iter() + let mut entries = current_app_record_entries(app_local_records(config)?); + let has_more = entries.len() > APP_RECORD_LIST_LIMIT as usize; + if has_more { + entries.truncate(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_record_summary(&entry.record, entry.superseded_count)) .collect::<Vec<_>>(); let state = if records.is_empty() { "empty" } else { "ready" }; @@ -684,6 +696,10 @@ pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListVie state: state.to_owned(), source: LISTING_APP_RECORD_SOURCE.to_owned(), count: records.len(), + limit: 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, @@ -1163,17 +1179,46 @@ struct AppRecordListEntry { } 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()) + 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, + APP_RECORD_LIST_LIMIT, + )? + } else { + list_shared_records_latest(config, 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() == APP_RECORD_LIST_LIMIT as usize; + app_records.extend( + shared_records + .into_iter() + .filter(is_supported_app_local_record), + ); + if !has_more { + break; + } + before_cursor = Some(next_cursor); + } + Ok(app_records) +} + +fn is_supported_app_local_record(record: &LocalEventRecord) -> bool { + record.source_runtime == SourceRuntime::App + && record.family == LocalRecordFamily::LocalWork + && matches!( + local_record_kind(record).as_deref(), + Some("farm_config_v1" | DRAFT_KIND) + ) } fn current_app_record_entries(mut records: Vec<LocalEventRecord>) -> Vec<AppRecordListEntry> { @@ -1296,10 +1341,15 @@ fn app_record_current_key(record: &LocalEventRecord) -> String { { 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}"); + let (listing_id, _, _) = app_listing_display_parts(record); + if let (Some(owner_pubkey), Some(listing_id)) = ( + record + .owner_pubkey + .as_deref() + .and_then(canonical_hex_pubkey), + listing_id.filter(|value| is_d_tag_base64url(value)), + ) { + return format!("listing_owner:{owner_pubkey}:{listing_id}"); } } Some("farm_config_v1") => { @@ -1326,6 +1376,15 @@ fn app_record_current_key(record: &LocalEventRecord) -> String { format!("record:{}", record.record_id) } +fn canonical_hex_pubkey(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.len() == 64 && trimmed.chars().all(|char| char.is_ascii_hexdigit()) { + Some(trimmed.to_ascii_lowercase()) + } else { + None + } +} + fn app_listing_display_parts( record: &LocalEventRecord, ) -> (Option<String>, Option<String>, Option<String>) { diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -165,7 +165,7 @@ pub fn shared_local_events_db_path(config: &RuntimeConfig) -> Result<PathBuf, Ru Ok(shared_local_events_root(config)?.join(SHARED_LOCAL_EVENTS_DB_FILE)) } -pub fn list_shared_records( +pub fn list_shared_records_latest( config: &RuntimeConfig, limit: u32, ) -> Result<Vec<LocalEventRecord>, RuntimeError> { @@ -175,7 +175,22 @@ pub fn list_shared_records( } let executor = SqliteExecutor::open(database_path)?; let store = LocalEventsStore::new(executor); - Ok(store.list_records_changed_after(0, limit)?) + Ok(store.list_records_changed_latest(limit)?) +} + +pub fn list_shared_records_before( + config: &RuntimeConfig, + before_change_seq: i64, + before_seq: i64, + 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_changed_before(before_change_seq, before_seq, limit)?) } pub fn get_shared_record( diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3686,6 +3686,8 @@ fn listing_app_records_list_current_records_and_blocks_stale_export() { let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); assert_eq!(list["result"]["count"], 2); + assert_eq!(list["result"]["limit"], 500); + assert_eq!(list["result"]["has_more"], false); let records = list["result"]["records"].as_array().expect("records"); assert!( records @@ -3732,6 +3734,48 @@ fn listing_app_records_list_current_records_and_blocks_stale_export() { } #[test] +fn listing_app_records_list_includes_new_records_after_older_volume() { + 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"); + for index in 0..505 { + let farm_d_tag = format!("F{index:021}"); + seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag.as_str()); + } + let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; + let current_record_id = seed_app_listing_record_variant( + &sandbox, + account_id, + Some(seller_pubkey), + "AAAAAAAAAAAAAAAAAAAAAw", + listing_d_tag, + "current", + "Newest App Eggs", + None, + ); + + let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); + assert_eq!(list["result"]["limit"], 500); + assert_eq!(list["result"]["count"], 500); + assert_eq!(list["result"]["has_more"], true); + assert!(list["result"]["next_before_change_seq"].as_i64().is_some()); + assert!(list["result"]["next_before_seq"].as_i64().is_some()); + let records = list["result"]["records"].as_array().expect("records"); + assert_eq!(records[0]["record_id"], current_record_id); + assert!( + records + .iter() + .any(|record| record["record_id"] == current_record_id) + ); +} + +#[test] fn listing_app_records_mark_unresolved_pubkey_records_non_exportable() { let sandbox = RadrootsCliSandbox::new(); let account_id = "acct_unresolved";