commit 3e57925ee07160b9f88e9ad255d9575c45f7e86d
parent 47fc2e0501103b8eca9f0090550349a62944b548
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 14:17:23 -0700
storage: add SDK status backup and integrity APIs
- expose SDK canonical storage status and integrity receipts for event-store and outbox
- implement safe SQLite VACUUM INTO backup with manifest and backup verification
- include outbox failed-terminal counts and runtime sqlx dependency for storage internals
- validate with SDK fmt, check, test, xtask, WASM, pnpm build, and typecheck lanes
Diffstat:
6 files changed, 594 insertions(+), 14 deletions(-)
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -49,6 +49,7 @@ runtime = [
"dep:radroots_outbox",
"dep:radroots_relay_transport",
"dep:sha2",
+ "dep:sqlx",
"dep:uuid",
"radroots_authority/std",
"radroots_event_store/sqlite",
@@ -103,6 +104,10 @@ serde_json = { workspace = true, optional = true, default-features = false, feat
"alloc",
] }
sha2 = { workspace = true, optional = true }
+sqlx = { workspace = true, optional = true, default-features = false, features = [
+ "runtime-tokio",
+ "sqlite",
+] }
uuid = { workspace = true, optional = true }
[[example]]
@@ -131,10 +136,6 @@ radroots_nostr = { workspace = true, default-features = false, features = [
"std",
"events",
] }
-sqlx = { workspace = true, default-features = false, features = [
- "runtime-tokio",
- "sqlite",
-] }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tokio-tungstenite = "0.26.2"
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -65,8 +65,9 @@ pub use crate::relay_targets::{
pub use crate::runtime::{
BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk,
RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths,
- RadrootsSdkTimestamp, SdkBackupState, SdkStorageKind, StorageStatusReceipt,
- StorageStatusRequest,
+ RadrootsSdkTimestamp, SdkBackupManifest, SdkBackupState, SdkBackupVerification,
+ SdkEventStoreStorageStatus, SdkOutboxStorageStatus, SdkSqliteStoreStatus, SdkStorageKind,
+ StorageStatusReceipt, StorageStatusRequest,
};
#[cfg(feature = "runtime")]
pub use crate::sync_runtime::{
diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs
@@ -8,13 +8,29 @@ use radroots_event_store::RadrootsEventStore;
#[cfg(feature = "runtime")]
use radroots_outbox::RadrootsOutbox;
#[cfg(feature = "runtime")]
+use sqlx::{Row, SqlitePool};
+#[cfg(feature = "runtime")]
use std::{
fs,
+ io::ErrorKind,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(feature = "runtime")]
+const SDK_STORAGE_MANIFEST_VERSION: u16 = 1;
+#[cfg(feature = "runtime")]
+const SDK_EVENT_STORE_SCHEMA_VERSION: i64 = 1;
+#[cfg(feature = "runtime")]
+const SDK_OUTBOX_SCHEMA_VERSION: i64 = 1;
+#[cfg(feature = "runtime")]
+const EVENT_STORE_BACKUP_FILE: &str = "event_store.sqlite";
+#[cfg(feature = "runtime")]
+const OUTBOX_BACKUP_FILE: &str = "outbox.sqlite";
+#[cfg(feature = "runtime")]
+const BACKUP_MANIFEST_FILE: &str = "manifest.json";
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub enum RadrootsSdkStorageConfig {
@@ -95,6 +111,8 @@ pub struct StorageStatusRequest {}
pub struct StorageStatusReceipt {
pub storage: SdkStorageKind,
pub paths: Option<RadrootsSdkStoragePaths>,
+ pub event_store: SdkEventStoreStorageStatus,
+ pub outbox: SdkOutboxStorageStatus,
}
#[cfg(feature = "runtime")]
@@ -108,6 +126,43 @@ pub enum SdkStorageKind {
#[cfg(feature = "runtime")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SdkSqliteStoreStatus {
+ 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,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SdkEventStoreStorageStatus {
+ pub store: SdkSqliteStoreStatus,
+ pub total_events: i64,
+ pub projection_eligible_events: i64,
+ pub relay_observations: i64,
+ pub last_event_seq: Option<i64>,
+ pub last_event_updated_at_ms: Option<i64>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SdkOutboxStorageStatus {
+ pub store: SdkSqliteStoreStatus,
+ 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,
+ pub last_attempt_at_ms: Option<i64>,
+ pub last_error: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct BackupRequest {
pub destination: PathBuf,
pub overwrite: bool,
@@ -120,6 +175,8 @@ pub struct BackupReceipt {
pub state: SdkBackupState,
pub event_store_path: Option<PathBuf>,
pub outbox_path: Option<PathBuf>,
+ pub manifest_path: Option<PathBuf>,
+ pub manifest: SdkBackupManifest,
}
#[cfg(feature = "runtime")]
@@ -132,6 +189,28 @@ pub enum SdkBackupState {
}
#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SdkBackupManifest {
+ pub manifest_version: u16,
+ pub sdk_version: &'static str,
+ pub created_at_ms: i64,
+ pub source_storage: SdkStorageKind,
+ pub source_paths: Option<RadrootsSdkStoragePaths>,
+ pub backup_paths: RadrootsSdkStoragePaths,
+ pub source_status: StorageStatusReceipt,
+ pub backup_verification: SdkBackupVerification,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+pub struct SdkBackupVerification {
+ pub event_store_ok: bool,
+ pub outbox_ok: bool,
+ pub event_store_events: i64,
+ pub outbox_events: i64,
+}
+
+#[cfg(feature = "runtime")]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct IntegrityRequest {}
@@ -141,6 +220,8 @@ pub struct IntegrityReceipt {
pub checked_paths: Vec<PathBuf>,
pub event_store_ok: bool,
pub outbox_ok: bool,
+ pub event_store_result: String,
+ pub outbox_result: String,
}
#[cfg(feature = "runtime")]
@@ -249,6 +330,131 @@ impl RadrootsSdk {
pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> {
self.storage_paths.as_ref()
}
+
+ pub async fn storage_status(
+ &self,
+ _request: StorageStatusRequest,
+ ) -> Result<StorageStatusReceipt, RadrootsSdkError> {
+ let now_ms = sdk_now_ms(self)?;
+ let event_summary = self._event_store.status_summary().await?;
+ let outbox_summary = self._outbox.status_summary(now_ms).await?;
+ Ok(StorageStatusReceipt {
+ storage: self.storage_kind(),
+ paths: self.storage_paths.clone(),
+ event_store: SdkEventStoreStorageStatus {
+ store: sqlite_store_status(
+ self._event_store.pool(),
+ SDK_EVENT_STORE_SCHEMA_VERSION,
+ self._event_store.pragma_journal_mode().await?,
+ self._event_store.pragma_foreign_keys().await? != 0,
+ self._event_store.pragma_busy_timeout().await?,
+ )
+ .await?,
+ total_events: event_summary.total_events,
+ projection_eligible_events: event_summary.projection_eligible_events,
+ relay_observations: event_summary.relay_observations,
+ last_event_seq: event_summary.last_event_seq,
+ last_event_updated_at_ms: event_summary.last_event_updated_at_ms,
+ },
+ outbox: SdkOutboxStorageStatus {
+ store: sqlite_store_status(
+ self._outbox.pool(),
+ SDK_OUTBOX_SCHEMA_VERSION,
+ self._outbox.pragma_journal_mode().await?,
+ self._outbox.pragma_foreign_keys().await? != 0,
+ self._outbox.pragma_busy_timeout().await?,
+ )
+ .await?,
+ total_events: outbox_summary.total_events,
+ pending_events: outbox_summary.pending_events,
+ retryable_events: outbox_summary.retryable_events,
+ terminal_events: outbox_summary.terminal_events,
+ failed_terminal_events: outbox_summary.failed_terminal_events,
+ ready_signed_events: outbox_summary.ready_signed_events,
+ publishing_events: outbox_summary.publishing_events,
+ last_attempt_at_ms: outbox_summary.last_attempt_at_ms,
+ last_error: outbox_summary.last_error,
+ },
+ })
+ }
+
+ pub async fn integrity(
+ &self,
+ _request: IntegrityRequest,
+ ) -> Result<IntegrityReceipt, RadrootsSdkError> {
+ let event_store_integrity = sqlite_integrity_result(self._event_store.pool()).await?;
+ let outbox_integrity = sqlite_integrity_result(self._outbox.pool()).await?;
+ let checked_paths = self
+ .storage_paths
+ .as_ref()
+ .map(|paths| vec![paths.event_store_path.clone(), paths.outbox_path.clone()])
+ .unwrap_or_default();
+ Ok(IntegrityReceipt {
+ checked_paths,
+ event_store_ok: event_store_integrity.ok,
+ outbox_ok: outbox_integrity.ok,
+ event_store_result: event_store_integrity.result,
+ outbox_result: outbox_integrity.result,
+ })
+ }
+
+ pub async fn backup(&self, request: BackupRequest) -> Result<BackupReceipt, RadrootsSdkError> {
+ if request.destination.as_os_str().is_empty() {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "backup destination must not be empty".to_owned(),
+ });
+ }
+ prepare_backup_destination(&request.destination, request.overwrite)?;
+ let backup_paths = RadrootsSdkStoragePaths {
+ event_store_path: request.destination.join(EVENT_STORE_BACKUP_FILE),
+ outbox_path: request.destination.join(OUTBOX_BACKUP_FILE),
+ };
+ let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE);
+ let source_status = self.storage_status(StorageStatusRequest::default()).await?;
+ sqlite_vacuum_into(
+ self._event_store.pool(),
+ &backup_paths.event_store_path,
+ "event store",
+ )
+ .await?;
+ sqlite_vacuum_into(self._outbox.pool(), &backup_paths.outbox_path, "outbox").await?;
+ let backup_verification = verify_backup_paths(&backup_paths).await?;
+ let manifest = SdkBackupManifest {
+ manifest_version: SDK_STORAGE_MANIFEST_VERSION,
+ sdk_version: env!("CARGO_PKG_VERSION"),
+ created_at_ms: sdk_now_ms(self)?,
+ source_storage: self.storage_kind(),
+ source_paths: self.storage_paths.clone(),
+ backup_paths: backup_paths.clone(),
+ source_status,
+ backup_verification,
+ };
+ let manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|error| {
+ RadrootsSdkError::InvalidRequest {
+ message: error.to_string(),
+ }
+ })?;
+ fs::write(&manifest_path, manifest_json).map_err(|error| RadrootsSdkError::Io {
+ path: manifest_path.clone(),
+ message: error.to_string(),
+ })?;
+ Ok(BackupReceipt {
+ destination: request.destination,
+ state: SdkBackupState::Completed,
+ event_store_path: Some(backup_paths.event_store_path),
+ outbox_path: Some(backup_paths.outbox_path),
+ manifest_path: Some(manifest_path),
+ manifest,
+ })
+ }
+
+ fn storage_kind(&self) -> SdkStorageKind {
+ if self.storage_paths.is_some() {
+ SdkStorageKind::Directory
+ } else {
+ SdkStorageKind::Memory
+ }
+ }
}
#[cfg(feature = "runtime")]
@@ -297,3 +503,130 @@ async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, Rad
paths: Some(paths),
})
}
+
+#[cfg(feature = "runtime")]
+struct SqliteIntegrityResult {
+ ok: bool,
+ result: String,
+}
+
+#[cfg(feature = "runtime")]
+async fn sqlite_store_status(
+ pool: &SqlitePool,
+ schema_version: i64,
+ journal_mode: String,
+ foreign_keys_enabled: bool,
+ busy_timeout_ms: i64,
+) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> {
+ let integrity = sqlite_integrity_result(pool).await?;
+ Ok(SdkSqliteStoreStatus {
+ schema_version,
+ journal_mode,
+ foreign_keys_enabled,
+ busy_timeout_ms,
+ integrity_ok: integrity.ok,
+ integrity_result: integrity.result,
+ })
+}
+
+#[cfg(feature = "runtime")]
+async fn sqlite_integrity_result(
+ pool: &SqlitePool,
+) -> Result<SqliteIntegrityResult, RadrootsSdkError> {
+ let rows = sqlx::query("PRAGMA integrity_check")
+ .fetch_all(pool)
+ .await
+ .map_err(|error| RadrootsSdkError::EventStore {
+ message: error.to_string(),
+ })?;
+ let results = rows
+ .into_iter()
+ .map(|row| row.try_get::<String, _>(0))
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|error| RadrootsSdkError::EventStore {
+ message: error.to_string(),
+ })?;
+ let result = results.join("; ");
+ Ok(SqliteIntegrityResult {
+ ok: result == "ok",
+ result,
+ })
+}
+
+#[cfg(feature = "runtime")]
+fn prepare_backup_destination(path: &Path, overwrite: bool) -> Result<(), RadrootsSdkError> {
+ match fs::symlink_metadata(path) {
+ Ok(metadata) if metadata.file_type().is_symlink() => {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "backup destination must not be a symbolic link".to_owned(),
+ });
+ }
+ Ok(metadata) if overwrite && metadata.is_dir() => {
+ fs::remove_dir_all(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ }
+ Ok(metadata) if overwrite && metadata.is_file() => {
+ fs::remove_file(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })?;
+ }
+ Ok(_) => {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "backup destination already exists and overwrite is false".to_owned(),
+ });
+ }
+ Err(error) if error.kind() == ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ });
+ }
+ }
+ fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io {
+ path: path.to_path_buf(),
+ message: error.to_string(),
+ })
+}
+
+#[cfg(feature = "runtime")]
+async fn sqlite_vacuum_into(
+ pool: &SqlitePool,
+ destination: &Path,
+ store_name: &'static str,
+) -> Result<(), RadrootsSdkError> {
+ let Some(destination) = destination.to_str() else {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("{store_name} backup destination must be valid UTF-8"),
+ });
+ };
+ sqlx::query("VACUUM INTO ?")
+ .bind(destination)
+ .execute(pool)
+ .await
+ .map(|_| ())
+ .map_err(|error| RadrootsSdkError::EventStore {
+ message: format!("{store_name} backup failed: {error}"),
+ })
+}
+
+#[cfg(feature = "runtime")]
+async fn verify_backup_paths(
+ paths: &RadrootsSdkStoragePaths,
+) -> Result<SdkBackupVerification, RadrootsSdkError> {
+ let event_store = RadrootsEventStore::open_file(&paths.event_store_path).await?;
+ let outbox = RadrootsOutbox::open_file(&paths.outbox_path).await?;
+ let event_store_integrity = sqlite_integrity_result(event_store.pool()).await?;
+ let outbox_integrity = sqlite_integrity_result(outbox.pool()).await?;
+ let event_summary = event_store.status_summary().await?;
+ let outbox_summary = outbox.status_summary(i64::MAX).await?;
+ Ok(SdkBackupVerification {
+ event_store_ok: event_store_integrity.ok,
+ outbox_ok: outbox_integrity.ok,
+ event_store_events: event_summary.total_events,
+ outbox_events: outbox_summary.total_events,
+ })
+}
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -87,6 +87,7 @@ pub struct SyncOutboxStatus {
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,
pub last_attempt_at_ms: Option<i64>,
@@ -101,6 +102,7 @@ impl From<RadrootsOutboxStatusSummary> for SyncOutboxStatus {
pending_events: summary.pending_events,
retryable_events: summary.retryable_events,
terminal_events: summary.terminal_events,
+ failed_terminal_events: summary.failed_terminal_events,
ready_signed_events: summary.ready_signed_events,
publishing_events: summary.publishing_events,
last_attempt_at_ms: summary.last_attempt_at_ms,
diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs
@@ -4,8 +4,9 @@ use radroots_sdk::{
BackupRequest, IntegrityRequest, RadrootsSdk, RadrootsSdkClock, RadrootsSdkError,
RadrootsSdkErrorClass, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig,
RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkBackupState,
- SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkStorageKind,
- StorageStatusReceipt, StorageStatusRequest,
+ SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus,
+ SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, SdkSqliteStoreStatus,
+ SdkStorageKind, StorageStatusReceipt, StorageStatusRequest,
};
use std::path::PathBuf;
@@ -147,6 +148,41 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() {
}
#[tokio::test]
+async fn sdk_memory_storage_status_and_integrity_report_canonical_stores() {
+ let sdk = RadrootsSdk::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .build()
+ .await
+ .expect("sdk");
+
+ let status = sdk
+ .storage_status(StorageStatusRequest::default())
+ .await
+ .expect("status");
+ assert_eq!(status.storage, SdkStorageKind::Memory);
+ assert_eq!(status.paths, None);
+ assert_eq!(status.event_store.store.schema_version, 1);
+ assert_eq!(status.outbox.store.schema_version, 1);
+ assert!(status.event_store.store.foreign_keys_enabled);
+ assert!(status.outbox.store.foreign_keys_enabled);
+ assert_eq!(status.event_store.total_events, 0);
+ assert_eq!(status.outbox.total_events, 0);
+ assert_eq!(status.outbox.failed_terminal_events, 0);
+ assert!(status.event_store.store.integrity_ok);
+ assert!(status.outbox.store.integrity_ok);
+
+ let integrity = sdk
+ .integrity(IntegrityRequest::default())
+ .await
+ .expect("integrity");
+ assert!(integrity.checked_paths.is_empty());
+ assert!(integrity.event_store_ok);
+ assert!(integrity.outbox_ok);
+ assert_eq!(integrity.event_store_result, "ok");
+ assert_eq!(integrity.outbox_result, "ok");
+}
+
+#[tokio::test]
async fn sdk_fixed_clock_is_used_by_runtime() {
let timestamp = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000);
let sdk = RadrootsSdk::builder()
@@ -502,6 +538,14 @@ fn idempotency_key_validation_is_bounded_and_debug_redacted() {
#[test]
fn storage_backup_and_integrity_contract_dtos_serialize() {
+ let store = SdkSqliteStoreStatus {
+ schema_version: 1,
+ journal_mode: "wal".to_owned(),
+ foreign_keys_enabled: true,
+ busy_timeout_ms: 5_000,
+ integrity_ok: true,
+ integrity_result: "ok".to_owned(),
+ };
assert_eq!(
serde_json::to_value(StorageStatusRequest::default()).expect("status request"),
serde_json::json!({})
@@ -510,11 +554,65 @@ fn storage_backup_and_integrity_contract_dtos_serialize() {
serde_json::to_value(StorageStatusReceipt {
storage: SdkStorageKind::Directory,
paths: None,
+ event_store: SdkEventStoreStorageStatus {
+ store: store.clone(),
+ total_events: 2,
+ projection_eligible_events: 1,
+ relay_observations: 1,
+ last_event_seq: Some(2),
+ last_event_updated_at_ms: Some(1_700_000_000_000),
+ },
+ outbox: SdkOutboxStorageStatus {
+ store,
+ total_events: 3,
+ pending_events: 1,
+ retryable_events: 1,
+ terminal_events: 1,
+ failed_terminal_events: 0,
+ ready_signed_events: 1,
+ publishing_events: 0,
+ last_attempt_at_ms: Some(1_700_000_000_000),
+ last_error: Some("relay publish incomplete".to_owned()),
+ },
})
.expect("status receipt"),
serde_json::json!({
"storage": "directory",
- "paths": null
+ "paths": null,
+ "event_store": {
+ "store": {
+ "schema_version": 1,
+ "journal_mode": "wal",
+ "foreign_keys_enabled": true,
+ "busy_timeout_ms": 5000,
+ "integrity_ok": true,
+ "integrity_result": "ok"
+ },
+ "total_events": 2,
+ "projection_eligible_events": 1,
+ "relay_observations": 1,
+ "last_event_seq": 2,
+ "last_event_updated_at_ms": 1700000000000i64
+ },
+ "outbox": {
+ "store": {
+ "schema_version": 1,
+ "journal_mode": "wal",
+ "foreign_keys_enabled": true,
+ "busy_timeout_ms": 5000,
+ "integrity_ok": true,
+ "integrity_result": "ok"
+ },
+ "total_events": 3,
+ "pending_events": 1,
+ "retryable_events": 1,
+ "terminal_events": 1,
+ "failed_terminal_events": 0,
+ "ready_signed_events": 1,
+ "publishing_events": 0,
+ "last_attempt_at_ms": 1700000000000i64,
+ "last_error": "relay publish incomplete"
+ }
})
);
assert_eq!(
@@ -533,6 +631,21 @@ fn storage_backup_and_integrity_contract_dtos_serialize() {
serde_json::json!("completed")
);
assert_eq!(
+ serde_json::to_value(SdkBackupVerification {
+ event_store_ok: true,
+ outbox_ok: true,
+ event_store_events: 2,
+ outbox_events: 3,
+ })
+ .expect("backup verification"),
+ serde_json::json!({
+ "event_store_ok": true,
+ "outbox_ok": true,
+ "event_store_events": 2,
+ "outbox_events": 3
+ })
+ );
+ assert_eq!(
serde_json::to_value(IntegrityRequest::default()).expect("integrity request"),
serde_json::json!({})
);
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -8,6 +8,7 @@ use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
+use radroots_event_store::RadrootsEventStore;
use radroots_events::{
contract::RadrootsActorRole,
draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts},
@@ -21,11 +22,12 @@ use radroots_relay_transport::{
RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError,
};
use radroots_sdk::{
- ListingEnqueuePublishRequest, ListingPreparePublishRequest, PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS,
- PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT,
- PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind,
- PushOutboxRelayReceipt, PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp,
- SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy, SyncStatusRequest,
+ BackupRequest, IntegrityRequest, ListingEnqueuePublishRequest, ListingPreparePublishRequest,
+ PUSH_OUTBOX_DEFAULT_CLAIM_TTL_MS, PUSH_OUTBOX_DEFAULT_LIMIT,
+ PUSH_OUTBOX_DEFAULT_NEXT_ATTEMPT_DELAY_MS, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt,
+ PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt,
+ PushOutboxRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkTimestamp, SdkRelayAuthPolicy,
+ SdkRelayTargetPolicy, SdkRelayUrlPolicy, StorageStatusRequest, SyncStatusRequest,
SyncStatusSource,
};
use std::sync::{Arc, Mutex};
@@ -302,6 +304,7 @@ async fn sync_status_empty_store_reports_canonical_sources_and_configured_relays
assert_eq!(receipt.outbox.pending_events, 0);
assert_eq!(receipt.outbox.retryable_events, 0);
assert_eq!(receipt.outbox.terminal_events, 0);
+ assert_eq!(receipt.outbox.failed_terminal_events, 0);
assert_eq!(receipt.outbox.ready_signed_events, 0);
assert_eq!(receipt.relay_targets.configured_count, 2);
assert_eq!(
@@ -325,6 +328,7 @@ async fn sync_status_empty_store_reports_canonical_sources_and_configured_relays
"pending_events": 0,
"retryable_events": 0,
"terminal_events": 0,
+ "failed_terminal_events": 0,
"ready_signed_events": 0,
"publishing_events": 0,
"last_attempt_at_ms": null,
@@ -370,6 +374,7 @@ async fn sync_status_reports_pending_retryable_terminal_and_last_attempt_metadat
assert_eq!(receipt.outbox.pending_events, 1);
assert_eq!(receipt.outbox.retryable_events, 1);
assert_eq!(receipt.outbox.terminal_events, 1);
+ assert_eq!(receipt.outbox.failed_terminal_events, 0);
assert_eq!(receipt.outbox.ready_signed_events, 1);
assert_eq!(receipt.outbox.publishing_events, 0);
assert_eq!(receipt.outbox.last_attempt_at_ms, Some(1_700_000_000_000));
@@ -380,6 +385,131 @@ async fn sync_status_reports_pending_retryable_terminal_and_last_attempt_metadat
}
#[tokio::test]
+async fn sdk_directory_backup_creates_verified_canonical_store_copy() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let outbox_event_id = enqueue_listing(&sdk, LISTING_A_D_TAG, "Backup Coffee", &[RELAY_A]).await;
+
+ let status = sdk
+ .storage_status(StorageStatusRequest::default())
+ .await
+ .expect("storage status");
+ let source_paths = sdk.storage_paths().expect("source paths");
+ assert_eq!(status.paths.as_ref(), Some(source_paths));
+ assert_eq!(status.event_store.total_events, 1);
+ assert_eq!(status.outbox.total_events, 1);
+ assert_eq!(status.outbox.ready_signed_events, 1);
+ assert!(status.event_store.store.integrity_ok);
+ assert!(status.outbox.store.integrity_ok);
+ assert_eq!(status.event_store.store.journal_mode, "wal");
+ assert_eq!(status.outbox.store.journal_mode, "wal");
+
+ let integrity = sdk
+ .integrity(IntegrityRequest::default())
+ .await
+ .expect("integrity");
+ assert_eq!(
+ integrity.checked_paths,
+ vec![
+ source_paths.event_store_path.clone(),
+ source_paths.outbox_path.clone()
+ ]
+ );
+ assert!(integrity.event_store_ok);
+ assert!(integrity.outbox_ok);
+
+ let backup_destination = tempdir.path().join("backup");
+ let backup = sdk
+ .backup(BackupRequest {
+ destination: backup_destination.clone(),
+ overwrite: false,
+ })
+ .await
+ .expect("backup");
+ let event_store_path = backup
+ .event_store_path
+ .as_ref()
+ .expect("event store backup");
+ let outbox_path = backup.outbox_path.as_ref().expect("outbox backup");
+ let manifest_path = backup.manifest_path.as_ref().expect("manifest");
+ assert!(event_store_path.exists());
+ assert!(outbox_path.exists());
+ assert!(manifest_path.exists());
+ assert_eq!(backup.manifest.created_at_ms, 1_700_000_000_000);
+ assert_eq!(backup.manifest.source_status.event_store.total_events, 1);
+ assert_eq!(backup.manifest.source_status.outbox.total_events, 1);
+ assert!(backup.manifest.backup_verification.event_store_ok);
+ assert!(backup.manifest.backup_verification.outbox_ok);
+
+ let backup_event_store = RadrootsEventStore::open_file(event_store_path)
+ .await
+ .expect("backup event store");
+ let backup_outbox = RadrootsOutbox::open_file(outbox_path)
+ .await
+ .expect("backup outbox");
+ assert_eq!(
+ backup_event_store
+ .status_summary()
+ .await
+ .expect("backup event status")
+ .total_events,
+ 1
+ );
+ assert_eq!(
+ backup_outbox
+ .status_summary(i64::MAX)
+ .await
+ .expect("backup outbox status")
+ .total_events,
+ 1
+ );
+ assert_eq!(
+ backup_outbox
+ .get_event(outbox_event_id)
+ .await
+ .expect("backup event")
+ .expect("backup event")
+ .state,
+ RadrootsOutboxEventState::Signed
+ );
+
+ let duplicate = sdk
+ .backup(BackupRequest {
+ destination: backup_destination.clone(),
+ overwrite: false,
+ })
+ .await
+ .expect_err("duplicate backup");
+ assert!(matches!(duplicate, RadrootsSdkError::InvalidRequest { .. }));
+
+ sdk.backup(BackupRequest {
+ destination: backup_destination,
+ overwrite: true,
+ })
+ .await
+ .expect("overwrite backup");
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn sdk_backup_rejects_symlink_destination_even_with_overwrite() {
+ let (tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let target = tempdir.path().join("backup-target");
+ let destination = tempdir.path().join("backup-link");
+ std::fs::create_dir(&target).expect("target");
+ std::os::unix::fs::symlink(&target, &destination).expect("symlink");
+
+ let error = sdk
+ .backup(BackupRequest {
+ destination,
+ overwrite: true,
+ })
+ .await
+ .expect_err("symlink destination");
+ assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(target.exists());
+}
+
+#[tokio::test]
async fn push_outbox_empty_queue_returns_zero_counts() {
let (_tempdir, sdk) = directory_sdk(&[]).await;
let adapter = RadrootsMockRelayPublishAdapter::new();