commit 879051fb4de9f4964febb1eb9e763eededc48a47
parent 79175bddaa9330a37dda261d5ffddd68016c838b
Author: triesap <tyson@radroots.org>
Date: Thu, 18 Jun 2026 13:28:14 -0700
app: add sdk restore lifecycle guards
- expose SDK restore dry-run preflight through app runtime
- track projection stale and rebuilding lifecycle state
- reject SDK commands while projection rebuild is active
- verify core sqlite and app package checks
Diffstat:
2 files changed, 484 insertions(+), 28 deletions(-)
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
@@ -38,10 +38,12 @@ pub use runtime::{
pub use sdk::{
APP_SDK_DEFAULT_COMMAND_QUEUE_CAPACITY, APP_SDK_STORAGE_DIR_NAME, AppSdkConfig,
AppSdkDiagnostics, AppSdkEventStoreDiagnostics, AppSdkIntegrityDiagnostics,
- AppSdkLifecycleState, AppSdkOutboxDiagnostics, AppSdkRelayUrlPolicy, AppSdkRuntime,
- AppSdkRuntimeError, AppSdkRuntimeIssue, AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics,
- AppSdkStorageDiagnostics, AppSdkStoragePaths, AppSdkSyncDiagnostics,
- AppSdkSyncEventStoreDiagnostics, AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics,
+ AppSdkLifecycleState, AppSdkOutboxDiagnostics, AppSdkProjectionLifecycleState,
+ AppSdkProjectionLifecycleStatus, AppSdkRelayUrlPolicy, AppSdkRestorePreflightReceipt,
+ AppSdkRestorePreflightRequest, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue,
+ AppSdkRuntimeStatus, AppSdkSqliteStoreDiagnostics, AppSdkStorageDiagnostics,
+ AppSdkStoragePaths, AppSdkSyncDiagnostics, AppSdkSyncEventStoreDiagnostics,
+ AppSdkSyncOutboxDiagnostics, AppSdkSyncRelayTargetDiagnostics,
app_sdk_storage_root_from_data_root,
};
pub use startup::{AppStartupEvent, AppStartupEventMetadata, launch_startup_event};
diff --git a/crates/runtime/src/sdk.rs b/crates/runtime/src/sdk.rs
@@ -11,6 +11,7 @@ use std::{
use radroots_sdk::{
IntegrityReceipt, IntegrityRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkStoragePaths,
+ RestoreReceipt, RestoreRequest, SdkBackupVerification,
SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy, StorageStatusReceipt, StorageStatusRequest,
SyncStatusReceipt, SyncStatusRequest,
};
@@ -75,6 +76,7 @@ pub struct AppSdkRuntimeStatus {
pub relay_url_policy: AppSdkRelayUrlPolicy,
pub storage_paths: Option<AppSdkStoragePaths>,
pub last_issue: Option<AppSdkRuntimeIssue>,
+ pub projection_lifecycle: AppSdkProjectionLifecycleStatus,
}
#[derive(Clone, Debug, PartialEq)]
@@ -173,6 +175,49 @@ pub struct AppSdkSyncRelayTargetDiagnostics {
pub configured_relays: Vec<String>,
}
+#[derive(Clone, Debug, PartialEq)]
+pub struct AppSdkRestorePreflightRequest {
+ pub source: PathBuf,
+ pub overwrite_existing_sdk_storage: bool,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct AppSdkRestorePreflightReceipt {
+ pub source: PathBuf,
+ pub destination: PathBuf,
+ pub state: String,
+ pub destination_paths: Option<AppSdkStoragePaths>,
+ pub restored_paths: Option<AppSdkStoragePaths>,
+ pub event_store_path: PathBuf,
+ pub outbox_path: PathBuf,
+ pub manifest_path: PathBuf,
+ pub verification: AppSdkBackupVerificationDiagnostics,
+ pub source_storage: AppSdkStorageDiagnostics,
+ pub projection_lifecycle: AppSdkProjectionLifecycleStatus,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AppSdkBackupVerificationDiagnostics {
+ pub event_store_ok: bool,
+ pub outbox_ok: bool,
+ pub event_store_events: i64,
+ pub outbox_events: i64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AppSdkProjectionLifecycleStatus {
+ pub state: AppSdkProjectionLifecycleState,
+ pub reason: Option<String>,
+ pub restore_source: Option<PathBuf>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AppSdkProjectionLifecycleState {
+ Current,
+ Stale,
+ Rebuilding,
+}
+
#[derive(Debug, Error)]
pub enum AppSdkRuntimeError {
#[error("app sdk command queue capacity must be greater than zero")]
@@ -212,6 +257,16 @@ enum AppSdkWorkerCommand {
IntegrityStatus(mpsc::Sender<Result<AppSdkIntegrityDiagnostics, AppSdkRuntimeIssue>>),
SyncStatus(mpsc::Sender<Result<AppSdkSyncDiagnostics, AppSdkRuntimeIssue>>),
Diagnostics(mpsc::Sender<Result<AppSdkDiagnostics, AppSdkRuntimeIssue>>),
+ RestorePreflight(
+ AppSdkRestorePreflightRequest,
+ mpsc::Sender<Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeIssue>>,
+ ),
+ BeginProjectionRebuild(
+ mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
+ ),
+ CompleteProjectionRebuild(
+ mpsc::Sender<Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue>>,
+ ),
}
impl fmt::Debug for AppSdkWorkerCommand {
@@ -222,6 +277,9 @@ impl fmt::Debug for AppSdkWorkerCommand {
Self::IntegrityStatus(_) => formatter.write_str("IntegrityStatus"),
Self::SyncStatus(_) => formatter.write_str("SyncStatus"),
Self::Diagnostics(_) => formatter.write_str("Diagnostics"),
+ Self::RestorePreflight(_, _) => formatter.write_str("RestorePreflight"),
+ Self::BeginProjectionRebuild(_) => formatter.write_str("BeginProjectionRebuild"),
+ Self::CompleteProjectionRebuild(_) => formatter.write_str("CompleteProjectionRebuild"),
}
}
}
@@ -246,6 +304,46 @@ impl AppSdkConfig {
}
}
+impl AppSdkRestorePreflightRequest {
+ pub fn new(source: impl Into<PathBuf>) -> Self {
+ Self {
+ source: source.into(),
+ overwrite_existing_sdk_storage: false,
+ }
+ }
+
+ pub fn with_overwrite_existing_sdk_storage(mut self, overwrite: bool) -> Self {
+ self.overwrite_existing_sdk_storage = overwrite;
+ self
+ }
+}
+
+impl AppSdkProjectionLifecycleStatus {
+ pub fn current() -> Self {
+ Self {
+ state: AppSdkProjectionLifecycleState::Current,
+ reason: None,
+ restore_source: None,
+ }
+ }
+
+ fn stale(reason: impl Into<String>, restore_source: Option<PathBuf>) -> Self {
+ Self {
+ state: AppSdkProjectionLifecycleState::Stale,
+ reason: Some(reason.into()),
+ restore_source,
+ }
+ }
+
+ fn rebuilding(reason: impl Into<String>, restore_source: Option<PathBuf>) -> Self {
+ Self {
+ state: AppSdkProjectionLifecycleState::Rebuilding,
+ reason: Some(reason.into()),
+ restore_source,
+ }
+ }
+}
+
impl AppSdkRuntime {
pub fn start(config: AppSdkConfig) -> Result<Self, AppSdkRuntimeError> {
if config.command_queue_capacity == 0 {
@@ -291,6 +389,27 @@ impl AppSdkRuntime {
self.run_command(AppSdkWorkerCommand::Diagnostics)
}
+ pub fn restore_preflight(
+ &self,
+ request: AppSdkRestorePreflightRequest,
+ ) -> Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeError> {
+ self.run_command(|response_sender| {
+ AppSdkWorkerCommand::RestorePreflight(request, response_sender)
+ })
+ }
+
+ pub fn begin_projection_rebuild(
+ &self,
+ ) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
+ self.run_command(AppSdkWorkerCommand::BeginProjectionRebuild)
+ }
+
+ pub fn complete_projection_rebuild(
+ &self,
+ ) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeError> {
+ self.run_command(AppSdkWorkerCommand::CompleteProjectionRebuild)
+ }
+
pub fn wait_for_startup(&self, timeout: Duration) -> AppSdkRuntimeStatus {
let deadline = Instant::now()
.checked_add(timeout)
@@ -428,6 +547,23 @@ impl AppSdkRuntimeIssue {
}),
}
}
+
+ fn lifecycle_blocked(state: AppSdkLifecycleState) -> Self {
+ Self {
+ code: "sdk_lifecycle_busy".to_owned(),
+ class: "runtime".to_owned(),
+ retryable: true,
+ message: format!("app sdk runtime is {:?}", state),
+ recovery_actions: vec!["wait_for_sdk_lifecycle".to_owned()],
+ detail_json: json!({
+ "code": "sdk_lifecycle_busy",
+ "class": "runtime",
+ "retryable": true,
+ "state": format!("{state:?}"),
+ "recovery_actions": ["wait_for_sdk_lifecycle"]
+ }),
+ }
+ }
}
impl fmt::Display for AppSdkRuntimeIssue {
@@ -450,6 +586,7 @@ impl AppSdkRuntimeStatus {
relay_url_policy: config.relay_url_policy,
storage_paths,
last_issue,
+ projection_lifecycle: AppSdkProjectionLifecycleStatus::current(),
}
}
}
@@ -539,6 +676,45 @@ impl From<SyncStatusReceipt> for AppSdkSyncDiagnostics {
}
}
+impl From<SdkBackupVerification> for AppSdkBackupVerificationDiagnostics {
+ fn from(verification: SdkBackupVerification) -> Self {
+ Self {
+ event_store_ok: verification.event_store_ok,
+ outbox_ok: verification.outbox_ok,
+ event_store_events: verification.event_store_events,
+ outbox_events: verification.outbox_events,
+ }
+ }
+}
+
+impl AppSdkRestorePreflightReceipt {
+ fn from_restore_receipt(
+ receipt: RestoreReceipt,
+ destination: PathBuf,
+ projection_lifecycle: AppSdkProjectionLifecycleStatus,
+ ) -> Self {
+ Self {
+ source: receipt.source,
+ destination: receipt.destination.unwrap_or(destination),
+ state: serialized_label(&receipt.state),
+ destination_paths: receipt
+ .destination_paths
+ .as_ref()
+ .map(AppSdkStoragePaths::from),
+ restored_paths: receipt
+ .restored_paths
+ .as_ref()
+ .map(AppSdkStoragePaths::from),
+ event_store_path: receipt.event_store_path,
+ outbox_path: receipt.outbox_path,
+ manifest_path: receipt.manifest_path,
+ verification: receipt.verification.into(),
+ source_storage: receipt.manifest.source_status.into(),
+ projection_lifecycle,
+ }
+ }
+}
+
pub fn app_sdk_storage_root_from_data_root(data_root: &Path) -> PathBuf {
data_root.join(APP_SDK_STORAGE_DIR_NAME)
}
@@ -619,44 +795,81 @@ fn run_app_sdk_worker(
return;
}
AppSdkWorkerCommand::StorageStatus(response_sender) => {
- let result = match sdk.as_ref() {
- Some(sdk) => runtime
- .block_on(sdk.storage_status(StorageStatusRequest::new()))
- .map(AppSdkStorageDiagnostics::from)
- .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
- None => Err(runtime_unavailable_issue(&shared)),
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => runtime
+ .block_on(sdk.storage_status(StorageStatusRequest::new()))
+ .map(AppSdkStorageDiagnostics::from)
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
};
send_worker_result(&shared, response_sender, result);
}
AppSdkWorkerCommand::IntegrityStatus(response_sender) => {
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => runtime
+ .block_on(sdk.integrity(IntegrityRequest::new()))
+ .map(AppSdkIntegrityDiagnostics::from)
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
+ };
+ send_worker_result(&shared, response_sender, result);
+ }
+ AppSdkWorkerCommand::SyncStatus(response_sender) => {
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => runtime
+ .block_on(sdk.sync().status(SyncStatusRequest::new()))
+ .map(AppSdkSyncDiagnostics::from)
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
+ };
+ send_worker_result(&shared, response_sender, result);
+ }
+ AppSdkWorkerCommand::Diagnostics(response_sender) => {
+ let result = if let Some(issue) = lifecycle_busy_issue(&shared) {
+ Err(issue)
+ } else {
+ match sdk.as_ref() {
+ Some(sdk) => {
+ let mut runtime_status = lock_status(&shared).clone();
+ runtime_status.last_issue = None;
+ runtime
+ .block_on(collect_sdk_diagnostics(sdk, runtime_status))
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
+ }
+ None => Err(runtime_unavailable_issue(&shared)),
+ }
+ };
+ send_worker_result(&shared, response_sender, result);
+ }
+ AppSdkWorkerCommand::RestorePreflight(request, response_sender) => {
let result = match sdk.as_ref() {
- Some(sdk) => runtime
- .block_on(sdk.integrity(IntegrityRequest::new()))
- .map(AppSdkIntegrityDiagnostics::from)
- .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
+ Some(_) => run_restore_preflight(&runtime, &shared, &config, request),
None => Err(runtime_unavailable_issue(&shared)),
};
send_worker_result(&shared, response_sender, result);
}
- AppSdkWorkerCommand::SyncStatus(response_sender) => {
+ AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
let result = match sdk.as_ref() {
- Some(sdk) => runtime
- .block_on(sdk.sync().status(SyncStatusRequest::new()))
- .map(AppSdkSyncDiagnostics::from)
- .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error)),
+ Some(_) => Ok(begin_projection_rebuild(&shared)),
None => Err(runtime_unavailable_issue(&shared)),
};
send_worker_result(&shared, response_sender, result);
}
- AppSdkWorkerCommand::Diagnostics(response_sender) => {
+ AppSdkWorkerCommand::CompleteProjectionRebuild(response_sender) => {
let result = match sdk.as_ref() {
- Some(sdk) => {
- let mut runtime_status = lock_status(&shared).clone();
- runtime_status.last_issue = None;
- runtime
- .block_on(collect_sdk_diagnostics(sdk, runtime_status))
- .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
- }
+ Some(_) => complete_projection_rebuild(&shared),
None => Err(runtime_unavailable_issue(&shared)),
};
send_worker_result(&shared, response_sender, result);
@@ -718,6 +931,27 @@ fn run_degraded_worker(
Err(runtime_unavailable_issue(&shared)),
);
}
+ AppSdkWorkerCommand::RestorePreflight(_, response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
+ AppSdkWorkerCommand::BeginProjectionRebuild(response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
+ AppSdkWorkerCommand::CompleteProjectionRebuild(response_sender) => {
+ send_worker_result(
+ &shared,
+ response_sender,
+ Err(runtime_unavailable_issue(&shared)),
+ );
+ }
}
}
@@ -738,6 +972,44 @@ async fn build_sdk_runtime(config: &AppSdkConfig) -> Result<RadrootsSdk, Radroot
builder.build().await
}
+fn run_restore_preflight(
+ runtime: &tokio::runtime::Runtime,
+ shared: &AppSdkRuntimeShared,
+ config: &AppSdkConfig,
+ request: AppSdkRestorePreflightRequest,
+) -> Result<AppSdkRestorePreflightReceipt, AppSdkRuntimeIssue> {
+ if let Some(issue) = lifecycle_busy_issue(shared) {
+ return Err(issue);
+ }
+ transition_status_state(shared, AppSdkLifecycleState::Pausing);
+ transition_status_state(shared, AppSdkLifecycleState::Paused);
+ transition_status_state(shared, AppSdkLifecycleState::Restoring);
+
+ let restore_request = RestoreRequest::new(request.source.clone())
+ .with_destination(config.storage_root.clone())
+ .with_overwrite(request.overwrite_existing_sdk_storage)
+ .dry_run();
+ let result = runtime
+ .block_on(RadrootsSdk::restore(restore_request))
+ .map_err(|error| AppSdkRuntimeIssue::from_sdk_error(&error))
+ .map(|receipt| {
+ let projection_lifecycle = mark_projections_stale(
+ shared,
+ "sdk_restore_preflight",
+ Some(request.source.clone()),
+ );
+ AppSdkRestorePreflightReceipt::from_restore_receipt(
+ receipt,
+ config.storage_root.clone(),
+ projection_lifecycle,
+ )
+ });
+ if result.is_err() {
+ transition_status_state(shared, AppSdkLifecycleState::Ready);
+ }
+ result
+}
+
async fn collect_sdk_diagnostics(
sdk: &RadrootsSdk,
runtime: AppSdkRuntimeStatus,
@@ -768,6 +1040,22 @@ fn send_worker_result<T>(
let _ = response_sender.send(result);
}
+fn lifecycle_busy_issue(shared: &AppSdkRuntimeShared) -> Option<AppSdkRuntimeIssue> {
+ let state = lock_status(shared).state;
+ if matches!(
+ state,
+ AppSdkLifecycleState::Pausing
+ | AppSdkLifecycleState::Paused
+ | AppSdkLifecycleState::Restoring
+ | AppSdkLifecycleState::RebuildingProjections
+ | AppSdkLifecycleState::ShuttingDown
+ ) {
+ Some(AppSdkRuntimeIssue::lifecycle_blocked(state))
+ } else {
+ None
+ }
+}
+
fn runtime_unavailable_issue(shared: &AppSdkRuntimeShared) -> AppSdkRuntimeIssue {
let status = lock_status(shared).clone();
if let Some(issue) = status.last_issue {
@@ -795,6 +1083,47 @@ fn transition_status_state(shared: &AppSdkRuntimeShared, state: AppSdkLifecycleS
shared.status_changed.notify_all();
}
+fn mark_projections_stale(
+ shared: &AppSdkRuntimeShared,
+ reason: impl Into<String>,
+ restore_source: Option<PathBuf>,
+) -> AppSdkProjectionLifecycleStatus {
+ let mut status = lock_status(shared);
+ status.projection_lifecycle = AppSdkProjectionLifecycleStatus::stale(reason, restore_source);
+ status.state = AppSdkLifecycleState::Ready;
+ let projection_lifecycle = status.projection_lifecycle.clone();
+ shared.status_changed.notify_all();
+ projection_lifecycle
+}
+
+fn begin_projection_rebuild(shared: &AppSdkRuntimeShared) -> AppSdkProjectionLifecycleStatus {
+ let restore_source = lock_status(shared)
+ .projection_lifecycle
+ .restore_source
+ .clone();
+ let mut status = lock_status(shared);
+ status.state = AppSdkLifecycleState::RebuildingProjections;
+ status.projection_lifecycle =
+ AppSdkProjectionLifecycleStatus::rebuilding("sdk_projection_rebuild", restore_source);
+ let projection_lifecycle = status.projection_lifecycle.clone();
+ shared.status_changed.notify_all();
+ projection_lifecycle
+}
+
+fn complete_projection_rebuild(
+ shared: &AppSdkRuntimeShared,
+) -> Result<AppSdkProjectionLifecycleStatus, AppSdkRuntimeIssue> {
+ let mut status = lock_status(shared);
+ if !matches!(status.state, AppSdkLifecycleState::RebuildingProjections) {
+ return Err(AppSdkRuntimeIssue::lifecycle_blocked(status.state));
+ }
+ status.state = AppSdkLifecycleState::Ready;
+ status.projection_lifecycle = AppSdkProjectionLifecycleStatus::current();
+ let projection_lifecycle = status.projection_lifecycle.clone();
+ shared.status_changed.notify_all();
+ Ok(projection_lifecycle)
+}
+
fn lock_status(shared: &AppSdkRuntimeShared) -> MutexGuard<'_, AppSdkRuntimeStatus> {
shared
.status
@@ -823,13 +1152,16 @@ mod tests {
time::{Duration, SystemTime, UNIX_EPOCH},
};
+ use radroots_sdk::{BackupRequest, RadrootsSdk, SdkRelayUrlPolicy as SdkRuntimeRelayUrlPolicy};
+
use crate::{
APP_RUNTIME_NAMESPACE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment,
AppRuntimePlatform,
};
use super::{
- APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState, AppSdkRelayUrlPolicy,
+ APP_SDK_STORAGE_DIR_NAME, AppSdkConfig, AppSdkLifecycleState,
+ AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, AppSdkRestorePreflightRequest,
AppSdkRuntime, AppSdkRuntimeError, app_sdk_storage_root_from_data_root,
};
@@ -963,6 +1295,128 @@ mod tests {
let _ = fs::remove_dir_all(storage_root);
}
+ #[test]
+ fn sdk_restore_preflight_marks_projections_stale_without_writing_destination() {
+ let backup_source_root = temp_storage_root("restore_backup_source");
+ let backup_archive = backup_source_root
+ .parent()
+ .expect("backup source should have parent")
+ .join("backup_archive");
+ let tokio = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("tokio runtime");
+ let sdk = tokio
+ .block_on(
+ RadrootsSdk::builder()
+ .directory_storage(backup_source_root.clone())
+ .relay_url_policy(SdkRuntimeRelayUrlPolicy::Localhost)
+ .relay_url("ws://127.0.0.1:8080")
+ .build(),
+ )
+ .expect("source sdk should build");
+ tokio
+ .block_on(sdk.backup(BackupRequest::new(backup_archive.clone())))
+ .expect("backup should complete");
+
+ let app_storage_root = temp_storage_root("restore_preflight_destination");
+ let app_data_root = app_storage_root
+ .parent()
+ .expect("app storage root should have parent")
+ .to_path_buf();
+ let config = AppSdkConfig::from_app_data_root(
+ app_data_root.as_path(),
+ vec!["ws://127.0.0.1:8080".to_owned()],
+ );
+ let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
+ assert_eq!(
+ runtime.wait_for_startup(Duration::from_secs(5)).state,
+ AppSdkLifecycleState::Ready
+ );
+ let sentinel = app_storage_root.join("restore-preflight-sentinel");
+ fs::write(&sentinel, "existing destination").expect("sentinel should write");
+
+ let receipt = runtime
+ .restore_preflight(
+ AppSdkRestorePreflightRequest::new(backup_archive.clone())
+ .with_overwrite_existing_sdk_storage(true),
+ )
+ .expect("restore preflight should succeed");
+
+ assert_eq!(receipt.state, "dry_run");
+ assert_eq!(receipt.destination, app_storage_root);
+ assert_eq!(receipt.restored_paths, None);
+ assert!(sentinel.exists());
+ assert_eq!(
+ receipt.projection_lifecycle.state,
+ AppSdkProjectionLifecycleState::Stale
+ );
+ assert_eq!(
+ receipt.projection_lifecycle.reason.as_deref(),
+ Some("sdk_restore_preflight")
+ );
+ assert_eq!(
+ runtime.status().projection_lifecycle.state,
+ AppSdkProjectionLifecycleState::Stale
+ );
+ assert_eq!(runtime.status().state, AppSdkLifecycleState::Ready);
+ runtime.shutdown().expect("sdk runtime should shut down");
+ let _ = fs::remove_dir_all(
+ backup_source_root
+ .parent()
+ .expect("backup source should have parent"),
+ );
+ let _ = fs::remove_dir_all(app_data_root);
+ }
+
+ #[test]
+ fn sdk_projection_rebuild_state_rejects_conflicting_commands() {
+ let storage_root = temp_storage_root("projection_rebuild");
+ let config = AppSdkConfig::from_app_data_root(
+ storage_root
+ .parent()
+ .expect("storage root should have parent"),
+ vec!["ws://127.0.0.1:8080".to_owned()],
+ );
+ let runtime = AppSdkRuntime::start(config).expect("sdk runtime should start");
+ assert_eq!(
+ runtime.wait_for_startup(Duration::from_secs(5)).state,
+ AppSdkLifecycleState::Ready
+ );
+
+ let rebuilding = runtime
+ .begin_projection_rebuild()
+ .expect("projection rebuild should start");
+
+ assert_eq!(rebuilding.state, AppSdkProjectionLifecycleState::Rebuilding);
+ assert_eq!(
+ runtime.status().state,
+ AppSdkLifecycleState::RebuildingProjections
+ );
+ let error = runtime
+ .sync_status()
+ .expect_err("sync status should wait for rebuild completion");
+ match error {
+ AppSdkRuntimeError::CommandFailed(issue) => {
+ assert_eq!(issue.code, "sdk_lifecycle_busy");
+ assert_eq!(issue.detail_json["state"], "RebuildingProjections");
+ }
+ unexpected => panic!("unexpected lifecycle error: {unexpected:?}"),
+ }
+
+ let complete = runtime
+ .complete_projection_rebuild()
+ .expect("projection rebuild should complete");
+
+ assert_eq!(complete.state, AppSdkProjectionLifecycleState::Current);
+ assert_eq!(runtime.status().state, AppSdkLifecycleState::Ready);
+ runtime
+ .sync_status()
+ .expect("sync status should work after rebuild");
+ runtime.shutdown().expect("sdk runtime should shut down");
+ let _ = fs::remove_dir_all(storage_root);
+ }
+
fn temp_storage_root(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)