sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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:
Mcrates/sdk/Cargo.toml | 9+++++----
Mcrates/sdk/src/lib.rs | 5+++--
Mcrates/sdk/src/runtime.rs | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/sync_runtime.rs | 2++
Mcrates/sdk/tests/runtime_foundation.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/sync_runtime.rs | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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();