cli

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

commit 0aa0c56d069a20d2911365e91ed1e097da7336ea
parent 3295e3f051cbef9ede766a36cdd6aa423ff68d77
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 15:50:01 -0700

store: migrate status and backup to SDK storage

- report SDK event store and outbox as the canonical local store
- label the legacy replica as derived migration state in status output
- create SDK backup directories with manifest and verification output
- update health readiness to use SDK store and signer state

Diffstat:
Msrc/ops/exec/core.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime/store.rs | 386+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/view/runtime.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtests/target_cli.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
4 files changed, 634 insertions(+), 118 deletions(-)

diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -31,6 +31,7 @@ use crate::runtime::config::{ PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, }; use crate::runtime::logging::LoggingState; +use crate::runtime::sdk::CliSdkAdapterError; use crate::view::runtime::{ CommandDisposition, LocalBackupView, PublishProviderRuntimeView, PublishRelayRuntimeView, PublishRuntimeView, @@ -99,17 +100,22 @@ impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> { fn execute( &self, - _request: OperationRequest<HealthStatusGetRequest>, + request: OperationRequest<HealthStatusGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let store = map_runtime(crate::runtime::store::status(self.config))?; + let store = map_sdk_adapter( + request.operation_id(), + crate::runtime::store::status(self.config), + )?; let account = map_runtime(resolve_account_resolution(self.config))?; let publish = publish_runtime_view(self.config, true, &account); + let signer = signer_health_view(self.config, &account); let state = health_status_state(&store.state, &publish); let actions = health_actions(self.config, store.state.as_str(), &account, &publish); json_operation_result::<HealthStatusGetResult>(json!({ "state": state, "store": store, "account_resolution": account_resolution_view(&account), + "signer": signer, "publish": publish, "logging": { "initialized": self.logging.initialized, @@ -125,9 +131,12 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { fn execute( &self, - _request: OperationRequest<HealthCheckRunRequest>, + request: OperationRequest<HealthCheckRunRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let store = map_runtime(crate::runtime::store::status(self.config))?; + let store = map_sdk_adapter( + request.operation_id(), + crate::runtime::store::status(self.config), + )?; let account = map_runtime(resolve_account_resolution(self.config))?; let account_reason = if account.resolved_account.is_some() { None @@ -135,6 +144,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { Some(map_runtime(unresolved_account_reason(self.config))?) }; let publish = publish_runtime_view(self.config, true, &account); + let signer = signer_health_view(self.config, &account); let state = health_check_state(&store.state, account.resolved_account.is_some(), &publish); let actions = health_actions(self.config, store.state.as_str(), &account, &publish); json_operation_result::<HealthCheckRunResult>(json!({ @@ -147,12 +157,22 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { }, "store": { "state": store.state, + "source": store.source, + "canonical_store": store.canonical_store, + "sdk_storage": store.sdk_storage, + "sdk_root": store.sdk_root, + "sdk_existed_before_open": store.sdk_existed_before_open, + "event_store": store.event_store, + "outbox": store.outbox, + "integrity": store.integrity, + "legacy_replica": store.legacy_replica, "reason": store.reason, }, "account": { "state": if account.resolved_account.is_some() { "ready" } else { "unconfigured" }, "reason": account_reason, }, + "signer": signer, "publish": { "state": publish.state, "mode": publish.mode, @@ -564,9 +584,12 @@ impl OperationService<StoreStatusGetRequest> for CoreOperationService<'_> { fn execute( &self, - _request: OperationRequest<StoreStatusGetRequest>, + request: OperationRequest<StoreStatusGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_runtime(crate::runtime::store::status(self.config))?; + let view = map_sdk_adapter( + request.operation_id(), + crate::runtime::store::status(self.config), + )?; serialized_operation_result::<StoreStatusGetResult, _>(&view) } } @@ -614,16 +637,16 @@ impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> { request: OperationRequest<StoreBackupCreateRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let output = optional_path(&request, "output") - .unwrap_or_else(|| self.config.local.backups_dir.join("store-backup.json")); + .unwrap_or_else(|| self.config.local.backups_dir.join("sdk-store-backup")); if request.context.dry_run { - let view = map_expected_runtime( + let view = map_sdk_adapter( request.operation_id(), crate::runtime::store::backup_preflight(self.config, output.as_path()), )?; return local_backup_result(request.operation_id(), &view); } - let view = map_expected_runtime( + let view = map_sdk_adapter( request.operation_id(), crate::runtime::store::backup(self.config, output.as_path()), )?; @@ -650,6 +673,13 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } +fn map_sdk_adapter<T>( + operation_id: &str, + result: Result<T, CliSdkAdapterError>, +) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::sdk_adapter_failure(operation_id, error)) +} + fn account_secret_backend_ready( operation_id: &str, config: &RuntimeConfig, @@ -824,6 +854,34 @@ fn radrootsd_publish_readiness(_config: &RuntimeConfig) -> (&'static str, bool, ) } +fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Value { + match config.signer.backend { + SignerBackend::Local => { + let write_capable = account + .resolved_account + .as_ref() + .map(|account| account.write_capable) + .unwrap_or(false); + json!({ + "state": if write_capable { "ready" } else { "unconfigured" }, + "backend": config.signer.backend.as_str(), + "write_capable_account": write_capable, + "reason": if write_capable { + Value::Null + } else { + json!("local signer requires a selected or default write-capable local account") + }, + }) + } + SignerBackend::Myc => json!({ + "state": "unavailable", + "backend": config.signer.backend.as_str(), + "write_capable_account": false, + "reason": "signer mode `myc` is deferred for CLI writes", + }), + } +} + fn health_status_state(store_state: &str, publish: &PublishRuntimeView) -> &'static str { if store_state == "ready" && publish_runtime_ready(publish) { "ready" @@ -856,7 +914,7 @@ fn health_actions( ) -> Vec<String> { let mut actions = Vec::new(); if store_state != "ready" { - push_unique(&mut actions, "radroots store init"); + push_unique(&mut actions, "radroots store status get"); } if let Some(resolved) = account.resolved_account.as_ref() { if !resolved.write_capable { @@ -1059,8 +1117,23 @@ mod tests { .expect("store status envelope"); assert_eq!(envelope.operation_id, "store.status.get"); - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!(envelope.result["replica_db"], "missing"); + assert_eq!(envelope.result["state"], "ready"); + assert_eq!( + envelope.result["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(envelope.result["canonical_store"], "sdk"); + assert_eq!(envelope.result["sdk_storage"], "directory"); + assert_eq!( + envelope.result["legacy_replica"]["source"], + "legacy local replica · derived/migration source" + ); + assert_eq!(envelope.result["legacy_replica"]["state"], "unconfigured"); + assert_eq!( + envelope.result["event_store"]["store"]["integrity_ok"], + true + ); + assert_eq!(envelope.result["outbox"]["store"]["integrity_ok"], true); } #[test] diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -1,23 +1,36 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; -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_sdk::{ + BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, SdkBackupState, + SdkEventStoreStorageStatus, SdkOutboxStorageStatus, SdkSqliteStoreStatus, SdkStorageKind, + StorageStatusReceipt, StorageStatusRequest, +}; use radroots_sql_core::SqliteExecutor; -use serde_json::json; +use serde::Serialize; +use serde_json::{Value, json}; use crate::cli::global::LocalExportFormatArg; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_storage_root}; use crate::runtime::sync::ensure_sync_run_table; use crate::view::runtime::{ - LocalBackupView, LocalExportView, LocalInitView, LocalReplicaCountsView, LocalReplicaSyncView, - LocalStatusView, + LocalBackupView, LocalExportView, LocalInitView, LocalLegacyReplicaStatusView, + LocalReplicaCountsView, LocalReplicaSyncView, LocalStatusView, SdkEventStoreStatusView, + SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView, }; -const LOCAL_SOURCE: &str = "local replica · local first"; +const LEGACY_REPLICA_SOURCE: &str = "legacy local replica · derived/migration source"; +const SDK_CANONICAL_SOURCE: &str = "SDK canonical event store and outbox"; +const SDK_CANONICAL_STORE: &str = "sdk"; +const SDK_BACKUP_KIND: &str = "sdk_canonical"; +const SDK_BACKUP_MANIFEST_FILE: &str = "manifest.json"; +const SDK_EVENT_STORE_FILE: &str = "event_store.sqlite"; +const SDK_OUTBOX_FILE: &str = "outbox.sqlite"; pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { let existed = config.local.replica_db_path.exists(); @@ -33,7 +46,7 @@ pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { } else { "initialized".to_owned() }, - source: LOCAL_SOURCE.to_owned(), + source: LEGACY_REPLICA_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(), @@ -50,7 +63,7 @@ pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeEr let manifest = export_manifest(&executor)?; return Ok(LocalInitView { state: "ready".to_owned(), - source: LOCAL_SOURCE.to_owned(), + source: LEGACY_REPLICA_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(), @@ -61,7 +74,7 @@ pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeEr Ok(LocalInitView { state: "dry_run".to_owned(), - source: LOCAL_SOURCE.to_owned(), + source: LEGACY_REPLICA_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(), @@ -70,12 +83,34 @@ pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeEr }) } -pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { +pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterError> { + let sdk_root = sdk_storage_root(config); + let sdk_existed_before_open = sdk_storage_files_exist(sdk_root.as_path()); + let legacy_replica = legacy_replica_status(config)?; + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on( + session + .sdk() + .storage_status(StorageStatusRequest::default()), + )?; + let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::default()))?; + Ok(sdk_status_view( + config, + sdk_root, + sdk_existed_before_open, + receipt, + integrity, + legacy_replica, + )) +} + +fn legacy_replica_status( + config: &RuntimeConfig, +) -> Result<LocalLegacyReplicaStatusView, RuntimeError> { if !config.local.replica_db_path.exists() { - return Ok(LocalStatusView { + return Ok(LocalLegacyReplicaStatusView { state: "unconfigured".to_owned(), - source: LOCAL_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), + source: LEGACY_REPLICA_SOURCE.to_owned(), replica_db: "missing".to_owned(), path: config.local.replica_db_path.display().to_string(), replica_db_version: String::new(), @@ -102,10 +137,9 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { let manifest = export_manifest(&executor)?; let sync = radroots_replica_sync_status(&executor)?; - Ok(LocalStatusView { + Ok(LocalLegacyReplicaStatusView { state: "ready".to_owned(), - source: LOCAL_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), + source: LEGACY_REPLICA_SOURCE.to_owned(), replica_db: "ready".to_owned(), path: config.local.replica_db_path.display().to_string(), replica_db_version: manifest.replica_db_version.clone(), @@ -121,52 +155,47 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> { }) } -pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(missing_backup_view(output)); - } - - 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 backup( + config: &RuntimeConfig, + output: &Path, +) -> Result<LocalBackupView, CliSdkAdapterError> { + ensure_safe_sdk_backup_destination(config, output)?; + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on(session.sdk().backup(BackupRequest { + destination: output.to_path_buf(), + overwrite: false, + }))?; + sdk_backup_view(receipt) } pub fn backup_preflight( config: &RuntimeConfig, output: &Path, -) -> Result<LocalBackupView, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(missing_backup_view(output)); - } - - ensure_safe_output_path(config, output)?; - let executor = SqliteExecutor::open(&config.local.replica_db_path)?; - let manifest = export_manifest(&executor)?; - +) -> Result<LocalBackupView, CliSdkAdapterError> { + ensure_safe_sdk_backup_destination(config, output)?; + let session = CliSdkSession::connect(config)?; + let status = session.block_on( + session + .sdk() + .storage_status(StorageStatusRequest::default()), + )?; + let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::default()))?; + let manifest = sdk_backup_manifest_preview(output, &status, &integrity); Ok(LocalBackupView { state: "dry_run".to_owned(), - source: LOCAL_SOURCE.to_owned(), - file: output.display().to_string(), + source: SDK_CANONICAL_SOURCE.to_owned(), + backup_kind: SDK_BACKUP_KIND.to_owned(), + canonical_store: SDK_CANONICAL_STORE.to_owned(), + destination: output.display().to_string(), + file: output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string(), + event_store_file: Some(output.join(SDK_EVENT_STORE_FILE).display().to_string()), + outbox_file: Some(output.join(SDK_OUTBOX_FILE).display().to_string()), + manifest_file: Some(output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string()), size_bytes: 0, - backup_format_version: manifest.backup_format_version, - replica_db_version: manifest.replica_db_version, - reason: Some("dry run requested; backup file was not written".to_owned()), + manifest, + reason: Some( + "dry run requested; SDK canonical backup directory was not written".to_owned(), + ), actions: vec!["radroots store backup create".to_owned()], }) } @@ -179,7 +208,7 @@ pub fn export( if !config.local.replica_db_path.exists() { return Ok(LocalExportView { state: "unconfigured".to_owned(), - source: LOCAL_SOURCE.to_owned(), + source: LEGACY_REPLICA_SOURCE.to_owned(), format: format.as_str().to_owned(), file: output.display().to_string(), records: 0, @@ -200,7 +229,7 @@ pub fn export( LocalExportFormatArg::Json => { let export = json!({ "kind": "local_export_manifest_v1", - "source": LOCAL_SOURCE, + "source": LEGACY_REPLICA_SOURCE, "replica_db_version": manifest.replica_db_version, "backup_format_version": manifest.backup_format_version, "export_version": manifest.export_version, @@ -219,7 +248,7 @@ pub fn export( lines.push( json!({ "kind": "local_export_manifest", - "source": LOCAL_SOURCE, + "source": LEGACY_REPLICA_SOURCE, "replica_db_version": manifest.replica_db_version, "backup_format_version": manifest.backup_format_version, "export_version": manifest.export_version, @@ -252,7 +281,7 @@ pub fn export( Ok(LocalExportView { state: "exported".to_owned(), - source: LOCAL_SOURCE.to_owned(), + source: LEGACY_REPLICA_SOURCE.to_owned(), format: format.as_str().to_owned(), file: output.display().to_string(), records, @@ -298,17 +327,238 @@ fn validate_directory_target(path: &Path) -> Result<(), RuntimeError> { } } -fn missing_backup_view(output: &Path) -> LocalBackupView { - 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 store init".to_owned()], +fn sdk_storage_files_exist(sdk_root: &Path) -> bool { + sdk_root.join(SDK_EVENT_STORE_FILE).exists() && sdk_root.join(SDK_OUTBOX_FILE).exists() +} + +fn sdk_status_view( + config: &RuntimeConfig, + sdk_root: PathBuf, + sdk_existed_before_open: bool, + receipt: StorageStatusReceipt, + integrity: IntegrityReceipt, + legacy_replica: LocalLegacyReplicaStatusView, +) -> LocalStatusView { + let event_store_path = receipt + .paths + .as_ref() + .map(|paths| paths.event_store_path.display().to_string()); + let outbox_path = receipt + .paths + .as_ref() + .map(|paths| paths.outbox_path.display().to_string()); + let state = sdk_status_state(&receipt, &integrity).to_owned(); + let reason = sdk_status_reason(&state); + let actions = sdk_status_actions(&state); + LocalStatusView { + state, + source: SDK_CANONICAL_SOURCE.to_owned(), + local_root: config.local.root.display().to_string(), + canonical_store: SDK_CANONICAL_STORE.to_owned(), + sdk_storage: sdk_storage_kind_label(receipt.storage).to_owned(), + sdk_root: sdk_root.display().to_string(), + sdk_existed_before_open, + event_store: sdk_event_store_status_view(receipt.event_store, event_store_path), + outbox: sdk_outbox_status_view(receipt.outbox, outbox_path), + integrity: sdk_integrity_view(integrity), + legacy_replica, + reason, + actions, + } +} + +fn sdk_status_state(receipt: &StorageStatusReceipt, integrity: &IntegrityReceipt) -> &'static str { + if receipt.event_store.store.integrity_ok + && receipt.outbox.store.integrity_ok + && integrity.event_store_ok + && integrity.outbox_ok + { + "ready" + } else { + "needs_attention" + } +} + +fn sdk_status_reason(state: &str) -> Option<String> { + match state { + "ready" => None, + _ => Some("SDK canonical store integrity check failed".to_owned()), + } +} + +fn sdk_status_actions(state: &str) -> Vec<String> { + match state { + "ready" => Vec::new(), + _ => vec!["radroots store status get".to_owned()], + } +} + +fn sdk_event_store_status_view( + status: SdkEventStoreStorageStatus, + path: Option<String>, +) -> SdkEventStoreStatusView { + SdkEventStoreStatusView { + path, + store: sdk_sqlite_status_view(status.store), + total_events: status.total_events, + projection_eligible_events: status.projection_eligible_events, + relay_observations: status.relay_observations, + last_event_seq: status.last_event_seq, + last_event_updated_at_ms: status.last_event_updated_at_ms, + } +} + +fn sdk_outbox_status_view( + status: SdkOutboxStorageStatus, + path: Option<String>, +) -> SdkOutboxStatusView { + SdkOutboxStatusView { + path, + store: sdk_sqlite_status_view(status.store), + total_events: status.total_events, + pending_events: status.pending_events, + retryable_events: status.retryable_events, + terminal_events: status.terminal_events, + failed_terminal_events: status.failed_terminal_events, + ready_signed_events: status.ready_signed_events, + publishing_events: status.publishing_events, + last_attempt_at_ms: status.last_attempt_at_ms, + last_error: status.last_error, + } +} + +fn sdk_sqlite_status_view(status: SdkSqliteStoreStatus) -> SdkSqliteStatusView { + SdkSqliteStatusView { + schema_version: status.schema_version, + journal_mode: status.journal_mode, + foreign_keys_enabled: status.foreign_keys_enabled, + busy_timeout_ms: status.busy_timeout_ms, + integrity_ok: status.integrity_ok, + integrity_result: status.integrity_result, + } +} + +fn sdk_integrity_view(receipt: IntegrityReceipt) -> SdkIntegrityView { + SdkIntegrityView { + checked_paths: receipt + .checked_paths + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + event_store_ok: receipt.event_store_ok, + outbox_ok: receipt.outbox_ok, + event_store_result: receipt.event_store_result, + outbox_result: receipt.outbox_result, + } +} + +fn ensure_safe_sdk_backup_destination( + config: &RuntimeConfig, + output: &Path, +) -> Result<(), RuntimeError> { + let sdk_root = sdk_storage_root(config); + let sdk_event_store_path = sdk_root.join(SDK_EVENT_STORE_FILE); + let sdk_outbox_path = sdk_root.join(SDK_OUTBOX_FILE); + let forbidden_paths = [ + sdk_root.as_path(), + config.local.replica_db_path.as_path(), + sdk_event_store_path.as_path(), + sdk_outbox_path.as_path(), + ]; + if forbidden_paths.iter().any(|forbidden| output == *forbidden) { + return Err(RuntimeError::Config(format!( + "backup destination {} would overwrite canonical or legacy store data", + output.display() + ))); + } + if output.starts_with(sdk_root.as_path()) { + return Err(RuntimeError::Config(format!( + "backup destination {} must not be inside the SDK canonical store directory", + output.display() + ))); } + Ok(()) +} + +fn sdk_backup_view(receipt: BackupReceipt) -> Result<LocalBackupView, CliSdkAdapterError> { + let event_store_file = receipt.event_store_path.as_ref().map(display_path); + let outbox_file = receipt.outbox_path.as_ref().map(display_path); + let manifest_file = receipt.manifest_path.as_ref().map(display_path); + let size_bytes = path_size(receipt.event_store_path.as_ref())? + + path_size(receipt.outbox_path.as_ref())? + + path_size(receipt.manifest_path.as_ref())?; + Ok(LocalBackupView { + state: sdk_backup_state_label(receipt.state).to_owned(), + source: SDK_CANONICAL_SOURCE.to_owned(), + backup_kind: SDK_BACKUP_KIND.to_owned(), + canonical_store: SDK_CANONICAL_STORE.to_owned(), + destination: display_path(&receipt.destination), + file: manifest_file + .clone() + .unwrap_or_else(|| receipt.destination.display().to_string()), + event_store_file, + outbox_file, + manifest_file, + size_bytes, + manifest: json_value(&receipt.manifest)?, + reason: None, + actions: Vec::new(), + }) +} + +fn sdk_backup_manifest_preview( + output: &Path, + status: &StorageStatusReceipt, + integrity: &IntegrityReceipt, +) -> Value { + json!({ + "manifest_kind": "sdk_canonical_backup_preview", + "destination": output.display().to_string(), + "source_storage": sdk_storage_kind_label(status.storage), + "source_paths": &status.paths, + "backup_paths": { + "event_store_path": output.join(SDK_EVENT_STORE_FILE).display().to_string(), + "outbox_path": output.join(SDK_OUTBOX_FILE).display().to_string(), + }, + "source_status": status, + "backup_verification": { + "event_store_ok": integrity.event_store_ok, + "outbox_ok": integrity.outbox_ok, + "event_store_result": &integrity.event_store_result, + "outbox_result": &integrity.outbox_result, + }, + }) +} + +fn sdk_storage_kind_label(kind: SdkStorageKind) -> &'static str { + match kind { + SdkStorageKind::Memory => "memory", + SdkStorageKind::Directory => "directory", + _ => "unknown", + } +} + +fn sdk_backup_state_label(state: SdkBackupState) -> &'static str { + match state { + SdkBackupState::Planned => "planned", + SdkBackupState::Completed => "completed", + _ => "unknown", + } +} + +fn json_value(value: impl Serialize) -> Result<Value, RuntimeError> { + serde_json::to_value(value).map_err(RuntimeError::from) +} + +fn path_size(path: Option<&PathBuf>) -> Result<u64, RuntimeError> { + path.map(fs::metadata) + .transpose()? + .map(|metadata| metadata.len()) + .ok_or_else(|| RuntimeError::Config("SDK backup did not report all file paths".to_owned())) +} + +fn display_path(path: &PathBuf) -> String { + path.display().to_string() } fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> { diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -609,13 +609,14 @@ 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, + pub canonical_store: String, + pub sdk_storage: String, + pub sdk_root: String, + pub sdk_existed_before_open: bool, + pub event_store: SdkEventStoreStatusView, + pub outbox: SdkOutboxStatusView, + pub integrity: SdkIntegrityView, + pub legacy_replica: LocalLegacyReplicaStatusView, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -626,12 +627,81 @@ impl LocalStatusView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { "unconfigured" => CommandDisposition::Unconfigured, + "needs_attention" => CommandDisposition::ValidationFailed, _ => CommandDisposition::Success, } } } #[derive(Debug, Clone, Serialize)] +pub struct SdkSqliteStatusView { + pub schema_version: i64, + pub journal_mode: String, + pub foreign_keys_enabled: bool, + pub busy_timeout_ms: i64, + pub integrity_ok: bool, + pub integrity_result: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SdkEventStoreStatusView { + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + pub store: SdkSqliteStatusView, + pub total_events: i64, + pub projection_eligible_events: i64, + pub relay_observations: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_event_seq: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_event_updated_at_ms: Option<i64>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SdkOutboxStatusView { + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + pub store: SdkSqliteStatusView, + pub total_events: i64, + pub pending_events: i64, + pub retryable_events: i64, + pub terminal_events: i64, + pub failed_terminal_events: i64, + pub ready_signed_events: i64, + pub publishing_events: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_attempt_at_ms: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SdkIntegrityView { + pub checked_paths: Vec<String>, + pub event_store_ok: bool, + pub outbox_ok: bool, + pub event_store_result: String, + pub outbox_result: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalLegacyReplicaStatusView { + pub state: String, + pub source: 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>, +} + +#[derive(Debug, Clone, Serialize)] pub struct LocalReplicaCountsView { pub farms: u64, pub listings: u64, @@ -3374,10 +3444,18 @@ pub struct SyncQueueView { pub struct LocalBackupView { pub state: String, pub source: String, + pub backup_kind: String, + pub canonical_store: String, + pub destination: String, pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_store_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub outbox_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub manifest_file: Option<String>, pub size_bytes: u64, - pub backup_format_version: String, - pub replica_db_version: String, + pub manifest: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1217,15 +1217,20 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { &value["result"]["publish"]["reason"], "radrootsd publish mode is deferred", ); - assert_eq!(value["result"]["actions"][0], "radroots store init"); - assert_eq!(value["result"]["actions"][1], "radroots account create"); + assert_eq!(value["result"]["store"]["state"], "ready"); + assert_eq!( + value["result"]["store"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); + assert_eq!(value["result"]["signer"]["state"], "unavailable"); + assert_eq!(value["result"]["actions"][0], "radroots account create"); assert_eq!( - value["result"]["actions"][2], + value["result"]["actions"][1], "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); - assert_eq!(value["next_actions"][0]["command"], "radroots store init"); assert_eq!( - value["next_actions"][1]["command"], + value["next_actions"][0]["command"], "radroots account create" ); assert_direct_relay_next_action( @@ -1259,11 +1264,12 @@ fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { &value["result"]["publish"]["reason"], "write-capable local account", ); - assert_eq!(value["result"]["actions"][0], "radroots store init"); - assert_eq!(value["result"]["actions"][1], "radroots account create"); - assert_eq!(value["next_actions"][0]["command"], "radroots store init"); + assert_eq!(value["result"]["store"]["state"], "ready"); + assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); + assert_eq!(value["result"]["signer"]["state"], "unconfigured"); + assert_eq!(value["result"]["actions"][0], "radroots account create"); assert_eq!( - value["next_actions"][1]["command"], + value["next_actions"][0]["command"], "radroots account create" ); } @@ -1289,15 +1295,20 @@ fn health_check_exposes_publish_readiness() { &value["result"]["checks"]["publish"]["reason"], "radrootsd publish mode is deferred", ); - assert_eq!(value["result"]["actions"][0], "radroots store init"); - assert_eq!(value["result"]["actions"][1], "radroots account create"); + assert_eq!(value["result"]["checks"]["store"]["state"], "ready"); assert_eq!( - value["result"]["actions"][2], + value["result"]["checks"]["store"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(value["result"]["checks"]["store"]["canonical_store"], "sdk"); + assert_eq!(value["result"]["checks"]["signer"]["state"], "unconfigured"); + assert_eq!(value["result"]["actions"][0], "radroots account create"); + assert_eq!( + value["result"]["actions"][1], "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); - assert_eq!(value["next_actions"][0]["command"], "radroots store init"); assert_eq!( - value["next_actions"][1]["command"], + value["next_actions"][0]["command"], "radroots account create" ); assert_direct_relay_next_action( @@ -2619,7 +2630,6 @@ fn human_health_status_surfaces_publish_reason_and_actions() { assert!(stdout.contains("publish_mode: nostr_relay")); assert!(stdout.contains("publish_state: unconfigured")); assert!(stdout.contains("reason: nostr_relay publish mode requires a selected or default write-capable local account")); - assert!(stdout.contains("- radroots store init")); assert!(stdout.contains("- radroots account create")); assert!(serde_json::from_str::<Value>(&stdout).is_err()); } @@ -3710,26 +3720,131 @@ fn store_export_dry_run_is_structured_unsupported() { #[test] fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { let sandbox = RadrootsCliSandbox::new(); - let (missing_output, missing_value) = - sandbox.json_output(&["--format", "json", "--dry-run", "store", "backup", "create"]); + let sdk_root = sandbox.root().join("data/apps/cli/replica/sdk"); - assert!(!missing_output.status.success()); - assert_eq!(missing_value["operation_id"], "store.backup.create"); - assert_eq!(missing_value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(missing_value["errors"][0]["exit_code"], 3); + assert!(!sdk_root.exists()); - let init = sandbox.json_success(&["--format", "json", "store", "init"]); - assert_eq!(init["operation_id"], "store.init"); + let status = sandbox.json_success(&["--format", "json", "store", "status", "get"]); - let backup = + assert_eq!(status["operation_id"], "store.status.get"); + assert_eq!(status["result"]["state"], "ready"); + assert_eq!( + status["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(status["result"]["canonical_store"], "sdk"); + assert_eq!(status["result"]["sdk_storage"], "directory"); + assert_eq!(status["result"]["sdk_existed_before_open"], false); + assert_eq!( + status["result"]["event_store"]["store"]["integrity_ok"], + true + ); + assert_eq!(status["result"]["outbox"]["store"]["integrity_ok"], true); + assert_eq!(status["result"]["legacy_replica"]["state"], "unconfigured"); + assert_eq!( + status["result"]["legacy_replica"]["source"], + "legacy local replica · derived/migration source" + ); + assert!(sdk_root.join("event_store.sqlite").exists()); + assert!(sdk_root.join("outbox.sqlite").exists()); + + let legacy = sandbox.json_success(&["--format", "json", "store", "init"]); + assert_eq!(legacy["operation_id"], "store.init"); + + let status_after_legacy = sandbox.json_success(&["--format", "json", "store", "status", "get"]); + + assert_eq!( + status_after_legacy["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!( + status_after_legacy["result"]["legacy_replica"]["state"], + "ready" + ); + assert_eq!( + status_after_legacy["result"]["legacy_replica"]["source"], + "legacy local replica · derived/migration source" + ); + + let dry_run = sandbox.json_success(&["--format", "json", "--dry-run", "store", "backup", "create"]); - let file = backup["result"]["file"].as_str().expect("backup file"); + let dry_run_destination = dry_run["result"]["destination"] + .as_str() + .expect("backup destination"); + let dry_run_file = dry_run["result"]["file"].as_str().expect("backup file"); + + assert_eq!(dry_run["operation_id"], "store.backup.create"); + assert_eq!(dry_run["dry_run"], true); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!( + dry_run["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(dry_run["result"]["backup_kind"], "sdk_canonical"); + assert_eq!(dry_run["result"]["canonical_store"], "sdk"); + assert_eq!(dry_run["result"]["size_bytes"], 0); + assert_eq!( + dry_run["result"]["manifest"]["manifest_kind"], + "sdk_canonical_backup_preview" + ); + assert_eq!( + dry_run["result"]["manifest"]["backup_verification"]["event_store_ok"], + true + ); + assert_eq!( + dry_run["result"]["manifest"]["backup_verification"]["outbox_ok"], + true + ); + assert!(!Path::new(dry_run_destination).exists()); + assert!(!Path::new(dry_run_file).exists()); + + let backup = sandbox.json_success(&["--format", "json", "store", "backup", "create"]); + let backup_destination = backup["result"]["destination"] + .as_str() + .expect("backup destination"); + let event_store_file = backup["result"]["event_store_file"] + .as_str() + .expect("event store backup"); + let outbox_file = backup["result"]["outbox_file"] + .as_str() + .expect("outbox backup"); + let manifest_file = backup["result"]["manifest_file"] + .as_str() + .expect("manifest backup"); assert_eq!(backup["operation_id"], "store.backup.create"); - assert_eq!(backup["dry_run"], true); - assert_eq!(backup["result"]["state"], "dry_run"); - assert_eq!(backup["result"]["size_bytes"], 0); - assert!(!Path::new(file).exists()); + assert_eq!(backup["result"]["state"], "completed"); + assert_eq!( + backup["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(backup["result"]["backup_kind"], "sdk_canonical"); + assert_eq!(backup["result"]["canonical_store"], "sdk"); + assert!( + backup["result"]["size_bytes"] + .as_u64() + .expect("backup size") + > 0 + ); + assert!(Path::new(backup_destination).exists()); + assert!(Path::new(event_store_file).exists()); + assert!(Path::new(outbox_file).exists()); + assert!(Path::new(manifest_file).exists()); + assert_eq!( + backup["result"]["manifest"]["backup_verification"]["event_store_ok"], + true + ); + assert_eq!( + backup["result"]["manifest"]["backup_verification"]["outbox_ok"], + true + ); + assert!( + backup["result"]["manifest"]["source_paths"]["event_store_path"] + .as_str() + .expect("source event store path") + .contains("data/apps/cli/replica/sdk/event_store.sqlite") + ); + assert!(!event_store_file.ends_with("replica.sqlite")); } #[test]