cli

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

commit 312012974874021ef4964b45aaf01cebaf4c6040
parent 886f6e99494c339e83ce138c43735efc11f876fd
Author: triesap <tyson@radroots.org>
Date:   Wed, 13 May 2026 03:39:55 +0000

cli: prove sync no-overwrite state

- expose sync action reason codes for relay fetch, ingest, and skipped-write outcomes
- report duplicate or older relay events as sync_no_overwrite instead of silent skips
- preserve newer local listing projections when stale relay events arrive
- cover the no-overwrite path in nested sync runtime tests

Diffstat:
Msrc/domain/runtime.rs | 2++
Msrc/runtime/sync.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
2 files changed, 138 insertions(+), 51 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -3282,6 +3282,8 @@ pub struct SyncActionView { #[serde(skip_serializing_if = "Option::is_none")] pub publish_plan: Option<SyncPublishPlanView>, #[serde(skip_serializing_if = "Option::is_none")] + pub reason_code: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -232,6 +232,7 @@ where view.skipped_count = Some(0); view.unsupported_count = Some(0); view.failed_count = Some(0); + view.reason_code = Some("dry_run".to_owned()); view.actions = vec![scope.ready_action().to_owned()]; return Ok(view); } @@ -262,6 +263,7 @@ where let mut view = empty_action_from_snapshot(snapshot, "pull"); view.state = "unavailable".to_owned(); view.reason = Some(failure_reason); + view.reason_code = Some("relay_fetch_failed".to_owned()); view.target_relays = target_relays; view.failed_relays = failed_relays; view.freshness = freshness_for_scope_from_executor(config, &executor, scope)?; @@ -285,6 +287,7 @@ where let mut view = empty_action_from_snapshot(snapshot, "pull"); view.state = "unavailable".to_owned(); view.reason = Some(failure_reason); + view.reason_code = Some("relay_fetch_failed".to_owned()); view.target_relays = config.relay.urls.clone(); view.freshness = freshness_for_scope_from_executor(config, &executor, scope)?; return Ok(view); @@ -326,6 +329,7 @@ where unsupported_count: Some(ingest.unsupported_count), failed_count: Some(ingest.failed_count), publish_plan: None, + reason_code: ingest.reason_code().map(str::to_owned), reason: ingest.reason(), actions: vec![scope.ready_action().to_owned()], }) @@ -546,6 +550,7 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc unsupported_count: None, failed_count: None, publish_plan: None, + reason_code: None, reason: snapshot.reason, actions: snapshot.actions, } @@ -583,6 +588,7 @@ fn push_radrootsd_unavailable_view(config: &RuntimeConfig) -> SyncActionView { unsupported_count: None, failed_count: None, publish_plan: None, + reason_code: Some("not_implemented".to_owned()), reason: Some(RADROOTSD_SYNC_PUSH_UNAVAILABLE_REASON.to_owned()), actions: vec!["radroots --publish-mode nostr_relay sync push".to_owned()], } @@ -631,6 +637,7 @@ fn push_view( unsupported_count: Some(counts.unsupported_count), failed_count: Some(counts.failed_count), publish_plan, + reason_code: counts.reason_code().map(str::to_owned), reason, actions, } @@ -804,6 +811,16 @@ impl SyncPushCounts { } None } + + fn reason_code(&self) -> Option<&'static str> { + if self.failed_count > 0 { + Some("sync_publish_failed") + } else if self.skipped_count > 0 { + Some("sync_publish_skipped_other_author") + } else { + None + } + } } #[derive(Debug, Clone)] @@ -1307,14 +1324,33 @@ struct RelayIngestCounts { } impl RelayIngestCounts { + fn reason_code(&self) -> Option<&'static str> { + if self.failed_count > 0 { + Some("sync_ingest_failed") + } else if self.skipped_count > 0 { + Some("sync_no_overwrite") + } else { + None + } + } + fn reason(&self) -> Option<String> { - (self.failed_count > 0).then(|| match &self.first_failure_reason { - Some(reason) => format!( - "{} fetched event(s) failed ingest: {}", - self.failed_count, reason - ), - None => format!("{} fetched event(s) failed ingest", self.failed_count), - }) + if self.failed_count > 0 { + return Some(match &self.first_failure_reason { + Some(reason) => format!( + "{} fetched event(s) failed ingest: {}", + self.failed_count, reason + ), + None => format!("{} fetched event(s) failed ingest", self.failed_count), + }); + } + if self.skipped_count > 0 { + return Some(format!( + "{} fetched event(s) skipped because the local replica already has current or newer state", + self.skipped_count + )); + } + None } } @@ -1490,7 +1526,7 @@ mod tests { use radroots_events_codec::wire::WireEventParts; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrFilter, radroots_nostr_build_event, + RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrTimestamp, radroots_nostr_build_event, }; use radroots_replica_db::{farm, farm_member_claim, migrations}; use radroots_replica_db_schema::farm::IFarmFields; @@ -1975,6 +2011,43 @@ mod tests { } #[test] + fn sync_pull_reports_no_overwrite_skips_without_replacing_projection() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::local::init(&config).expect("store init"); + let seller = identity(12); + + let first = listing_event_with_title_at(&seller, "Pasture Eggs", 200); + let stale = listing_event_with_title_at(&seller, "Older Eggs", 199); + pull_with_fetcher(&config, fake_fetcher(vec![first])).expect("initial sync pull"); + let view = pull_with_fetcher(&config, fake_fetcher(vec![stale])).expect("stale sync pull"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.fetched_count, Some(1)); + assert_eq!(view.ingested_count, Some(0)); + assert_eq!(view.skipped_count, Some(1)); + assert_eq!(view.reason_code.as_deref(), Some("sync_no_overwrite")); + assert!( + view.reason + .as_deref() + .expect("skip reason") + .contains("current or newer state") + ); + let run = view.freshness.run.as_ref().expect("run freshness"); + assert_eq!(run.last_state, "success"); + assert_eq!(run.skipped_count, Some(1)); + + let search = crate::runtime::find::search( + &config, + &FindQueryArgs { + query: vec!["eggs".to_owned()], + }, + ) + .expect("market search"); + assert_eq!(search.results[0].title, "Pasture Eggs"); + } + + #[test] fn sync_status_reports_relay_set_changed_freshness() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path(), vec!["wss://relay-a.example.com".to_owned()]); @@ -2125,51 +2198,63 @@ mod tests { } fn listing_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { - signed_event( - identity, - WireEventParts { - kind: KIND_LISTING, - tags: vec![ - vec!["d".to_owned(), LISTING_D_TAG.to_owned()], - vec![ - "a".to_owned(), - format!("{}:{}:{}", KIND_FARM, identity.public_key_hex(), FARM_D_TAG), - ], - vec!["p".to_owned(), identity.public_key_hex()], - vec!["key".to_owned(), "pasture-eggs".to_owned()], - vec!["title".to_owned(), "Pasture Eggs".to_owned()], - vec!["category".to_owned(), "eggs".to_owned()], - vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], - vec!["process".to_owned(), "washed".to_owned()], - vec!["lot".to_owned(), "lot-a".to_owned()], - vec!["profile".to_owned(), "dozen".to_owned()], - vec!["year".to_owned(), "2026".to_owned()], - vec!["radroots:primary_bin".to_owned(), "bin-a".to_owned()], - vec![ - "radroots:bin".to_owned(), - "bin-a".to_owned(), - "12".to_owned(), - "each".to_owned(), - "12".to_owned(), - "each".to_owned(), - "dozen".to_owned(), - ], - vec![ - "radroots:price".to_owned(), - "bin-a".to_owned(), - "6".to_owned(), - "USD".to_owned(), - "1".to_owned(), - "each".to_owned(), - "6".to_owned(), - "each".to_owned(), - ], - vec!["inventory".to_owned(), "5".to_owned()], - vec!["status".to_owned(), "active".to_owned()], + listing_event_with_title_at(identity, "Pasture Eggs", 0) + } + + fn listing_event_with_title_at( + identity: &RadrootsIdentity, + title: &str, + created_at: u64, + ) -> RadrootsNostrEvent { + let mut builder = radroots_nostr_build_event( + KIND_LISTING, + "# Pasture Eggs", + vec![ + vec!["d".to_owned(), LISTING_D_TAG.to_owned()], + vec![ + "a".to_owned(), + format!("{}:{}:{}", KIND_FARM, identity.public_key_hex(), FARM_D_TAG), ], - content: "# Pasture Eggs".to_owned(), - }, + vec!["p".to_owned(), identity.public_key_hex()], + vec!["key".to_owned(), "pasture-eggs".to_owned()], + vec!["title".to_owned(), title.to_owned()], + vec!["category".to_owned(), "eggs".to_owned()], + vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], + vec!["process".to_owned(), "washed".to_owned()], + vec!["lot".to_owned(), "lot-a".to_owned()], + vec!["profile".to_owned(), "dozen".to_owned()], + vec!["year".to_owned(), "2026".to_owned()], + vec!["radroots:primary_bin".to_owned(), "bin-a".to_owned()], + vec![ + "radroots:bin".to_owned(), + "bin-a".to_owned(), + "12".to_owned(), + "each".to_owned(), + "12".to_owned(), + "each".to_owned(), + "dozen".to_owned(), + ], + vec![ + "radroots:price".to_owned(), + "bin-a".to_owned(), + "6".to_owned(), + "USD".to_owned(), + "1".to_owned(), + "each".to_owned(), + "6".to_owned(), + "each".to_owned(), + ], + vec!["inventory".to_owned(), "5".to_owned()], + vec!["status".to_owned(), "active".to_owned()], + ], ) + .expect("listing parts"); + if created_at > 0 { + builder = builder.custom_created_at(RadrootsNostrTimestamp::from(created_at)); + } + builder + .sign_with_keys(identity.keys()) + .expect("signed event") } fn signed_event(identity: &RadrootsIdentity, parts: WireEventParts) -> RadrootsNostrEvent {