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:
| M | src/ops/exec/core.rs | | | 97 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- |
| M | src/runtime/store.rs | | | 386 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
| M | src/view/runtime.rs | | | 96 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- |
| M | tests/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]