cli

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

commit 83a151e6551bb3275754a2f2831bd51a843c12e0
parent 65d592ed49af6339186a9984adf7c2741fe12a53
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 04:53:10 +0000

land local replica operator surfaces

Diffstat:
MCargo.lock | 155++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 3+++
Msrc/cli.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Asrc/commands/local.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/mod.rs | 9+++++----
Msrc/commands/runtime.rs | 11+++++++++--
Msrc/domain/runtime.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/config.rs | 28++++++++++++++++++++++++++++
Asrc/runtime/local.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 12+++++++++++-
Atests/local.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/runtime_show.rs | 16++++++++++++++++
13 files changed, 1046 insertions(+), 18 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -530,6 +530,18 @@ dependencies = [ ] [[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] name = "fastrand" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -548,6 +560,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -608,7 +626,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -616,6 +634,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -875,6 +896,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1093,6 +1125,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1198,6 +1236,9 @@ dependencies = [ "radroots-log", "radroots-nostr-accounts", "radroots-nostr-signer", + "radroots-replica-db", + "radroots-replica-sync", + "radroots-sql-core", "serde", "serde_json", "tempfile", @@ -1224,6 +1265,16 @@ dependencies = [ ] [[package]] +name = "radroots-events-codec" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots-core", + "radroots-events", + "serde", + "serde_json", +] + +[[package]] name = "radroots-identity" version = "0.1.0-alpha.1" dependencies = [ @@ -1289,6 +1340,46 @@ dependencies = [ ] [[package]] +name = "radroots-replica-db" +version = "0.1.0-alpha.1" +dependencies = [ + "hex", + "radroots-replica-db-schema", + "radroots-sql-core", + "radroots-types", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "radroots-replica-db-schema" +version = "0.1.0-alpha.1" +dependencies = [ + "radroots-types", + "serde", + "serde_json", +] + +[[package]] +name = "radroots-replica-sync" +version = "0.1.0-alpha.1" +dependencies = [ + "base64 0.22.1", + "hex", + "radroots-events", + "radroots-events-codec", + "radroots-replica-db", + "radroots-replica-db-schema", + "radroots-sql-core", + "radroots-types", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] name = "radroots-runtime" version = "0.1.0-alpha.1" dependencies = [ @@ -1305,6 +1396,26 @@ dependencies = [ ] [[package]] +name = "radroots-sql-core" +version = "0.1.0-alpha.1" +dependencies = [ + "chrono", + "rusqlite", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "radroots-types" +version = "0.1.0-alpha.1" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1364,6 +1475,30 @@ dependencies = [ ] [[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] name = "rust-ini" version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1556,6 +1691,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1947,6 +2094,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -22,6 +22,9 @@ radroots-identity = { path = "../lib/crates/identity" } radroots-log = { path = "../lib/crates/log" } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } +radroots-replica-db = { path = "../lib/crates/replica-db" } +radroots-replica-sync = { path = "../lib/crates/replica-sync" } +radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" diff --git a/src/cli.rs b/src/cli.rs @@ -93,8 +93,8 @@ impl Command { Self::Local(local) => match local.command { LocalCommand::Init => "local init", LocalCommand::Status => "local status", - LocalCommand::Export => "local export", - LocalCommand::Backup => "local backup", + LocalCommand::Export(_) => "local export", + LocalCommand::Backup(_) => "local backup", }, Self::Myc(myc) => match myc.command { MycCommand::Status => "myc status", @@ -158,7 +158,7 @@ impl Command { Self::Account(AccountArgs { command: AccountCommand::New | AccountCommand::Use(_), }) | Self::Local(LocalArgs { - command: LocalCommand::Init | LocalCommand::Export | LocalCommand::Backup, + command: LocalCommand::Init | LocalCommand::Export(_) | LocalCommand::Backup(_), }) | Self::Sync(SyncArgs { command: SyncCommand::Pull | SyncCommand::Push, }) | Self::Listing(ListingArgs { @@ -257,8 +257,37 @@ pub struct LocalArgs { pub enum LocalCommand { Init, Status, - Export, - Backup, + Export(LocalExportArgs), + Backup(LocalBackupArgs), +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum LocalExportFormatArg { + Json, + Ndjson, +} + +impl LocalExportFormatArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Ndjson => "ndjson", + } + } +} + +#[derive(Debug, Clone, Args)] +pub struct LocalExportArgs { + #[arg(long)] + pub format: LocalExportFormatArg, + #[arg(long)] + pub output: PathBuf, +} + +#[derive(Debug, Clone, Args)] +pub struct LocalBackupArgs { + #[arg(long)] + pub output: PathBuf, } #[derive(Debug, Clone, Args)] @@ -348,7 +377,8 @@ pub struct RecordKeyArgs { mod tests { use super::{ AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, - MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, SignerCommand, SyncCommand, + LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, + SignerCommand, SyncCommand, }; use crate::runtime::config::OutputFormat; use clap::Parser; @@ -530,6 +560,26 @@ mod tests { _ => panic!("unexpected command variant"), } + let local_export = CliArgs::parse_from([ + "radroots", + "local", + "export", + "--format", + "ndjson", + "--output", + "replica.ndjson", + ]); + match local_export.command { + Command::Local(args) => match args.command { + LocalCommand::Export(export) => { + assert!(matches!(export.format, LocalExportFormatArg::Ndjson)); + assert_eq!(export.output.to_str(), Some("replica.ndjson")); + } + _ => panic!("unexpected local subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let sync = CliArgs::parse_from(["radroots", "sync", "status"]); match sync.command { Command::Sync(args) => match args.command { diff --git a/src/commands/local.rs b/src/commands/local.rs @@ -0,0 +1,64 @@ +use crate::cli::{LocalBackupArgs, LocalExportArgs}; +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub fn init(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { + Ok(CommandOutput::success(CommandView::LocalInit( + crate::runtime::local::init(config)?, + ))) +} + +pub fn status(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::local::status(config)?; + Ok(match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::LocalStatus(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::LocalStatus(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::LocalStatus(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::LocalStatus(view)) + } + }) +} + +pub fn backup( + config: &RuntimeConfig, + args: &LocalBackupArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::local::backup(config, args.output.as_path())?; + Ok(match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::LocalBackup(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::LocalBackup(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::LocalBackup(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::LocalBackup(view)) + } + }) +} + +pub fn export( + config: &RuntimeConfig, + args: &LocalExportArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::local::export(config, args.format, args.output.as_path())?; + Ok(match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::LocalExport(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::LocalExport(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::LocalExport(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::LocalExport(view)) + } + }) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod doctor; pub mod identity; +pub mod local; pub mod myc; pub mod net; pub mod relay; @@ -58,10 +59,10 @@ pub fn dispatch( ListingCommand::Archive(_) => unimplemented_command("listing archive"), }, Command::Local(local) => match &local.command { - LocalCommand::Init => unimplemented_command("local init"), - LocalCommand::Status => unimplemented_command("local status"), - LocalCommand::Export => unimplemented_command("local export"), - LocalCommand::Backup => unimplemented_command("local backup"), + LocalCommand::Init => local::init(config), + LocalCommand::Status => local::status(config), + LocalCommand::Export(args) => local::export(config, args), + LocalCommand::Backup(args) => local::backup(config, args), }, Command::Net(net) => match &net.command { NetCommand::Status => net::status(config), diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,6 +1,7 @@ use crate::domain::runtime::{ - AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LoggingRuntimeView, MycRuntimeView, - OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, SignerRuntimeView, + AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LocalRuntimeView, + LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, + SignerRuntimeView, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -52,6 +53,12 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { publish_policy: config.relay.publish_policy.as_str().to_owned(), source: config.relay.source.as_str().to_owned(), }, + local: LocalRuntimeView { + root: config.local.root.display().to_string(), + replica_db_path: config.local.replica_db_path.display().to_string(), + backups_dir: config.local.backups_dir.display().to_string(), + exports_dir: config.local.exports_dir.display().to_string(), + }, myc: MycRuntimeView { executable: config.myc.executable.display().to_string(), }, diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -74,6 +74,10 @@ pub enum CommandView { AccountWhoami(AccountWhoamiView), ConfigShow(ConfigShowView), Doctor(DoctorView), + LocalBackup(LocalBackupView), + LocalExport(LocalExportView), + LocalInit(LocalInitView), + LocalStatus(LocalStatusView), MycStatus(MycStatusView), NetStatus(NetStatusView), RelayList(RelayListView), @@ -90,6 +94,7 @@ pub struct ConfigShowView { pub account: AccountRuntimeView, pub signer: SignerRuntimeView, pub relay: RelayRuntimeView, + pub local: LocalRuntimeView, pub myc: MycRuntimeView, } @@ -146,6 +151,14 @@ pub struct RelayRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct LocalRuntimeView { + pub root: String, + pub replica_db_path: String, + pub backups_dir: String, + pub exports_dir: String, +} + +#[derive(Debug, Clone, Serialize)] pub struct MycRuntimeView { pub executable: String, } @@ -257,6 +270,106 @@ pub struct AccountListView { } #[derive(Debug, Clone, Serialize)] +pub struct LocalInitView { + pub state: String, + pub source: String, + pub local_root: String, + pub replica_db: String, + pub path: String, + pub replica_db_version: String, + pub backup_format_version: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalStatusView { + pub state: String, + pub source: String, + pub local_root: String, + pub replica_db: String, + pub path: String, + pub replica_db_version: String, + pub backup_format_version: String, + pub schema_hash: String, + pub counts: LocalReplicaCountsView, + pub sync: LocalReplicaSyncView, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl LocalStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalReplicaCountsView { + pub farms: u64, + pub listings: u64, + pub profiles: u64, + pub relays: u64, + pub event_states: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalReplicaSyncView { + pub expected_count: usize, + pub pending_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalBackupView { + pub state: String, + pub source: String, + pub file: String, + pub size_bytes: u64, + pub backup_format_version: String, + pub replica_db_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl LocalBackupView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalExportView { + pub state: String, + pub source: String, + pub format: String, + pub file: String, + pub records: usize, + pub export_version: String, + pub schema_hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl LocalExportView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct RelayListView { pub state: String, pub source: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,7 +2,7 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, - NetStatusView, RelayListView, + LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, RelayListView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -81,6 +81,18 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::Doctor(view) => { render_doctor(stdout, view)?; } + CommandView::LocalBackup(view) => { + render_local_backup(stdout, view)?; + } + CommandView::LocalExport(view) => { + render_local_export(stdout, view)?; + } + CommandView::LocalInit(view) => { + render_local_init(stdout, view)?; + } + CommandView::LocalStatus(view) => { + render_local_status(stdout, view)?; + } CommandView::RelayList(view) => { render_relay_list(stdout, view)?; } @@ -159,6 +171,22 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::LocalBackup(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::LocalExport(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::LocalInit(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::LocalStatus(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::RelayList(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -308,6 +336,16 @@ fn render_config_show( )?; render_pairs( stdout, + "local", + &[ + ("root", view.local.root.as_str()), + ("replica db", view.local.replica_db_path.as_str()), + ("backups dir", view.local.backups_dir.as_str()), + ("exports dir", view.local.exports_dir.as_str()), + ], + )?; + render_pairs( + stdout, "myc", &[("executable", view.myc.executable.as_str())], )?; @@ -399,6 +437,117 @@ fn render_net_status(stdout: &mut dyn Write, view: &NetStatusView) -> Result<(), Ok(()) } +fn render_local_init(stdout: &mut dyn Write, view: &LocalInitView) -> Result<(), RuntimeError> { + write_context(stdout, format!("local · {}", view.state).as_str())?; + render_pairs( + stdout, + "local", + &[ + ("replica db", view.replica_db.as_str()), + ("path", view.path.as_str()), + ("local root", view.local_root.as_str()), + ("replica db version", view.replica_db_version.as_str()), + ("backup format version", view.backup_format_version.as_str()), + ], + )?; + writeln!(stdout, "source: {}", view.source)?; + Ok(()) +} + +fn render_local_status(stdout: &mut dyn Write, view: &LocalStatusView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "ready" => "local · status", + _ => "local · unconfigured", + }, + )?; + let mut rows = vec![ + ("replica db", view.replica_db.as_str()), + ("path", view.path.as_str()), + ("local root", view.local_root.as_str()), + ]; + if view.state == "ready" { + rows.push(("replica db version", view.replica_db_version.as_str())); + rows.push(("backup format version", view.backup_format_version.as_str())); + rows.push(("schema hash", view.schema_hash.as_str())); + } + render_pairs(stdout, "local", rows.as_slice())?; + if view.state == "ready" { + let sync_expected = view.sync.expected_count.to_string(); + let sync_pending = view.sync.pending_count.to_string(); + render_pairs( + stdout, + "sync", + &[ + ("expected", sync_expected.as_str()), + ("pending", sync_pending.as_str()), + ], + )?; + let farms = view.counts.farms.to_string(); + let listings = view.counts.listings.to_string(); + let profiles = view.counts.profiles.to_string(); + let relays = view.counts.relays.to_string(); + let event_states = view.counts.event_states.to_string(); + render_pairs( + stdout, + "counts", + &[ + ("farms", farms.as_str()), + ("listings", listings.as_str()), + ("profiles", profiles.as_str()), + ("relays", relays.as_str()), + ("event states", event_states.as_str()), + ], + )?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_local_backup(stdout: &mut dyn Write, view: &LocalBackupView) -> Result<(), RuntimeError> { + write_context(stdout, format!("local · {}", view.state).as_str())?; + let size_bytes = view.size_bytes.to_string(); + let mut rows = vec![("file", view.file.as_str())]; + if view.state != "unconfigured" { + rows.push(("size bytes", size_bytes.as_str())); + rows.push(("backup format version", view.backup_format_version.as_str())); + rows.push(("replica db version", view.replica_db_version.as_str())); + } + render_pairs(stdout, "backup", rows.as_slice())?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_local_export(stdout: &mut dyn Write, view: &LocalExportView) -> Result<(), RuntimeError> { + write_context(stdout, format!("local · {}", view.state).as_str())?; + let records = view.records.to_string(); + let mut rows = vec![ + ("format", view.format.as_str()), + ("file", view.file.as_str()), + ]; + if view.state != "unconfigured" { + rows.push(("records", records.as_str())); + rows.push(("export version", view.export_version.as_str())); + rows.push(("schema hash", view.schema_hash.as_str())); + } + render_pairs(stdout, "export", rows.as_slice())?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + fn doctor_row(check: &DoctorCheckView) -> Vec<String> { vec![ check.name.clone(), @@ -604,6 +753,10 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::AccountWhoami(_) => "account whoami", CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", + CommandView::LocalBackup(_) => "local backup", + CommandView::LocalExport(_) => "local export", + CommandView::LocalInit(_) => "local init", + CommandView::LocalStatus(_) => "local status", CommandView::MycStatus(_) => "myc status", CommandView::NetStatus(_) => "net status", CommandView::RelayList(_) => "relay ls", @@ -620,9 +773,9 @@ mod tests { RelayEntryView, RelayListView, }; use crate::runtime::config::{ - AccountConfig, IdentityConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RuntimeConfig, - SignerBackend, SignerConfig, Verbosity, + AccountConfig, IdentityConfig, LocalConfig, LoggingConfig, MycConfig, OutputConfig, + OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, + RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -662,6 +815,13 @@ mod tests { publish_policy: RelayPublishPolicy::Any, source: RelayConfigSource::WorkspaceConfig, }, + local: LocalConfig { + root: "/home/tester/.local/share/radroots/replica".into(), + replica_db_path: "/home/tester/.local/share/radroots/replica/replica.sqlite" + .into(), + backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), + exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), + }, myc: MycConfig { executable: "myc".into(), }, @@ -684,6 +844,11 @@ mod tests { ); assert_eq!(view.relay.count, 2); assert_eq!(view.relay.publish_policy, "any"); + assert!( + view.local + .replica_db_path + .ends_with(".local/share/radroots/replica/replica.sqlite") + ); } #[test] @@ -743,6 +908,13 @@ mod tests { publish_policy: RelayPublishPolicy::Any, source: RelayConfigSource::Defaults, }, + local: LocalConfig { + root: "/home/tester/.local/share/radroots/replica".into(), + replica_db_path: "/home/tester/.local/share/radroots/replica/replica.sqlite" + .into(), + backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), + exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), + }, myc: MycConfig { executable: "myc".into(), }, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -14,6 +14,10 @@ const DEFAULT_ENV_PATH: &str = ".env"; const DEFAULT_WORKSPACE_CONFIG_PATH: &str = ".radroots/config.toml"; const DEFAULT_USER_CONFIG_PATH: &str = ".config/radroots/config.toml"; const DEFAULT_USER_STATE_ROOT: &str = ".local/share/radroots"; +const DEFAULT_LOCAL_STATE_DIR: &str = "replica"; +const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; +const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; +const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; const ENV_FILE_PATH: &str = "RADROOTS_ENV_FILE"; const ENV_OUTPUT: &str = "RADROOTS_OUTPUT"; const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; @@ -167,6 +171,14 @@ pub struct RelayConfig { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalConfig { + pub root: PathBuf, + pub replica_db_path: PathBuf, + pub backups_dir: PathBuf, + pub exports_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MycConfig { pub executable: PathBuf, } @@ -180,6 +192,7 @@ pub struct RuntimeConfig { pub identity: IdentityConfig, pub signer: SignerConfig, pub relay: RelayConfig, + pub local: LocalConfig, pub myc: MycConfig, } @@ -303,6 +316,21 @@ impl RuntimeConfig { user_config.as_ref(), workspace_config.as_ref(), )?, + local: LocalConfig { + root: paths.user_state_root.join(DEFAULT_LOCAL_STATE_DIR), + replica_db_path: paths + .user_state_root + .join(DEFAULT_LOCAL_STATE_DIR) + .join(DEFAULT_LOCAL_DB_FILE), + backups_dir: paths + .user_state_root + .join(DEFAULT_LOCAL_STATE_DIR) + .join(DEFAULT_LOCAL_BACKUPS_DIR), + exports_dir: paths + .user_state_root + .join(DEFAULT_LOCAL_STATE_DIR) + .join(DEFAULT_LOCAL_EXPORTS_DIR), + }, myc: MycConfig { executable: args .myc_executable diff --git a/src/runtime/local.rs b/src/runtime/local.rs @@ -0,0 +1,261 @@ +use std::fs; +use std::path::Path; + +use radroots_replica_db::backup::export_database_backup_json; +use radroots_replica_db::export::{ReplicaDbExportManifestRs, export_manifest}; +use radroots_replica_db::migrations; +use radroots_replica_sync::radroots_replica_sync_status; +use radroots_sql_core::SqliteExecutor; +use serde_json::json; + +use crate::cli::LocalExportFormatArg; +use crate::domain::runtime::{ + LocalBackupView, LocalExportView, LocalInitView, LocalReplicaCountsView, LocalReplicaSyncView, + LocalStatusView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +const LOCAL_SOURCE: &str = "local replica · local first"; + +pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { + let existed = config.local.replica_db_path.exists(); + ensure_local_roots(config)?; + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + migrations::run_all_up(&executor)?; + let manifest = export_manifest(&executor)?; + + Ok(LocalInitView { + state: if existed { + "ready".to_owned() + } else { + "initialized".to_owned() + }, + source: LOCAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + replica_db: "ready".to_owned(), + path: config.local.replica_db_path.display().to_string(), + replica_db_version: manifest.replica_db_version, + backup_format_version: manifest.backup_format_version, + }) +} + +pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(LocalStatusView { + state: "unconfigured".to_owned(), + source: LOCAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + replica_db: "missing".to_owned(), + path: config.local.replica_db_path.display().to_string(), + replica_db_version: String::new(), + backup_format_version: String::new(), + schema_hash: String::new(), + counts: LocalReplicaCountsView { + farms: 0, + listings: 0, + profiles: 0, + relays: 0, + event_states: 0, + }, + sync: LocalReplicaSyncView { + expected_count: 0, + pending_count: 0, + }, + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots local init".to_owned()], + }); + } + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let manifest = export_manifest(&executor)?; + let sync = radroots_replica_sync_status(&executor)?; + + Ok(LocalStatusView { + state: "ready".to_owned(), + source: LOCAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + replica_db: "ready".to_owned(), + path: config.local.replica_db_path.display().to_string(), + replica_db_version: manifest.replica_db_version.clone(), + backup_format_version: manifest.backup_format_version.clone(), + schema_hash: manifest.schema_hash.clone(), + counts: manifest_counts(&manifest), + sync: LocalReplicaSyncView { + expected_count: sync.expected_count, + pending_count: sync.pending_count, + }, + reason: None, + actions: Vec::new(), + }) +} + +pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(LocalBackupView { + state: "unconfigured".to_owned(), + source: LOCAL_SOURCE.to_owned(), + file: output.display().to_string(), + size_bytes: 0, + backup_format_version: String::new(), + replica_db_version: String::new(), + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots local init".to_owned()], + }); + } + + ensure_safe_output_path(config, output)?; + create_parent_dir(output)?; + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let backup_json = export_database_backup_json(&executor)?; + fs::write(output, backup_json)?; + let file_size = fs::metadata(output)?.len(); + let manifest = export_manifest(&executor)?; + + Ok(LocalBackupView { + state: "backup created".to_owned(), + source: LOCAL_SOURCE.to_owned(), + file: output.display().to_string(), + size_bytes: file_size, + backup_format_version: manifest.backup_format_version, + replica_db_version: manifest.replica_db_version, + reason: None, + actions: Vec::new(), + }) +} + +pub fn export( + config: &RuntimeConfig, + format: LocalExportFormatArg, + output: &Path, +) -> Result<LocalExportView, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(LocalExportView { + state: "unconfigured".to_owned(), + source: LOCAL_SOURCE.to_owned(), + format: format.as_str().to_owned(), + file: output.display().to_string(), + records: 0, + export_version: String::new(), + schema_hash: String::new(), + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots local init".to_owned()], + }); + } + + ensure_safe_output_path(config, output)?; + create_parent_dir(output)?; + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let manifest = export_manifest(&executor)?; + let sync = radroots_replica_sync_status(&executor)?; + let records = match format { + LocalExportFormatArg::Json => { + let export = json!({ + "kind": "local_export_manifest_v1", + "source": LOCAL_SOURCE, + "replica_db_version": manifest.replica_db_version, + "backup_format_version": manifest.backup_format_version, + "export_version": manifest.export_version, + "schema_hash": manifest.schema_hash, + "sync": { + "expected_count": sync.expected_count, + "pending_count": sync.pending_count, + }, + "table_counts": manifest.table_counts, + }); + fs::write(output, serde_json::to_string_pretty(&export)?)?; + 1 + } + LocalExportFormatArg::Ndjson => { + let mut lines = Vec::new(); + lines.push( + json!({ + "kind": "local_export_manifest", + "source": LOCAL_SOURCE, + "replica_db_version": manifest.replica_db_version, + "backup_format_version": manifest.backup_format_version, + "export_version": manifest.export_version, + "schema_hash": manifest.schema_hash, + }) + .to_string(), + ); + lines.push( + json!({ + "kind": "local_sync_status", + "expected_count": sync.expected_count, + "pending_count": sync.pending_count, + }) + .to_string(), + ); + for table in &manifest.table_counts { + lines.push( + json!({ + "kind": "local_table_count", + "table": table.name, + "row_count": table.row_count, + }) + .to_string(), + ); + } + fs::write(output, format!("{}\n", lines.join("\n")))?; + lines.len() + } + }; + + Ok(LocalExportView { + state: "exported".to_owned(), + source: LOCAL_SOURCE.to_owned(), + format: format.as_str().to_owned(), + file: output.display().to_string(), + records, + export_version: manifest.export_version, + schema_hash: manifest.schema_hash, + reason: None, + actions: Vec::new(), + }) +} + +fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { + fs::create_dir_all(&config.local.root)?; + fs::create_dir_all(&config.local.backups_dir)?; + fs::create_dir_all(&config.local.exports_dir)?; + Ok(()) +} + +fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + Ok(()) +} + +fn ensure_safe_output_path(config: &RuntimeConfig, output: &Path) -> Result<(), RuntimeError> { + if output == config.local.replica_db_path.as_path() { + return Err(RuntimeError::Config(format!( + "output path {} would overwrite the local replica database", + output.display() + ))); + } + Ok(()) +} + +fn manifest_counts(manifest: &ReplicaDbExportManifestRs) -> LocalReplicaCountsView { + LocalReplicaCountsView { + farms: table_row_count(manifest, "farm"), + listings: table_row_count(manifest, "trade_product"), + profiles: table_row_count(manifest, "nostr_profile"), + relays: table_row_count(manifest, "nostr_relay"), + event_states: table_row_count(manifest, "nostr_event_state"), + } +} + +fn table_row_count(manifest: &ReplicaDbExportManifestRs, name: &str) -> u64 { + manifest + .table_counts + .iter() + .find(|table| table.name == name) + .map(|table| table.row_count) + .unwrap_or(0) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod config; +pub mod local; pub mod logging; pub mod myc; pub mod network; @@ -15,6 +16,10 @@ pub enum RuntimeError { Logging(#[from] radroots_log::Error), #[error("accounts error: {0}")] Accounts(#[from] radroots_nostr_accounts::prelude::RadrootsNostrAccountsError), + #[error("replica sql error: {0}")] + Sql(#[from] radroots_replica_db::SqlError), + #[error("replica sync error: {0}")] + ReplicaSync(#[from] radroots_replica_sync::RadrootsReplicaEventsError), #[error("failed to serialize json output: {0}")] Json(#[from] serde_json::Error), #[error("failed to write output: {0}")] @@ -25,7 +30,12 @@ impl RuntimeError { pub fn exit_code(&self) -> ExitCode { match self { Self::Config(_) => ExitCode::from(2), - Self::Logging(_) | Self::Accounts(_) | Self::Json(_) | Self::Io(_) => ExitCode::from(1), + Self::Logging(_) + | Self::Accounts(_) + | Self::Sql(_) + | Self::ReplicaSync(_) + | Self::Json(_) + | Self::Io(_) => ExitCode::from(1), } } } diff --git a/tests/local.rs b/tests/local.rs @@ -0,0 +1,150 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn local_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + ] { + command.env_remove(key); + } + command +} + +#[test] +fn local_init_json_creates_replica_db_and_roots() { + let dir = tempdir().expect("tempdir"); + let output = local_command_in(dir.path()) + .args(["--json", "local", "init"]) + .output() + .expect("run local init"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "initialized"); + assert_eq!(json["replica_db"], "ready"); + + let replica_db = dir + .path() + .join("home") + .join(".local/share/radroots/replica/replica.sqlite"); + assert!(replica_db.exists()); + assert!( + dir.path() + .join("home") + .join(".local/share/radroots/replica/backups") + .exists() + ); + assert!( + dir.path() + .join("home") + .join(".local/share/radroots/replica/exports") + .exists() + ); +} + +#[test] +fn local_status_reports_unconfigured_when_replica_is_missing() { + let dir = tempdir().expect("tempdir"); + let output = local_command_in(dir.path()) + .args(["--json", "local", "status"]) + .output() + .expect("run local status"); + + assert_eq!(output.status.code(), Some(3)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["replica_db"], "missing"); + assert_eq!(json["actions"][0], "radroots local init"); +} + +#[test] +fn local_status_reports_real_replica_metadata_after_init() { + let dir = tempdir().expect("tempdir"); + let init = local_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let output = local_command_in(dir.path()) + .args(["--json", "local", "status"]) + .output() + .expect("run local status"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["counts"]["farms"], 0); + assert_eq!(json["counts"]["listings"], 0); + assert_eq!(json["sync"]["expected_count"], 0); + assert_eq!(json["sync"]["pending_count"], 0); +} + +#[test] +fn local_backup_and_export_write_files() { + let dir = tempdir().expect("tempdir"); + let init = local_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let backup_path = dir.path().join("backup").join("local.radb"); + let backup = local_command_in(dir.path()) + .args([ + "--json", + "local", + "backup", + "--output", + backup_path.to_str().expect("backup path"), + ]) + .output() + .expect("run local backup"); + assert!(backup.status.success()); + let backup_json: Value = serde_json::from_slice(backup.stdout.as_slice()).expect("json"); + assert_eq!(backup_json["state"], "backup created"); + assert!(backup_path.exists()); + assert!(fs::metadata(&backup_path).expect("backup metadata").len() > 0); + + let export_path = dir.path().join("export").join("local.ndjson"); + let export = local_command_in(dir.path()) + .args([ + "--json", + "local", + "export", + "--format", + "ndjson", + "--output", + export_path.to_str().expect("export path"), + ]) + .output() + .expect("run local export"); + assert!(export.status.success()); + let export_json: Value = serde_json::from_slice(export.stdout.as_slice()).expect("json"); + assert_eq!(export_json["state"], "exported"); + assert_eq!(export_json["format"], "ndjson"); + let export_raw = fs::read_to_string(&export_path).expect("read export"); + let lines = export_raw.lines().collect::<Vec<_>>(); + assert!(lines.len() >= 3); + assert!(lines[0].contains("\"kind\":\"local_export_manifest\"")); +} diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -97,6 +97,22 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["relay"]["count"], 0); assert_eq!(json["relay"]["publish_policy"], "any"); assert_eq!(json["relay"]["source"], "defaults · local first"); + assert_eq!( + json["local"]["root"], + dir.path() + .join("home") + .join(".local/share/radroots/replica") + .display() + .to_string() + ); + assert_eq!( + json["local"]["replica_db_path"], + dir.path() + .join("home") + .join(".local/share/radroots/replica/replica.sqlite") + .display() + .to_string() + ); assert_eq!(json["myc"]["executable"], "myc"); }