commit 5a0470046729c7dc62748707f54f89f0c2723878
parent 51b0873989d32d58e8ae7e94f35915f4a57765f4
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 18:16:04 -0700
cli: expose sdk backup restore
- add store backup restore parser and registry metadata
- delegate restore execution to SDK without opening destination stores first
- gate overwrite restores on approval tokens outside dry-run
- cover dry-run, restore, overwrite, and SDK error output in target tests
Diffstat:
12 files changed, 433 insertions(+), 40 deletions(-)
diff --git a/src/cli/input.rs b/src/cli/input.rs
@@ -48,8 +48,8 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData {
FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand,
MarketListingCommand, MarketProductCommand, OrderAppCommand, OrderCommand,
OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand,
- OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, ValidationCommand,
- ValidationReceiptCommand,
+ OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, StoreBackupCommand,
+ StoreCommand, ValidationCommand, ValidationReceiptCommand,
};
let mut input = OperationData::new();
@@ -168,6 +168,19 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData {
},
MarketCommand::Refresh => {}
},
+ TargetCommand::Store(args) => match &args.command {
+ StoreCommand::Backup(backup) => match &backup.command {
+ StoreBackupCommand::Restore(args) => {
+ insert_path(&mut input, "source", &Some(args.source.clone()));
+ insert_path(&mut input, "destination", &args.destination);
+ if args.overwrite {
+ input.insert("overwrite".to_owned(), Value::Bool(true));
+ }
+ }
+ StoreBackupCommand::Create => {}
+ },
+ StoreCommand::Init | StoreCommand::Status(_) | StoreCommand::Export => {}
+ },
TargetCommand::Basket(args) => match &args.command {
BasketCommand::Create(args) => {
insert_string(&mut input, "basket_id", &args.basket_id);
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
@@ -182,8 +182,9 @@ impl TargetCommand {
StoreStatusCommand::Get => "store.status.get",
},
StoreCommand::Export => "store.export",
- StoreCommand::Backup(backup) => match backup.command {
+ StoreCommand::Backup(backup) => match &backup.command {
StoreBackupCommand::Create => "store.backup.create",
+ StoreBackupCommand::Restore(_) => "store.backup.restore",
},
},
Self::Sync(args) => match &args.command {
diff --git a/src/cli/store.rs b/src/cli/store.rs
@@ -1,4 +1,6 @@
-use clap::{Args, Subcommand};
+use std::path::PathBuf;
+
+use clap::{ArgAction, Args, Subcommand};
#[derive(Debug, Clone, Args)]
pub struct StoreArgs {
@@ -31,7 +33,17 @@ pub struct StoreBackupArgs {
pub command: StoreBackupCommand,
}
-#[derive(Debug, Clone, Copy, Subcommand)]
+#[derive(Debug, Clone, Subcommand)]
pub enum StoreBackupCommand {
Create,
+ Restore(StoreBackupRestoreArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct StoreBackupRestoreArgs {
+ pub source: PathBuf,
+ #[arg(long = "destination")]
+ pub destination: Option<PathBuf>,
+ #[arg(long = "overwrite", action = ArgAction::SetTrue)]
+ pub overwrite: bool,
}
diff --git a/src/main.rs b/src/main.rs
@@ -132,6 +132,9 @@ fn execute_request(
TargetOperationRequest::StoreBackupCreate(request) => {
execute_with(CoreOperationService::new(config, logging), request)
}
+ TargetOperationRequest::StoreBackupRestore(request) => {
+ execute_with(CoreOperationService::new(config, logging), request)
+ }
TargetOperationRequest::SignerStatusGet(request) => {
execute_with(RuntimeOperationService::new(config), request)
}
diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs
@@ -14,9 +14,9 @@ use crate::ops::{
HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, OperationAdapterError,
OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult,
OperationResultData, OperationService, StoreBackupCreateRequest, StoreBackupCreateResult,
- StoreExportRequest, StoreExportResult, StoreInitRequest, StoreInitResult,
- StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, WorkspaceGetResult,
- WorkspaceInitRequest, WorkspaceInitResult,
+ StoreBackupRestoreRequest, StoreBackupRestoreResult, StoreExportRequest, StoreExportResult,
+ StoreInitRequest, StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult,
+ WorkspaceGetRequest, WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult,
};
use crate::runtime::RuntimeError;
use crate::runtime::account::{
@@ -33,8 +33,8 @@ use crate::runtime::config::{
use crate::runtime::logging::LoggingState;
use crate::runtime::sdk::CliSdkAdapterError;
use crate::view::runtime::{
- CommandDisposition, LocalBackupView, PublishProviderRuntimeView, PublishRelayRuntimeView,
- PublishRuntimeView,
+ CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView,
+ PublishRelayRuntimeView, PublishRuntimeView,
};
pub struct CoreOperationService<'a> {
@@ -654,6 +654,36 @@ impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> {
}
}
+impl OperationService<StoreBackupRestoreRequest> for CoreOperationService<'_> {
+ type Result = StoreBackupRestoreResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<StoreBackupRestoreRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let source = required_path(&request, "source")?;
+ let destination = optional_path(&request, "destination");
+ let overwrite = bool_input(&request, "overwrite").unwrap_or(false);
+ if overwrite && request.context.requires_approval_token() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
+
+ let view = map_sdk_adapter(
+ request.operation_id(),
+ crate::runtime::store::restore(
+ self.config,
+ source.as_path(),
+ destination.as_deref(),
+ overwrite,
+ request.context.dry_run,
+ ),
+ )?;
+ local_restore_result(request.operation_id(), &view)
+ }
+}
+
fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError>
where
R: OperationResultData,
@@ -728,6 +758,34 @@ fn local_backup_result(
}
}
+fn local_restore_result(
+ operation_id: &str,
+ view: &LocalRestoreView,
+) -> Result<OperationResult<StoreBackupRestoreResult>, OperationAdapterError> {
+ match view.disposition() {
+ CommandDisposition::Success => {
+ serialized_operation_result::<StoreBackupRestoreResult, _>(view)
+ }
+ disposition => Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ view.reason.clone().unwrap_or_else(|| match disposition {
+ CommandDisposition::Success => "store restore succeeded".to_owned(),
+ CommandDisposition::NotFound => "store restore source was not found".to_owned(),
+ CommandDisposition::ValidationFailed => {
+ "store restore validation failed".to_owned()
+ }
+ CommandDisposition::Unconfigured => "store restore is unconfigured".to_owned(),
+ CommandDisposition::ExternalUnavailable => {
+ "store restore is unavailable".to_owned()
+ }
+ CommandDisposition::Unsupported => "store restore is unsupported".to_owned(),
+ CommandDisposition::InternalError => "store restore failed".to_owned(),
+ }),
+ )),
+ }
+}
+
fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig {
let mut config = config.clone();
config.account.selector = Some(selector);
diff --git a/src/ops/target.rs b/src/ops/target.rs
@@ -188,6 +188,7 @@ target_operation_contracts! {
StoreStatusGet => (StoreStatusGetRequest, StoreStatusGetResult, "store.status.get"),
StoreExport => (StoreExportRequest, StoreExportResult, "store.export"),
StoreBackupCreate => (StoreBackupCreateRequest, StoreBackupCreateResult, "store.backup.create"),
+ StoreBackupRestore => (StoreBackupRestoreRequest, StoreBackupRestoreResult, "store.backup.restore"),
SyncStatusGet => (SyncStatusGetRequest, SyncStatusGetResult, "sync.status.get"),
SyncPull => (SyncPullRequest, SyncPullResult, "sync.pull"),
SyncPush => (SyncPushRequest, SyncPushResult, "sync.push"),
diff --git a/src/registry/mod.rs b/src/registry/mod.rs
@@ -117,6 +117,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
store::STORE_STATUS_GET,
store::STORE_EXPORT,
store::STORE_BACKUP_CREATE,
+ store::STORE_BACKUP_RESTORE,
sync::SYNC_STATUS_GET,
sync::SYNC_PULL,
sync::SYNC_PUSH,
@@ -295,6 +296,7 @@ mod tests {
"store.status.get",
"store.export",
"store.backup.create",
+ "store.backup.restore",
"sync.status.get",
"sync.pull",
"sync.push",
@@ -365,6 +367,7 @@ mod tests {
"account.selection.clear",
"store.init",
"store.backup.create",
+ "store.backup.restore",
"sync.pull",
"sync.push",
"farm.create",
@@ -410,7 +413,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 78);
+ assert_eq!(OPERATION_REGISTRY.len(), 79);
}
#[test]
diff --git a/src/registry/store.rs b/src/registry/store.rs
@@ -63,3 +63,19 @@ pub const STORE_BACKUP_CREATE: OperationSpec = operation!(
false,
true
);
+
+pub const STORE_BACKUP_RESTORE: OperationSpec = operation!(
+ "store.backup.restore",
+ "radroots store backup restore sdk-store-backup",
+ "store",
+ "store_backup_restore",
+ "StoreBackupRestoreRequest",
+ "StoreBackupRestoreResult",
+ "Restore SDK canonical store backup.",
+ Any,
+ true,
+ Conditional,
+ High,
+ false,
+ true
+);
diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs
@@ -140,7 +140,7 @@ pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf {
config.local.root.join(SDK_STORAGE_DIR_NAME)
}
-fn sdk_runtime() -> Result<Runtime, RuntimeError> {
+pub(crate) fn sdk_runtime() -> Result<Runtime, RuntimeError> {
TokioRuntimeBuilder::new_multi_thread()
.enable_all()
.build()
@@ -517,8 +517,8 @@ mod tests {
),
&[
"session.sdk()",
- "storage_status(StorageStatusRequest::default())",
- "integrity(IntegrityRequest::default())",
+ "storage_status(StorageStatusRequest::new())",
+ "integrity(IntegrityRequest::new())",
],
);
assert_migrated_path(
@@ -532,10 +532,19 @@ mod tests {
);
assert_migrated_path(
"store backup preflight",
- source_segment(&store, "pub fn backup_preflight(", "pub fn export("),
+ source_segment(&store, "pub fn backup_preflight(", "pub fn restore("),
&[
- "storage_status(StorageStatusRequest::default())",
- "integrity(IntegrityRequest::default())",
+ "storage_status(StorageStatusRequest::new())",
+ "integrity(IntegrityRequest::new())",
+ ],
+ );
+ assert_migrated_path(
+ "store restore",
+ source_segment(&store, "pub fn restore(", "pub fn export("),
+ &[
+ "RestoreRequest::new",
+ "sdk_runtime()",
+ "RadrootsSdk::restore",
],
);
}
diff --git a/src/runtime/store.rs b/src/runtime/store.rs
@@ -5,9 +5,10 @@ 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,
+ BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk, RestoreReceipt,
+ RestoreRequest, SdkBackupState, SdkEventStoreStorageStatus, SdkOutboxStorageStatus,
+ SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt,
+ StorageStatusRequest,
};
use radroots_sql_core::SqliteExecutor;
use serde::Serialize;
@@ -16,12 +17,12 @@ 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::sdk::{CliSdkAdapterError, CliSdkSession, sdk_runtime, sdk_storage_root};
use crate::runtime::sync::ensure_sync_run_table;
use crate::view::runtime::{
LocalBackupView, LocalExportView, LocalInitView, LocalLegacyReplicaStatusView,
- LocalReplicaCountsView, LocalReplicaSyncView, LocalStatusView, SdkEventStoreStatusView,
- SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView,
+ LocalReplicaCountsView, LocalReplicaSyncView, LocalRestoreView, LocalStatusView,
+ SdkEventStoreStatusView, SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView,
};
const LEGACY_REPLICA_SOURCE: &str = "legacy local replica ยท derived/migration source";
@@ -88,12 +89,8 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterEr
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()))?;
+ let receipt = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?;
+ let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?;
Ok(sdk_status_view(
config,
sdk_root,
@@ -161,10 +158,7 @@ pub fn backup(
) -> 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,
- }))?;
+ let receipt = session.block_on(session.sdk().backup(BackupRequest::new(output)))?;
sdk_backup_view(receipt)
}
@@ -174,12 +168,8 @@ pub fn backup_preflight(
) -> 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 status = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?;
+ let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?;
let manifest = sdk_backup_manifest_preview(output, &status, &integrity);
Ok(LocalBackupView {
state: "dry_run".to_owned(),
@@ -200,6 +190,26 @@ pub fn backup_preflight(
})
}
+pub fn restore(
+ config: &RuntimeConfig,
+ source: &Path,
+ destination: Option<&Path>,
+ overwrite: bool,
+ dry_run: bool,
+) -> Result<LocalRestoreView, CliSdkAdapterError> {
+ let destination = destination
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| sdk_storage_root(config));
+ ensure_safe_sdk_restore_destination(config, &destination)?;
+ let request = RestoreRequest::new(source)
+ .with_destination(destination)
+ .with_overwrite(overwrite)
+ .with_dry_run(dry_run);
+ let runtime = sdk_runtime()?;
+ let receipt = runtime.block_on(RadrootsSdk::restore(request))?;
+ sdk_restore_view(receipt, overwrite, dry_run)
+}
+
pub fn export(
config: &RuntimeConfig,
format: LocalExportFormatArg,
@@ -480,6 +490,40 @@ fn ensure_safe_sdk_backup_destination(
Ok(())
}
+fn ensure_safe_sdk_restore_destination(
+ config: &RuntimeConfig,
+ destination: &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 = [
+ config.local.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| destination == *forbidden)
+ {
+ return Err(RuntimeError::Config(format!(
+ "restore destination {} would overwrite canonical runtime roots or store files",
+ destination.display()
+ )));
+ }
+ if config.local.replica_db_path.starts_with(destination)
+ || config.local.backups_dir.starts_with(destination)
+ || config.local.exports_dir.starts_with(destination)
+ {
+ return Err(RuntimeError::Config(format!(
+ "restore destination {} must not contain CLI runtime state directories",
+ destination.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);
@@ -506,6 +550,59 @@ fn sdk_backup_view(receipt: BackupReceipt) -> Result<LocalBackupView, CliSdkAdap
})
}
+fn sdk_restore_view(
+ receipt: RestoreReceipt,
+ overwrite: bool,
+ dry_run: bool,
+) -> Result<LocalRestoreView, CliSdkAdapterError> {
+ let destination_paths = receipt.destination_paths.as_ref();
+ let restored_paths = receipt.restored_paths.as_ref();
+ Ok(LocalRestoreView {
+ state: sdk_restore_state_label(receipt.state).to_owned(),
+ source: SDK_CANONICAL_SOURCE.to_owned(),
+ restore_kind: SDK_BACKUP_KIND.to_owned(),
+ canonical_store: SDK_CANONICAL_STORE.to_owned(),
+ backup_source: display_path(&receipt.source),
+ destination: receipt
+ .destination
+ .as_ref()
+ .map(display_path)
+ .unwrap_or_default(),
+ event_store_file: display_path(&receipt.event_store_path),
+ outbox_file: display_path(&receipt.outbox_path),
+ manifest_file: display_path(&receipt.manifest_path),
+ destination_event_store_file: destination_paths
+ .map(|paths| display_path(&paths.event_store_path)),
+ destination_outbox_file: destination_paths.map(|paths| display_path(&paths.outbox_path)),
+ restored_event_store_file: restored_paths
+ .map(|paths| display_path(&paths.event_store_path)),
+ restored_outbox_file: restored_paths.map(|paths| display_path(&paths.outbox_path)),
+ manifest: json_value(&receipt.manifest)?,
+ verification: json_value(&receipt.verification)?,
+ overwrite,
+ dry_run,
+ reason: if dry_run {
+ Some("dry run requested; SDK canonical store was not restored".to_owned())
+ } else {
+ None
+ },
+ actions: if dry_run {
+ vec!["radroots store backup restore <backup-dir>".to_owned()]
+ } else {
+ Vec::new()
+ },
+ })
+}
+
+fn sdk_restore_state_label(state: SdkRestoreState) -> &'static str {
+ match state {
+ SdkRestoreState::Validated => "validated",
+ SdkRestoreState::DryRun => "dry_run",
+ SdkRestoreState::Completed => "completed",
+ _ => "unknown",
+ }
+}
+
fn sdk_backup_manifest_preview(
output: &Path,
status: &StorageStatusReceipt,
diff --git a/src/view/runtime.rs b/src/view/runtime.rs
@@ -3472,6 +3472,44 @@ impl LocalBackupView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct LocalRestoreView {
+ pub state: String,
+ pub source: String,
+ pub restore_kind: String,
+ pub canonical_store: String,
+ pub backup_source: String,
+ pub destination: String,
+ pub event_store_file: String,
+ pub outbox_file: String,
+ pub manifest_file: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub destination_event_store_file: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub destination_outbox_file: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub restored_event_store_file: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub restored_outbox_file: Option<String>,
+ pub manifest: serde_json::Value,
+ pub verification: serde_json::Value,
+ pub overwrite: bool,
+ pub dry_run: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl LocalRestoreView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct LocalExportView {
pub state: String,
pub source: String,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -3715,7 +3715,7 @@ fn store_export_dry_run_is_structured_unsupported() {
}
#[test]
-fn store_backup_dry_run_preflights_initialized_store_without_writing_file() {
+fn store_backup_and_restore_use_sdk_canonical_store() {
let sandbox = RadrootsCliSandbox::new();
let sdk_root = sandbox.root().join("data/apps/cli/replica/sdk");
@@ -3817,6 +3817,10 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() {
);
assert_eq!(backup["result"]["backup_kind"], "sdk_canonical");
assert_eq!(backup["result"]["canonical_store"], "sdk");
+ assert_eq!(
+ backup["result"]["manifest"]["manifest_kind"],
+ "storage_backup"
+ );
assert!(
backup["result"]["size_bytes"]
.as_u64()
@@ -3842,6 +3846,144 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() {
.contains("data/apps/cli/replica/sdk/event_store.sqlite")
);
assert!(!event_store_file.ends_with("replica.sqlite"));
+
+ let restore_destination = sandbox.root().join("restored-sdk-store");
+ let restore_destination_arg = restore_destination.to_string_lossy().to_string();
+ let restore_dry_run = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "store",
+ "backup",
+ "restore",
+ backup_destination,
+ "--destination",
+ restore_destination_arg.as_str(),
+ ]);
+
+ assert_eq!(restore_dry_run["operation_id"], "store.backup.restore");
+ assert_eq!(restore_dry_run["dry_run"], true);
+ assert_eq!(restore_dry_run["result"]["state"], "dry_run");
+ assert_eq!(
+ restore_dry_run["result"]["source"],
+ "SDK canonical event store and outbox"
+ );
+ assert_eq!(restore_dry_run["result"]["restore_kind"], "sdk_canonical");
+ assert_eq!(restore_dry_run["result"]["canonical_store"], "sdk");
+ assert_eq!(
+ restore_dry_run["result"]["backup_source"],
+ backup_destination
+ );
+ assert_eq!(
+ restore_dry_run["result"]["destination"],
+ restore_destination_arg
+ );
+ assert_eq!(
+ restore_dry_run["result"]["manifest"]["manifest_kind"],
+ "storage_backup"
+ );
+ assert_eq!(
+ restore_dry_run["result"]["verification"]["event_store_ok"],
+ true
+ );
+ assert_eq!(restore_dry_run["result"]["verification"]["outbox_ok"], true);
+ assert!(!restore_destination.exists());
+
+ let restored = sandbox.json_success(&[
+ "--format",
+ "json",
+ "store",
+ "backup",
+ "restore",
+ backup_destination,
+ "--destination",
+ restore_destination_arg.as_str(),
+ ]);
+
+ assert_eq!(restored["operation_id"], "store.backup.restore");
+ assert_eq!(restored["result"]["state"], "completed");
+ assert_eq!(
+ restored["result"]["source"],
+ "SDK canonical event store and outbox"
+ );
+ assert_eq!(restored["result"]["restore_kind"], "sdk_canonical");
+ assert_eq!(restored["result"]["canonical_store"], "sdk");
+ assert!(restore_destination.join("event_store.sqlite").exists());
+ assert!(restore_destination.join("outbox.sqlite").exists());
+ assert_eq!(
+ restored["result"]["restored_event_store_file"],
+ restore_destination
+ .join("event_store.sqlite")
+ .to_string_lossy()
+ .to_string()
+ );
+ assert_eq!(
+ restored["result"]["restored_outbox_file"],
+ restore_destination
+ .join("outbox.sqlite")
+ .to_string_lossy()
+ .to_string()
+ );
+
+ let unapproved_overwrite = sandbox.json_output(&[
+ "--format",
+ "json",
+ "store",
+ "backup",
+ "restore",
+ backup_destination,
+ "--destination",
+ restore_destination_arg.as_str(),
+ "--overwrite",
+ ]);
+ assert!(!unapproved_overwrite.0.status.success());
+ assert_eq!(unapproved_overwrite.0.status.code(), Some(6));
+ assert_eq!(
+ unapproved_overwrite.1["operation_id"],
+ "store.backup.restore"
+ );
+ assert_eq!(
+ unapproved_overwrite.1["errors"][0]["code"],
+ "approval_required"
+ );
+
+ let approved_overwrite = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "restore-ok",
+ "store",
+ "backup",
+ "restore",
+ backup_destination,
+ "--destination",
+ restore_destination_arg.as_str(),
+ "--overwrite",
+ ]);
+ assert_eq!(approved_overwrite["operation_id"], "store.backup.restore");
+ assert_eq!(approved_overwrite["result"]["state"], "completed");
+ assert_eq!(approved_overwrite["result"]["overwrite"], true);
+
+ let missing_backup = sandbox.root().join("missing-sdk-backup");
+ let missing_backup_arg = missing_backup.to_string_lossy().to_string();
+ let (missing_output, missing_value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "store",
+ "backup",
+ "restore",
+ missing_backup_arg.as_str(),
+ "--destination",
+ sandbox
+ .root()
+ .join("missing-restore-destination")
+ .to_string_lossy()
+ .as_ref(),
+ ]);
+ assert!(!missing_output.status.success());
+ assert_eq!(missing_value["operation_id"], "store.backup.restore");
+ assert_eq!(missing_value["errors"][0]["code"], "io");
+ assert_eq!(missing_value["errors"][0]["detail"]["class"], "storage");
}
#[test]