commit 83a151e6551bb3275754a2f2831bd51a843c12e0
parent 65d592ed49af6339186a9984adf7c2741fe12a53
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 04:53:10 +0000
land local replica operator surfaces
Diffstat:
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");
}