cli

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

store.rs (25535B)


      1 use std::fs;
      2 use std::path::{Path, PathBuf};
      3 
      4 use radroots_replica_db::export::{ReplicaDbExportManifestRs, export_manifest};
      5 use radroots_replica_db::migrations;
      6 use radroots_replica_sync::radroots_replica_sync_status;
      7 use radroots_sdk::{
      8     BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk, RestoreReceipt,
      9     RestoreRequest, SdkBackupState, SdkEventStoreStorageStatus, SdkOutboxStorageStatus,
     10     SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt,
     11     StorageStatusRequest,
     12 };
     13 use radroots_sql_core::SqliteExecutor;
     14 use serde::Serialize;
     15 use serde_json::{Value, json};
     16 
     17 use crate::cli::global::LocalExportFormatArg;
     18 use crate::runtime::RuntimeError;
     19 use crate::runtime::config::RuntimeConfig;
     20 use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_runtime, sdk_storage_root};
     21 use crate::runtime::sync::ensure_sync_run_table;
     22 use crate::view::runtime::{
     23     LocalBackupView, LocalExportView, LocalInitView, LocalLegacyReplicaStatusView,
     24     LocalReplicaCountsView, LocalReplicaSyncView, LocalRestoreView, LocalStatusView,
     25     SdkEventStoreStatusView, SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView,
     26 };
     27 
     28 const LEGACY_REPLICA_SOURCE: &str = "legacy local replica ยท derived/migration source";
     29 const SDK_CANONICAL_SOURCE: &str = "SDK canonical event store and outbox";
     30 const SDK_CANONICAL_STORE: &str = "sdk";
     31 const SDK_BACKUP_KIND: &str = "sdk_canonical";
     32 const SDK_BACKUP_MANIFEST_FILE: &str = "manifest.json";
     33 const SDK_EVENT_STORE_FILE: &str = "event_store.sqlite";
     34 const SDK_OUTBOX_FILE: &str = "outbox.sqlite";
     35 
     36 pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> {
     37     let existed = config.local.replica_db_path.exists();
     38     ensure_local_roots(config)?;
     39     let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
     40     migrations::run_all_up(&executor)?;
     41     ensure_sync_run_table(&executor)?;
     42     let manifest = export_manifest(&executor)?;
     43 
     44     Ok(LocalInitView {
     45         state: if existed {
     46             "ready".to_owned()
     47         } else {
     48             "initialized".to_owned()
     49         },
     50         source: LEGACY_REPLICA_SOURCE.to_owned(),
     51         local_root: config.local.root.display().to_string(),
     52         replica_db: "ready".to_owned(),
     53         path: config.local.replica_db_path.display().to_string(),
     54         replica_db_version: manifest.replica_db_version,
     55         backup_format_version: manifest.backup_format_version,
     56     })
     57 }
     58 
     59 pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> {
     60     validate_local_roots(config)?;
     61     if config.local.replica_db_path.exists() {
     62         let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
     63         ensure_sync_run_table(&executor)?;
     64         let manifest = export_manifest(&executor)?;
     65         return Ok(LocalInitView {
     66             state: "ready".to_owned(),
     67             source: LEGACY_REPLICA_SOURCE.to_owned(),
     68             local_root: config.local.root.display().to_string(),
     69             replica_db: "ready".to_owned(),
     70             path: config.local.replica_db_path.display().to_string(),
     71             replica_db_version: manifest.replica_db_version,
     72             backup_format_version: manifest.backup_format_version,
     73         });
     74     }
     75 
     76     Ok(LocalInitView {
     77         state: "dry_run".to_owned(),
     78         source: LEGACY_REPLICA_SOURCE.to_owned(),
     79         local_root: config.local.root.display().to_string(),
     80         replica_db: "missing".to_owned(),
     81         path: config.local.replica_db_path.display().to_string(),
     82         replica_db_version: String::new(),
     83         backup_format_version: String::new(),
     84     })
     85 }
     86 
     87 pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterError> {
     88     let sdk_root = sdk_storage_root(config);
     89     let sdk_existed_before_open = sdk_storage_files_exist(sdk_root.as_path());
     90     let legacy_replica = legacy_replica_status(config)?;
     91     let session = CliSdkSession::connect(config)?;
     92     let receipt = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?;
     93     let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?;
     94     Ok(sdk_status_view(
     95         config,
     96         sdk_root,
     97         sdk_existed_before_open,
     98         receipt,
     99         integrity,
    100         legacy_replica,
    101     ))
    102 }
    103 
    104 fn legacy_replica_status(
    105     config: &RuntimeConfig,
    106 ) -> Result<LocalLegacyReplicaStatusView, RuntimeError> {
    107     if !config.local.replica_db_path.exists() {
    108         return Ok(LocalLegacyReplicaStatusView {
    109             state: "unconfigured".to_owned(),
    110             source: LEGACY_REPLICA_SOURCE.to_owned(),
    111             replica_db: "missing".to_owned(),
    112             path: config.local.replica_db_path.display().to_string(),
    113             replica_db_version: String::new(),
    114             backup_format_version: String::new(),
    115             schema_hash: String::new(),
    116             counts: LocalReplicaCountsView {
    117                 farms: 0,
    118                 listings: 0,
    119                 profiles: 0,
    120                 relays: 0,
    121                 event_states: 0,
    122             },
    123             sync: LocalReplicaSyncView {
    124                 expected_count: 0,
    125                 pending_count: 0,
    126             },
    127             reason: Some("local replica database is not initialized".to_owned()),
    128             actions: vec!["radroots store init".to_owned()],
    129         });
    130     }
    131 
    132     let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
    133     ensure_sync_run_table(&executor)?;
    134     let manifest = export_manifest(&executor)?;
    135     let sync = radroots_replica_sync_status(&executor)?;
    136 
    137     Ok(LocalLegacyReplicaStatusView {
    138         state: "ready".to_owned(),
    139         source: LEGACY_REPLICA_SOURCE.to_owned(),
    140         replica_db: "ready".to_owned(),
    141         path: config.local.replica_db_path.display().to_string(),
    142         replica_db_version: manifest.replica_db_version.clone(),
    143         backup_format_version: manifest.backup_format_version.clone(),
    144         schema_hash: manifest.schema_hash.clone(),
    145         counts: manifest_counts(&manifest),
    146         sync: LocalReplicaSyncView {
    147             expected_count: sync.expected_count,
    148             pending_count: sync.pending_count,
    149         },
    150         reason: None,
    151         actions: Vec::new(),
    152     })
    153 }
    154 
    155 pub fn backup(
    156     config: &RuntimeConfig,
    157     output: &Path,
    158 ) -> Result<LocalBackupView, CliSdkAdapterError> {
    159     ensure_safe_sdk_backup_destination(config, output)?;
    160     let session = CliSdkSession::connect(config)?;
    161     let receipt = session.block_on(session.sdk().backup(BackupRequest::new(output)))?;
    162     sdk_backup_view(receipt)
    163 }
    164 
    165 pub fn backup_preflight(
    166     config: &RuntimeConfig,
    167     output: &Path,
    168 ) -> Result<LocalBackupView, CliSdkAdapterError> {
    169     ensure_safe_sdk_backup_destination(config, output)?;
    170     let session = CliSdkSession::connect(config)?;
    171     let status = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?;
    172     let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?;
    173     let manifest = sdk_backup_manifest_preview(output, &status, &integrity);
    174     Ok(LocalBackupView {
    175         state: "dry_run".to_owned(),
    176         source: SDK_CANONICAL_SOURCE.to_owned(),
    177         backup_kind: SDK_BACKUP_KIND.to_owned(),
    178         canonical_store: SDK_CANONICAL_STORE.to_owned(),
    179         destination: output.display().to_string(),
    180         file: output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string(),
    181         event_store_file: Some(output.join(SDK_EVENT_STORE_FILE).display().to_string()),
    182         outbox_file: Some(output.join(SDK_OUTBOX_FILE).display().to_string()),
    183         manifest_file: Some(output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string()),
    184         size_bytes: 0,
    185         manifest,
    186         reason: Some(
    187             "dry run requested; SDK canonical backup directory was not written".to_owned(),
    188         ),
    189         actions: vec!["radroots store backup create".to_owned()],
    190     })
    191 }
    192 
    193 pub fn restore(
    194     config: &RuntimeConfig,
    195     source: &Path,
    196     destination: Option<&Path>,
    197     overwrite: bool,
    198     dry_run: bool,
    199 ) -> Result<LocalRestoreView, CliSdkAdapterError> {
    200     let destination = destination
    201         .map(Path::to_path_buf)
    202         .unwrap_or_else(|| sdk_storage_root(config));
    203     ensure_safe_sdk_restore_destination(config, &destination)?;
    204     let request = RestoreRequest::new(source)
    205         .with_destination(destination)
    206         .with_overwrite(overwrite)
    207         .with_dry_run(dry_run);
    208     let runtime = sdk_runtime()?;
    209     let receipt = runtime.block_on(RadrootsSdk::restore(request))?;
    210     sdk_restore_view(receipt, overwrite, dry_run)
    211 }
    212 
    213 pub fn export(
    214     config: &RuntimeConfig,
    215     format: LocalExportFormatArg,
    216     output: &Path,
    217 ) -> Result<LocalExportView, RuntimeError> {
    218     if !config.local.replica_db_path.exists() {
    219         return Ok(LocalExportView {
    220             state: "unconfigured".to_owned(),
    221             source: LEGACY_REPLICA_SOURCE.to_owned(),
    222             format: format.as_str().to_owned(),
    223             file: output.display().to_string(),
    224             records: 0,
    225             export_version: String::new(),
    226             schema_hash: String::new(),
    227             reason: Some("local replica database is not initialized".to_owned()),
    228             actions: vec!["radroots store init".to_owned()],
    229         });
    230     }
    231 
    232     ensure_safe_output_path(config, output)?;
    233     create_parent_dir(output)?;
    234 
    235     let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
    236     let manifest = export_manifest(&executor)?;
    237     let sync = radroots_replica_sync_status(&executor)?;
    238     let records = match format {
    239         LocalExportFormatArg::Json => {
    240             let export = json!({
    241                 "kind": "local_export_manifest_v1",
    242                 "source": LEGACY_REPLICA_SOURCE,
    243                 "replica_db_version": manifest.replica_db_version,
    244                 "backup_format_version": manifest.backup_format_version,
    245                 "export_version": manifest.export_version,
    246                 "schema_hash": manifest.schema_hash,
    247                 "sync": {
    248                     "expected_count": sync.expected_count,
    249                     "pending_count": sync.pending_count,
    250                 },
    251                 "table_counts": manifest.table_counts,
    252             });
    253             fs::write(output, serde_json::to_string_pretty(&export)?)?;
    254             1
    255         }
    256         LocalExportFormatArg::Ndjson => {
    257             let mut lines = Vec::new();
    258             lines.push(
    259                 json!({
    260                     "kind": "local_export_manifest",
    261                     "source": LEGACY_REPLICA_SOURCE,
    262                     "replica_db_version": manifest.replica_db_version,
    263                     "backup_format_version": manifest.backup_format_version,
    264                     "export_version": manifest.export_version,
    265                     "schema_hash": manifest.schema_hash,
    266                 })
    267                 .to_string(),
    268             );
    269             lines.push(
    270                 json!({
    271                     "kind": "local_sync_status",
    272                     "expected_count": sync.expected_count,
    273                     "pending_count": sync.pending_count,
    274                 })
    275                 .to_string(),
    276             );
    277             for table in &manifest.table_counts {
    278                 lines.push(
    279                     json!({
    280                         "kind": "local_table_count",
    281                         "table": table.name,
    282                         "row_count": table.row_count,
    283                     })
    284                     .to_string(),
    285                 );
    286             }
    287             fs::write(output, format!("{}\n", lines.join("\n")))?;
    288             lines.len()
    289         }
    290     };
    291 
    292     Ok(LocalExportView {
    293         state: "exported".to_owned(),
    294         source: LEGACY_REPLICA_SOURCE.to_owned(),
    295         format: format.as_str().to_owned(),
    296         file: output.display().to_string(),
    297         records,
    298         export_version: manifest.export_version,
    299         schema_hash: manifest.schema_hash,
    300         reason: None,
    301         actions: Vec::new(),
    302     })
    303 }
    304 
    305 fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> {
    306     fs::create_dir_all(&config.local.root)?;
    307     fs::create_dir_all(&config.local.backups_dir)?;
    308     fs::create_dir_all(&config.local.exports_dir)?;
    309     Ok(())
    310 }
    311 
    312 fn validate_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> {
    313     validate_directory_target(&config.local.root)?;
    314     validate_directory_target(&config.local.backups_dir)?;
    315     validate_directory_target(&config.local.exports_dir)?;
    316     Ok(())
    317 }
    318 
    319 fn validate_directory_target(path: &Path) -> Result<(), RuntimeError> {
    320     let mut candidate = path.to_path_buf();
    321     loop {
    322         if candidate.exists() {
    323             if candidate.is_dir() {
    324                 return Ok(());
    325             }
    326             return Err(RuntimeError::Config(format!(
    327                 "path {} is not a directory",
    328                 candidate.display()
    329             )));
    330         }
    331         if !candidate.pop() {
    332             return Err(RuntimeError::Config(format!(
    333                 "path {} has no existing parent directory",
    334                 path.display()
    335             )));
    336         }
    337     }
    338 }
    339 
    340 fn sdk_storage_files_exist(sdk_root: &Path) -> bool {
    341     sdk_root.join(SDK_EVENT_STORE_FILE).exists() && sdk_root.join(SDK_OUTBOX_FILE).exists()
    342 }
    343 
    344 fn sdk_status_view(
    345     config: &RuntimeConfig,
    346     sdk_root: PathBuf,
    347     sdk_existed_before_open: bool,
    348     receipt: StorageStatusReceipt,
    349     integrity: IntegrityReceipt,
    350     legacy_replica: LocalLegacyReplicaStatusView,
    351 ) -> LocalStatusView {
    352     let event_store_path = receipt
    353         .paths
    354         .as_ref()
    355         .map(|paths| paths.event_store_path.display().to_string());
    356     let outbox_path = receipt
    357         .paths
    358         .as_ref()
    359         .map(|paths| paths.outbox_path.display().to_string());
    360     let state = sdk_status_state(&receipt, &integrity).to_owned();
    361     let reason = sdk_status_reason(&state);
    362     let actions = sdk_status_actions(&state);
    363     LocalStatusView {
    364         state,
    365         source: SDK_CANONICAL_SOURCE.to_owned(),
    366         local_root: config.local.root.display().to_string(),
    367         canonical_store: SDK_CANONICAL_STORE.to_owned(),
    368         sdk_storage: sdk_storage_kind_label(receipt.storage).to_owned(),
    369         sdk_root: sdk_root.display().to_string(),
    370         sdk_existed_before_open,
    371         event_store: sdk_event_store_status_view(receipt.event_store, event_store_path),
    372         outbox: sdk_outbox_status_view(receipt.outbox, outbox_path),
    373         integrity: sdk_integrity_view(integrity),
    374         legacy_replica,
    375         reason,
    376         actions,
    377     }
    378 }
    379 
    380 fn sdk_status_state(receipt: &StorageStatusReceipt, integrity: &IntegrityReceipt) -> &'static str {
    381     if receipt.event_store.store.integrity_ok
    382         && receipt.outbox.store.integrity_ok
    383         && integrity.event_store_ok
    384         && integrity.outbox_ok
    385     {
    386         "ready"
    387     } else {
    388         "needs_attention"
    389     }
    390 }
    391 
    392 fn sdk_status_reason(state: &str) -> Option<String> {
    393     match state {
    394         "ready" => None,
    395         _ => Some("SDK canonical store integrity check failed".to_owned()),
    396     }
    397 }
    398 
    399 fn sdk_status_actions(state: &str) -> Vec<String> {
    400     match state {
    401         "ready" => Vec::new(),
    402         _ => vec!["radroots store status get".to_owned()],
    403     }
    404 }
    405 
    406 fn sdk_event_store_status_view(
    407     status: SdkEventStoreStorageStatus,
    408     path: Option<String>,
    409 ) -> SdkEventStoreStatusView {
    410     SdkEventStoreStatusView {
    411         path,
    412         store: sdk_sqlite_status_view(status.store),
    413         total_events: status.total_events,
    414         projection_eligible_events: status.projection_eligible_events,
    415         relay_observations: status.relay_observations,
    416         last_event_seq: status.last_event_seq,
    417         last_event_updated_at_ms: status.last_event_updated_at_ms,
    418     }
    419 }
    420 
    421 fn sdk_outbox_status_view(
    422     status: SdkOutboxStorageStatus,
    423     path: Option<String>,
    424 ) -> SdkOutboxStatusView {
    425     SdkOutboxStatusView {
    426         path,
    427         store: sdk_sqlite_status_view(status.store),
    428         total_events: status.total_events,
    429         pending_events: status.pending_events,
    430         retryable_events: status.retryable_events,
    431         terminal_events: status.terminal_events,
    432         failed_terminal_events: status.failed_terminal_events,
    433         ready_signed_events: status.ready_signed_events,
    434         publishing_events: status.publishing_events,
    435         last_attempt_at_ms: status.last_attempt_at_ms,
    436         last_error: status.last_error,
    437     }
    438 }
    439 
    440 fn sdk_sqlite_status_view(status: SdkSqliteStoreStatus) -> SdkSqliteStatusView {
    441     SdkSqliteStatusView {
    442         schema_version: status.schema_version,
    443         journal_mode: status.journal_mode,
    444         foreign_keys_enabled: status.foreign_keys_enabled,
    445         busy_timeout_ms: status.busy_timeout_ms,
    446         integrity_ok: status.integrity_ok,
    447         integrity_result: status.integrity_result,
    448     }
    449 }
    450 
    451 fn sdk_integrity_view(receipt: IntegrityReceipt) -> SdkIntegrityView {
    452     SdkIntegrityView {
    453         checked_paths: receipt
    454             .checked_paths
    455             .into_iter()
    456             .map(|path| path.display().to_string())
    457             .collect(),
    458         event_store_ok: receipt.event_store_ok,
    459         outbox_ok: receipt.outbox_ok,
    460         event_store_result: receipt.event_store_result,
    461         outbox_result: receipt.outbox_result,
    462     }
    463 }
    464 
    465 fn ensure_safe_sdk_backup_destination(
    466     config: &RuntimeConfig,
    467     output: &Path,
    468 ) -> Result<(), RuntimeError> {
    469     let sdk_root = sdk_storage_root(config);
    470     let sdk_event_store_path = sdk_root.join(SDK_EVENT_STORE_FILE);
    471     let sdk_outbox_path = sdk_root.join(SDK_OUTBOX_FILE);
    472     let forbidden_paths = [
    473         sdk_root.as_path(),
    474         config.local.replica_db_path.as_path(),
    475         sdk_event_store_path.as_path(),
    476         sdk_outbox_path.as_path(),
    477     ];
    478     if forbidden_paths.iter().any(|forbidden| output == *forbidden) {
    479         return Err(RuntimeError::Config(format!(
    480             "backup destination {} would overwrite canonical or legacy store data",
    481             output.display()
    482         )));
    483     }
    484     if output.starts_with(sdk_root.as_path()) {
    485         return Err(RuntimeError::Config(format!(
    486             "backup destination {} must not be inside the SDK canonical store directory",
    487             output.display()
    488         )));
    489     }
    490     Ok(())
    491 }
    492 
    493 fn ensure_safe_sdk_restore_destination(
    494     config: &RuntimeConfig,
    495     destination: &Path,
    496 ) -> Result<(), RuntimeError> {
    497     let sdk_root = sdk_storage_root(config);
    498     let sdk_event_store_path = sdk_root.join(SDK_EVENT_STORE_FILE);
    499     let sdk_outbox_path = sdk_root.join(SDK_OUTBOX_FILE);
    500     let forbidden_paths = [
    501         config.local.root.as_path(),
    502         config.local.replica_db_path.as_path(),
    503         sdk_event_store_path.as_path(),
    504         sdk_outbox_path.as_path(),
    505     ];
    506     if forbidden_paths
    507         .iter()
    508         .any(|forbidden| destination == *forbidden)
    509     {
    510         return Err(RuntimeError::Config(format!(
    511             "restore destination {} would overwrite canonical runtime roots or store files",
    512             destination.display()
    513         )));
    514     }
    515     if config.local.replica_db_path.starts_with(destination)
    516         || config.local.backups_dir.starts_with(destination)
    517         || config.local.exports_dir.starts_with(destination)
    518     {
    519         return Err(RuntimeError::Config(format!(
    520             "restore destination {} must not contain CLI runtime state directories",
    521             destination.display()
    522         )));
    523     }
    524     Ok(())
    525 }
    526 
    527 fn sdk_backup_view(receipt: BackupReceipt) -> Result<LocalBackupView, CliSdkAdapterError> {
    528     let event_store_file = receipt.event_store_path.as_ref().map(display_path);
    529     let outbox_file = receipt.outbox_path.as_ref().map(display_path);
    530     let manifest_file = receipt.manifest_path.as_ref().map(display_path);
    531     let size_bytes = path_size(receipt.event_store_path.as_ref())?
    532         + path_size(receipt.outbox_path.as_ref())?
    533         + path_size(receipt.manifest_path.as_ref())?;
    534     Ok(LocalBackupView {
    535         state: sdk_backup_state_label(receipt.state).to_owned(),
    536         source: SDK_CANONICAL_SOURCE.to_owned(),
    537         backup_kind: SDK_BACKUP_KIND.to_owned(),
    538         canonical_store: SDK_CANONICAL_STORE.to_owned(),
    539         destination: display_path(&receipt.destination),
    540         file: manifest_file
    541             .clone()
    542             .unwrap_or_else(|| receipt.destination.display().to_string()),
    543         event_store_file,
    544         outbox_file,
    545         manifest_file,
    546         size_bytes,
    547         manifest: json_value(&receipt.manifest)?,
    548         reason: None,
    549         actions: Vec::new(),
    550     })
    551 }
    552 
    553 fn sdk_restore_view(
    554     receipt: RestoreReceipt,
    555     overwrite: bool,
    556     dry_run: bool,
    557 ) -> Result<LocalRestoreView, CliSdkAdapterError> {
    558     let destination_paths = receipt.destination_paths.as_ref();
    559     let restored_paths = receipt.restored_paths.as_ref();
    560     Ok(LocalRestoreView {
    561         state: sdk_restore_state_label(receipt.state).to_owned(),
    562         source: SDK_CANONICAL_SOURCE.to_owned(),
    563         restore_kind: SDK_BACKUP_KIND.to_owned(),
    564         canonical_store: SDK_CANONICAL_STORE.to_owned(),
    565         backup_source: display_path(&receipt.source),
    566         destination: receipt
    567             .destination
    568             .as_ref()
    569             .map(display_path)
    570             .unwrap_or_default(),
    571         event_store_file: display_path(&receipt.event_store_path),
    572         outbox_file: display_path(&receipt.outbox_path),
    573         manifest_file: display_path(&receipt.manifest_path),
    574         destination_event_store_file: destination_paths
    575             .map(|paths| display_path(&paths.event_store_path)),
    576         destination_outbox_file: destination_paths.map(|paths| display_path(&paths.outbox_path)),
    577         restored_event_store_file: restored_paths
    578             .map(|paths| display_path(&paths.event_store_path)),
    579         restored_outbox_file: restored_paths.map(|paths| display_path(&paths.outbox_path)),
    580         manifest: json_value(&receipt.manifest)?,
    581         verification: json_value(&receipt.verification)?,
    582         overwrite,
    583         dry_run,
    584         reason: if dry_run {
    585             Some("dry run requested; SDK canonical store was not restored".to_owned())
    586         } else {
    587             None
    588         },
    589         actions: if dry_run {
    590             vec!["radroots store backup restore <backup-dir>".to_owned()]
    591         } else {
    592             Vec::new()
    593         },
    594     })
    595 }
    596 
    597 fn sdk_restore_state_label(state: SdkRestoreState) -> &'static str {
    598     match state {
    599         SdkRestoreState::Validated => "validated",
    600         SdkRestoreState::DryRun => "dry_run",
    601         SdkRestoreState::Completed => "completed",
    602         _ => "unknown",
    603     }
    604 }
    605 
    606 fn sdk_backup_manifest_preview(
    607     output: &Path,
    608     status: &StorageStatusReceipt,
    609     integrity: &IntegrityReceipt,
    610 ) -> Value {
    611     json!({
    612         "manifest_kind": "sdk_canonical_backup_preview",
    613         "destination": output.display().to_string(),
    614         "source_storage": sdk_storage_kind_label(status.storage),
    615         "source_paths": &status.paths,
    616         "backup_paths": {
    617             "event_store_path": output.join(SDK_EVENT_STORE_FILE).display().to_string(),
    618             "outbox_path": output.join(SDK_OUTBOX_FILE).display().to_string(),
    619         },
    620         "source_status": status,
    621         "backup_verification": {
    622             "event_store_ok": integrity.event_store_ok,
    623             "outbox_ok": integrity.outbox_ok,
    624             "event_store_result": &integrity.event_store_result,
    625             "outbox_result": &integrity.outbox_result,
    626         },
    627     })
    628 }
    629 
    630 fn sdk_storage_kind_label(kind: SdkStorageKind) -> &'static str {
    631     match kind {
    632         SdkStorageKind::Memory => "memory",
    633         SdkStorageKind::Directory => "directory",
    634         _ => "unknown",
    635     }
    636 }
    637 
    638 fn sdk_backup_state_label(state: SdkBackupState) -> &'static str {
    639     match state {
    640         SdkBackupState::Planned => "planned",
    641         SdkBackupState::Completed => "completed",
    642         _ => "unknown",
    643     }
    644 }
    645 
    646 fn json_value(value: impl Serialize) -> Result<Value, RuntimeError> {
    647     serde_json::to_value(value).map_err(RuntimeError::from)
    648 }
    649 
    650 fn path_size(path: Option<&PathBuf>) -> Result<u64, RuntimeError> {
    651     path.map(fs::metadata)
    652         .transpose()?
    653         .map(|metadata| metadata.len())
    654         .ok_or_else(|| RuntimeError::Config("SDK backup did not report all file paths".to_owned()))
    655 }
    656 
    657 fn display_path(path: &PathBuf) -> String {
    658     path.display().to_string()
    659 }
    660 
    661 fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> {
    662     if let Some(parent) = path.parent() {
    663         fs::create_dir_all(parent)?;
    664     }
    665     Ok(())
    666 }
    667 
    668 fn ensure_safe_output_path(config: &RuntimeConfig, output: &Path) -> Result<(), RuntimeError> {
    669     if output == config.local.replica_db_path.as_path() {
    670         return Err(RuntimeError::Config(format!(
    671             "output path {} would overwrite the local replica database",
    672             output.display()
    673         )));
    674     }
    675     Ok(())
    676 }
    677 
    678 fn manifest_counts(manifest: &ReplicaDbExportManifestRs) -> LocalReplicaCountsView {
    679     LocalReplicaCountsView {
    680         farms: table_row_count(manifest, "farm"),
    681         listings: table_row_count(manifest, "trade_product"),
    682         profiles: table_row_count(manifest, "nostr_profile"),
    683         relays: table_row_count(manifest, "direct_nostr_relay"),
    684         event_states: table_row_count(manifest, "nostr_event_state"),
    685     }
    686 }
    687 
    688 fn table_row_count(manifest: &ReplicaDbExportManifestRs, name: &str) -> u64 {
    689     manifest
    690         .table_counts
    691         .iter()
    692         .find(|table| table.name == name)
    693         .map(|table| table.row_count)
    694         .unwrap_or(0)
    695 }