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:
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"]);