lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 6cf47f4a0daa32ea93580a848f97ed08578d9957
parent ba8dc9cae4d0ff645b751a4636f2d2b0c82a307f
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 09:32:56 +0000

sdk: add private farm location store

Diffstat:
Mcrates/sdk/src/error.rs | 9+++++++++
Mcrates/sdk/src/farms_runtime.rs | 314++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/lib.rs | 9++++++---
Acrates/sdk/src/private_store.rs | 308+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/runtime.rs | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/farms_runtime.rs | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/runtime_foundation.rs | 47++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/sync_runtime.rs | 39++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 33+++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/runtime_tests.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1194 insertions(+), 19 deletions(-)

diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -172,6 +172,9 @@ pub enum RadrootsSdkError { Outbox { message: String, }, + PrivateStore { + message: String, + }, GeoNames { kind: RadrootsSdkGeoNamesErrorKind, message: String, @@ -215,6 +218,7 @@ impl RadrootsSdkError { Self::ListingDraft { .. } => "listing_draft", Self::ListingMutation { .. } => "listing_mutation", Self::Outbox { .. } => "outbox", + Self::PrivateStore { .. } => "private_store", Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => "geonames_configuration", RadrootsSdkGeoNamesErrorKind::Download => "geonames_download", @@ -234,6 +238,7 @@ impl RadrootsSdkError { Self::Io { .. } | Self::EventStore { .. } | Self::Outbox { .. } + | Self::PrivateStore { .. } | Self::Projection { .. } => RadrootsSdkErrorClass::Storage, Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => RadrootsSdkErrorClass::Configuration, @@ -279,6 +284,7 @@ impl RadrootsSdkError { | Self::ProductSyncRelaySetupFailure { .. } | Self::EventStore { .. } | Self::Outbox { .. } + | Self::PrivateStore { .. } | Self::GeoNames { kind: RadrootsSdkGeoNamesErrorKind::Cache | RadrootsSdkGeoNamesErrorKind::Download, @@ -297,6 +303,7 @@ impl RadrootsSdkError { Self::Io { .. } | Self::EventStore { .. } | Self::Outbox { .. } + | Self::PrivateStore { .. } | Self::Projection { .. } => vec![RadrootsSdkRecoveryAction::InspectLocalStores], Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => { @@ -415,6 +422,7 @@ impl RadrootsSdkError { | Self::ListingDraft { message } | Self::ListingMutation { message } | Self::Outbox { message } + | Self::PrivateStore { message } | Self::RelayTransport { message } | Self::Projection { message } => json!({ "message": message }), Self::GeoNames { kind, message } => json!({ "kind": kind, "message": message }), @@ -600,6 +608,7 @@ impl fmt::Display for RadrootsSdkError { write!(f, "sdk listing mutation error: {message}") } Self::Outbox { message } => write!(f, "sdk outbox error: {message}"), + Self::PrivateStore { message } => write!(f, "sdk private store error: {message}"), Self::GeoNames { kind, message } => { write!(f, "sdk GeoNames {kind:?} error: {message}") } diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -4,6 +4,8 @@ use crate::workflow_runtime::enqueue_configured_signed_workflow; use crate::{ FarmsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, farm, + geonames::{Geocoder, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult}, + private_store::SdkPrivateFarmLocationRecord, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] @@ -12,9 +14,10 @@ use radroots_authority::{RadrootsActorContext, RadrootsEventSigner}; use radroots_events::{ contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, - farm::RadrootsFarm, + farm::{RadrootsFarm, RadrootsFarmPublicLocation}, ids::{RadrootsAddressableCoordinate, RadrootsEventId}, kinds::KIND_FARM, + listing::RadrootsListingPublicLocation, }; #[cfg(feature = "runtime")] use radroots_events_codec::wire::to_frozen_draft; @@ -23,6 +26,12 @@ pub const FARM_PUBLISH_OPERATION_KIND: &str = "farm.publish.v1"; #[cfg(feature = "runtime")] const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; +#[cfg(feature = "runtime")] +const FARM_PRIVATE_LOCATION_OPERATION: &str = "farm.private_location.upsert"; +#[cfg(feature = "runtime")] +const GEOHASH5_LEN: usize = 5; +#[cfg(feature = "runtime")] +const GEOHASH_BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz"; #[cfg(feature = "runtime")] #[derive(Clone, Debug, serde::Serialize)] @@ -133,6 +142,101 @@ pub struct FarmEnqueueReceipt { } #[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct SdkExactLocation { + pub latitude: f64, + pub longitude: f64, +} + +#[cfg(feature = "runtime")] +impl SdkExactLocation { + pub fn new(latitude: f64, longitude: f64) -> Self { + Self { + latitude, + longitude, + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SdkPublicLocality { + pub primary: String, + pub city: Option<String>, + pub region: Option<String>, + pub country: Option<String>, + pub geohash5: String, +} + +#[cfg(feature = "runtime")] +impl SdkPublicLocality { + pub fn to_farm_public_location(&self) -> RadrootsFarmPublicLocation { + RadrootsFarmPublicLocation { + primary: self.primary.clone(), + city: self.city.clone(), + region: self.region.clone(), + country: self.country.clone(), + geohash: self.geohash5.clone(), + } + } + + pub fn to_listing_public_location(&self) -> RadrootsListingPublicLocation { + RadrootsListingPublicLocation { + primary: self.primary.clone(), + city: self.city.clone(), + region: self.region.clone(), + country: self.country.clone(), + geohash: self.geohash5.clone(), + } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct FarmPrivateLocationUpsertRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub farm_d_tag: String, + pub exact_location: SdkExactLocation, + pub updated_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl FarmPrivateLocationUpsertRequest { + pub fn new( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + exact_location: SdkExactLocation, + ) -> Self { + Self { + actor, + farm_d_tag: farm_d_tag.into(), + exact_location, + updated_at: None, + } + } + + pub fn with_updated_at(mut self, updated_at: RadrootsSdkTimestamp) -> Self { + self.updated_at = Some(updated_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FarmPrivateLocationReceipt { + pub farm_addr: RadrootsAddressableCoordinate, + pub farm_pubkey: String, + pub farm_d_tag: String, + pub exact_location: SdkExactLocation, + pub public_locality: SdkPublicLocality, + pub geonames_feature_id: Option<i64>, + pub geonames_country_id: Option<String>, + pub updated_at_ms: i64, +} + +#[cfg(feature = "runtime")] impl<'sdk> FarmsClient<'sdk> { pub fn prepare_publish( &self, @@ -237,6 +341,64 @@ impl<'sdk> FarmsClient<'sdk> { Ok(farm_enqueue_receipt(plan, enqueue)) } + pub async fn upsert_private_location( + &self, + request: FarmPrivateLocationUpsertRequest, + ) -> Result<FarmPrivateLocationReceipt, RadrootsSdkError> { + let geocoder = self.sdk.geonames().open_verified()?; + self.upsert_private_location_with_geocoder(request, &geocoder) + .await + } + + pub async fn upsert_private_location_with_geocoder( + &self, + request: FarmPrivateLocationUpsertRequest, + geocoder: &Geocoder, + ) -> Result<FarmPrivateLocationReceipt, RadrootsSdkError> { + require_farmer_actor(&request.actor, FARM_PRIVATE_LOCATION_OPERATION)?; + validate_exact_location(request.exact_location)?; + let updated_at_ms = match request.updated_at { + Some(updated_at) => sdk_timestamp_ms(updated_at)?, + None => crate::runtime::sdk_now_ms(self.sdk)?, + }; + let farm_addr = farm_addr(&request.actor, request.farm_d_tag.as_str())?; + let reverse = nearest_geonames_locality(geocoder, request.exact_location)?; + let public_locality = public_locality_from_reverse(request.exact_location, &reverse)?; + let record = SdkPrivateFarmLocationRecord { + farm_addr: farm_addr.clone(), + farm_pubkey: request.actor.pubkey().as_str().to_owned(), + farm_d_tag: request.farm_d_tag, + latitude: request.exact_location.latitude, + longitude: request.exact_location.longitude, + locality_primary: public_locality.primary.clone(), + locality_city: public_locality.city.clone(), + locality_region: public_locality.region.clone(), + locality_country: public_locality.country.clone(), + geohash5: public_locality.geohash5.clone(), + geonames_feature_id: Some(reverse.id), + geonames_country_id: Some(reverse.country_id.clone()), + updated_at_ms, + }; + self.sdk + ._private_store + .upsert_farm_location(&record) + .await?; + Ok(private_location_receipt_from_record(record)) + } + + pub async fn private_location( + &self, + farm_addr: &RadrootsAddressableCoordinate, + ) -> Result<Option<FarmPrivateLocationReceipt>, RadrootsSdkError> { + self.sdk + ._private_store + .farm_location(farm_addr) + .await? + .map(private_location_receipt_from_record) + .map(Ok) + .transpose() + } + fn resolved_created_at( &self, created_at: Option<RadrootsSdkTimestamp>, @@ -323,6 +485,156 @@ fn farm_addr( ) } +#[cfg(feature = "runtime")] +fn validate_exact_location(location: SdkExactLocation) -> Result<(), RadrootsSdkError> { + if !location.latitude.is_finite() + || !location.longitude.is_finite() + || location.latitude < -90.0 + || location.latitude > 90.0 + || location.longitude < -180.0 + || location.longitude > 180.0 + { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm exact location coordinates are outside valid latitude/longitude bounds" + .to_owned(), + }); + } + Ok(()) +} + +#[cfg(feature = "runtime")] +fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> { + let seconds = timestamp.unix_seconds(); + let millis = seconds + .checked_mul(1_000) + .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?; + i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds }) +} + +#[cfg(feature = "runtime")] +fn nearest_geonames_locality( + geocoder: &Geocoder, + exact_location: SdkExactLocation, +) -> Result<GeocoderReverseResult, RadrootsSdkError> { + let mut results = geocoder.reverse( + GeocoderPoint { + lat: exact_location.latitude, + lng: exact_location.longitude, + }, + Some(GeocoderReverseOptions { + limit: 1, + degree_offset: 0.5, + }), + )?; + results.pop().ok_or_else(|| RadrootsSdkError::GeoNames { + kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, + message: "GeoNames reverse lookup returned no public locality".to_owned(), + }) +} + +#[cfg(feature = "runtime")] +fn public_locality_from_reverse( + exact_location: SdkExactLocation, + reverse: &GeocoderReverseResult, +) -> Result<SdkPublicLocality, RadrootsSdkError> { + let primary = required_public_string(reverse.name.as_str(), "GeoNames locality name")?; + let country = optional_public_string(reverse.country_name.as_deref()) + .or_else(|| Some(reverse.country_id.clone())); + Ok(SdkPublicLocality { + primary: primary.clone(), + city: Some(primary), + region: optional_public_string(reverse.admin1_name.as_deref()), + country, + geohash5: geohash5(exact_location)?, + }) +} + +#[cfg(feature = "runtime")] +fn required_public_string(value: &str, label: &str) -> Result<String, RadrootsSdkError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RadrootsSdkError::GeoNames { + kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, + message: format!("{label} must not be empty"), + }); + } + Ok(trimmed.to_owned()) +} + +#[cfg(feature = "runtime")] +fn optional_public_string(value: Option<&str>) -> Option<String> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +#[cfg(feature = "runtime")] +fn geohash5(location: SdkExactLocation) -> Result<String, RadrootsSdkError> { + validate_exact_location(location)?; + let mut latitude_min = -90.0; + let mut latitude_max = 90.0; + let mut longitude_min = -180.0; + let mut longitude_max = 180.0; + let mut even = true; + let mut bit_count = 0; + let mut value = 0usize; + let mut geohash = String::with_capacity(GEOHASH5_LEN); + while geohash.len() < GEOHASH5_LEN { + value <<= 1; + if even { + let middle = (longitude_min + longitude_max) / 2.0; + if location.longitude >= middle { + value |= 1; + longitude_min = middle; + } else { + longitude_max = middle; + } + } else { + let middle = (latitude_min + latitude_max) / 2.0; + if location.latitude >= middle { + value |= 1; + latitude_min = middle; + } else { + latitude_max = middle; + } + } + even = !even; + bit_count += 1; + if bit_count == 5 { + geohash.push(GEOHASH_BASE32[value] as char); + bit_count = 0; + value = 0; + } + } + Ok(geohash) +} + +#[cfg(feature = "runtime")] +fn private_location_receipt_from_record( + record: SdkPrivateFarmLocationRecord, +) -> FarmPrivateLocationReceipt { + FarmPrivateLocationReceipt { + farm_addr: record.farm_addr, + farm_pubkey: record.farm_pubkey, + farm_d_tag: record.farm_d_tag, + exact_location: SdkExactLocation { + latitude: record.latitude, + longitude: record.longitude, + }, + public_locality: SdkPublicLocality { + primary: record.locality_primary, + city: record.locality_city, + region: record.locality_region, + country: record.locality_country, + geohash5: record.geohash5, + }, + geonames_feature_id: record.geonames_feature_id, + geonames_country_id: record.geonames_country_id, + updated_at_ms: record.updated_at_ms, + } +} + #[cfg(all(test, feature = "runtime"))] #[path = "../tests/unit/farms_runtime_tests.rs"] mod tests; diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -33,6 +33,8 @@ mod order; #[cfg(feature = "runtime")] mod orders_runtime; #[cfg(feature = "runtime")] +mod private_store; +#[cfg(feature = "runtime")] mod product_clients; mod profile; pub mod protocol; @@ -56,7 +58,8 @@ pub use crate::error::{ #[cfg(feature = "runtime")] pub use crate::farms_runtime::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, - FarmPreparePublishRequest, FarmPublishPlan, + FarmPreparePublishRequest, FarmPrivateLocationReceipt, FarmPrivateLocationUpsertRequest, + FarmPublishPlan, SdkExactLocation, SdkPublicLocality, }; #[cfg(feature = "runtime")] pub use crate::geonames::{ @@ -111,8 +114,8 @@ pub use crate::runtime::{ RadrootsClientBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest, SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, - SdkOutboxStorageStatus, SdkPublishTransport, SdkRestoreState, SdkSqliteStoreStatus, - SdkStorageKind, StorageStatusReceipt, StorageStatusRequest, + SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkPublishTransport, SdkRestoreState, + SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt, StorageStatusRequest, }; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] pub use crate::signer_provider::{ diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -0,0 +1,308 @@ +#![cfg(feature = "runtime")] + +use crate::RadrootsSdkError; +use radroots_events::ids::RadrootsAddressableCoordinate; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Row, SqlitePool}; +use std::path::Path; +use std::str::FromStr; + +pub(crate) const SDK_PRIVATE_STORE_SCHEMA_VERSION: i64 = 1; + +const PRIVATE_STORE_MIGRATION_UP: &str = r#" +CREATE TABLE IF NOT EXISTS sdk_private_farm_location ( + farm_addr TEXT PRIMARY KEY NOT NULL, + farm_pubkey TEXT NOT NULL, + farm_d_tag TEXT NOT NULL, + latitude REAL NOT NULL CHECK(latitude >= -90.0 AND latitude <= 90.0), + longitude REAL NOT NULL CHECK(longitude >= -180.0 AND longitude <= 180.0), + locality_primary TEXT NOT NULL, + locality_city TEXT, + locality_region TEXT, + locality_country TEXT, + geohash5 TEXT NOT NULL CHECK(length(geohash5) = 5), + geonames_feature_id INTEGER, + geonames_country_id TEXT, + updated_at_ms INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS sdk_private_farm_location_pubkey + ON sdk_private_farm_location (farm_pubkey, farm_d_tag); +"#; + +#[derive(Clone)] +pub(crate) struct SdkPrivateStore { + pool: SqlitePool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SdkPrivateFarmLocationRecord { + pub farm_addr: RadrootsAddressableCoordinate, + pub farm_pubkey: String, + pub farm_d_tag: String, + pub latitude: f64, + pub longitude: f64, + pub locality_primary: String, + pub locality_city: Option<String>, + pub locality_region: Option<String>, + pub locality_country: Option<String>, + pub geohash5: String, + pub geonames_feature_id: Option<i64>, + pub geonames_country_id: Option<String>, + pub updated_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SdkPrivateStoreStatusSummary { + pub farm_private_locations: i64, +} + +impl SdkPrivateStore { + pub async fn open_memory() -> Result<Self, RadrootsSdkError> { + let options = + SqliteConnectOptions::from_str("sqlite::memory:").map_err(private_store_error)?; + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(private_store_error)?; + configure_connection(&pool, false).await?; + apply_up(&pool).await?; + Ok(Self { pool }) + } + + pub async fn open_file(path: impl AsRef<Path>) -> Result<Self, RadrootsSdkError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(private_store_error)?; + configure_connection(&pool, true).await?; + apply_up(&pool).await?; + Ok(Self { pool }) + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } + + pub async fn pragma_foreign_keys(&self) -> Result<i64, RadrootsSdkError> { + query_i64(&self.pool, "PRAGMA foreign_keys").await + } + + pub async fn pragma_busy_timeout(&self) -> Result<i64, RadrootsSdkError> { + query_i64(&self.pool, "PRAGMA busy_timeout").await + } + + pub async fn pragma_journal_mode(&self) -> Result<String, RadrootsSdkError> { + query_string(&self.pool, "PRAGMA journal_mode").await + } + + pub async fn status_summary(&self) -> Result<SdkPrivateStoreStatusSummary, RadrootsSdkError> { + Ok(SdkPrivateStoreStatusSummary { + farm_private_locations: query_i64( + &self.pool, + "SELECT COUNT(*) FROM sdk_private_farm_location", + ) + .await?, + }) + } + + pub async fn upsert_farm_location( + &self, + record: &SdkPrivateFarmLocationRecord, + ) -> Result<(), RadrootsSdkError> { + validate_location_record(record)?; + sqlx::query( + r#" + INSERT INTO sdk_private_farm_location ( + farm_addr, + farm_pubkey, + farm_d_tag, + latitude, + longitude, + locality_primary, + locality_city, + locality_region, + locality_country, + geohash5, + geonames_feature_id, + geonames_country_id, + updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ON CONFLICT(farm_addr) DO UPDATE SET + farm_pubkey = excluded.farm_pubkey, + farm_d_tag = excluded.farm_d_tag, + latitude = excluded.latitude, + longitude = excluded.longitude, + locality_primary = excluded.locality_primary, + locality_city = excluded.locality_city, + locality_region = excluded.locality_region, + locality_country = excluded.locality_country, + geohash5 = excluded.geohash5, + geonames_feature_id = excluded.geonames_feature_id, + geonames_country_id = excluded.geonames_country_id, + updated_at_ms = excluded.updated_at_ms + "#, + ) + .bind(record.farm_addr.as_str()) + .bind(record.farm_pubkey.as_str()) + .bind(record.farm_d_tag.as_str()) + .bind(record.latitude) + .bind(record.longitude) + .bind(record.locality_primary.as_str()) + .bind(record.locality_city.as_deref()) + .bind(record.locality_region.as_deref()) + .bind(record.locality_country.as_deref()) + .bind(record.geohash5.as_str()) + .bind(record.geonames_feature_id) + .bind(record.geonames_country_id.as_deref()) + .bind(record.updated_at_ms) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(private_store_error) + } + + pub async fn farm_location( + &self, + farm_addr: &RadrootsAddressableCoordinate, + ) -> Result<Option<SdkPrivateFarmLocationRecord>, RadrootsSdkError> { + let row = sqlx::query( + r#" + SELECT + farm_addr, + farm_pubkey, + farm_d_tag, + latitude, + longitude, + locality_primary, + locality_city, + locality_region, + locality_country, + geohash5, + geonames_feature_id, + geonames_country_id, + updated_at_ms + FROM sdk_private_farm_location + WHERE farm_addr = ?1 + "#, + ) + .bind(farm_addr.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(private_store_error)?; + row.map(|row| private_farm_location_from_row(farm_addr.clone(), row)) + .transpose() + } +} + +async fn configure_connection( + pool: &SqlitePool, + file_backed: bool, +) -> Result<(), RadrootsSdkError> { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(pool) + .await + .map_err(private_store_error)?; + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(pool) + .await + .map_err(private_store_error)?; + if file_backed { + sqlx::query("PRAGMA journal_mode = WAL") + .execute(pool) + .await + .map_err(private_store_error)?; + } + Ok(()) +} + +async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { + sqlx::raw_sql(PRIVATE_STORE_MIGRATION_UP) + .execute(pool) + .await + .map(|_| ()) + .map_err(private_store_error) +} + +async fn query_i64(pool: &SqlitePool, sql: &str) -> Result<i64, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(private_store_error)?; + row.try_get(0).map_err(private_store_error) +} + +async fn query_string(pool: &SqlitePool, sql: &str) -> Result<String, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(private_store_error)?; + row.try_get(0).map_err(private_store_error) +} + +fn private_farm_location_from_row( + farm_addr: RadrootsAddressableCoordinate, + row: sqlx::sqlite::SqliteRow, +) -> Result<SdkPrivateFarmLocationRecord, RadrootsSdkError> { + Ok(SdkPrivateFarmLocationRecord { + farm_addr, + farm_pubkey: row.try_get("farm_pubkey").map_err(private_store_error)?, + farm_d_tag: row.try_get("farm_d_tag").map_err(private_store_error)?, + latitude: row.try_get("latitude").map_err(private_store_error)?, + longitude: row.try_get("longitude").map_err(private_store_error)?, + locality_primary: row + .try_get("locality_primary") + .map_err(private_store_error)?, + locality_city: row.try_get("locality_city").map_err(private_store_error)?, + locality_region: row + .try_get("locality_region") + .map_err(private_store_error)?, + locality_country: row + .try_get("locality_country") + .map_err(private_store_error)?, + geohash5: row.try_get("geohash5").map_err(private_store_error)?, + geonames_feature_id: row + .try_get("geonames_feature_id") + .map_err(private_store_error)?, + geonames_country_id: row + .try_get("geonames_country_id") + .map_err(private_store_error)?, + updated_at_ms: row.try_get("updated_at_ms").map_err(private_store_error)?, + }) +} + +fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), RadrootsSdkError> { + if !record.latitude.is_finite() + || !record.longitude.is_finite() + || record.latitude < -90.0 + || record.latitude > 90.0 + || record.longitude < -180.0 + || record.longitude > 180.0 + { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm exact location coordinates are outside valid latitude/longitude bounds" + .to_owned(), + }); + } + if record.locality_primary.trim().is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm public locality primary name must not be empty".to_owned(), + }); + } + if record.geohash5.len() != 5 { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm public locality geohash must be precision 5".to_owned(), + }); + } + Ok(()) +} + +fn private_store_error(error: impl ToString) -> RadrootsSdkError { + RadrootsSdkError::PrivateStore { + message: error.to_string(), + } +} diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1,4 +1,6 @@ #[cfg(feature = "runtime")] +use crate::private_store::{SDK_PRIVATE_STORE_SCHEMA_VERSION, SdkPrivateStore}; +#[cfg(feature = "runtime")] use crate::{ DvmClient, FarmsClient, GeoNamesClient, ListingsClient, MarketClient, RadrootsGeoNamesConfig, RadrootsSdkError, SdkRelayTargetSet, SdkRelayUrlPolicy, SyncClient, TradesClient, @@ -34,10 +36,14 @@ const SDK_EVENT_STORE_SCHEMA_VERSION: i64 = 1; #[cfg(feature = "runtime")] const SDK_OUTBOX_SCHEMA_VERSION: i64 = 1; #[cfg(feature = "runtime")] +const SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT: i64 = SDK_PRIVATE_STORE_SCHEMA_VERSION; +#[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 PRIVATE_STORE_BACKUP_FILE: &str = "private.sqlite"; +#[cfg(feature = "runtime")] const BACKUP_MANIFEST_FILE: &str = "manifest.json"; #[cfg(feature = "runtime")] @@ -119,6 +125,7 @@ fn sdk_timestamp_from_system_time( pub struct RadrootsSdkStoragePaths { pub event_store_path: PathBuf, pub outbox_path: PathBuf, + pub private_store_path: PathBuf, } #[cfg(feature = "runtime")] @@ -140,6 +147,7 @@ pub struct StorageStatusReceipt { pub paths: Option<RadrootsSdkStoragePaths>, pub event_store: SdkEventStoreStorageStatus, pub outbox: SdkOutboxStorageStatus, + pub private_store: SdkPrivateStoreStorageStatus, } #[cfg(feature = "runtime")] @@ -190,6 +198,13 @@ pub struct SdkOutboxStorageStatus { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SdkPrivateStoreStorageStatus { + pub store: SdkSqliteStoreStatus, + pub farm_private_locations: i64, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[non_exhaustive] pub struct BackupRequest { pub destination: PathBuf, @@ -218,6 +233,7 @@ pub struct BackupReceipt { pub state: SdkBackupState, pub event_store_path: Option<PathBuf>, pub outbox_path: Option<PathBuf>, + pub private_store_path: Option<PathBuf>, pub manifest_path: Option<PathBuf>, pub manifest: SdkBackupManifest, } @@ -258,8 +274,10 @@ pub struct SdkBackupManifest { pub struct SdkBackupVerification { pub event_store_ok: bool, pub outbox_ok: bool, + pub private_store_ok: bool, pub event_store_events: i64, pub outbox_events: i64, + pub private_farm_locations: i64, } #[cfg(feature = "runtime")] @@ -280,8 +298,10 @@ pub struct IntegrityReceipt { pub checked_paths: Vec<PathBuf>, pub event_store_ok: bool, pub outbox_ok: bool, + pub private_store_ok: bool, pub event_store_result: String, pub outbox_result: String, + pub private_store_result: String, } #[cfg(feature = "runtime")] @@ -368,6 +388,7 @@ pub struct RestoreArchive { pub source: PathBuf, pub event_store_path: PathBuf, pub outbox_path: PathBuf, + pub private_store_path: PathBuf, pub manifest_path: PathBuf, pub manifest: SdkBackupManifest, pub verification: SdkBackupVerification, @@ -382,6 +403,7 @@ pub struct RestoreReceipt { pub destination_paths: Option<RadrootsSdkStoragePaths>, pub event_store_path: PathBuf, pub outbox_path: PathBuf, + pub private_store_path: PathBuf, pub manifest_path: PathBuf, pub manifest: SdkBackupManifest, pub verification: SdkBackupVerification, @@ -477,6 +499,7 @@ impl RadrootsClientBuilder { Ok(RadrootsClient { _event_store: storage.event_store, _outbox: storage.outbox, + _private_store: storage.private_store, storage_paths: storage.paths, geonames: self.geonames, clock: self.clock, @@ -493,6 +516,7 @@ impl RadrootsClientBuilder { pub struct RadrootsClient { pub(crate) _event_store: RadrootsEventStore, pub(crate) _outbox: RadrootsOutbox, + pub(crate) _private_store: SdkPrivateStore, storage_paths: Option<RadrootsSdkStoragePaths>, geonames: Option<RadrootsGeoNamesConfig>, clock: RadrootsSdkClock, @@ -590,8 +614,10 @@ impl RadrootsClient { let now_ms = sdk_now_ms(self)?; let event_store_status = event_store_sqlite_status(&self._event_store).await?; let outbox_store_status = outbox_sqlite_status(&self._outbox).await?; + let private_store_status = private_store_sqlite_status(&self._private_store).await?; let event_summary = event_store_status_summary(&self._event_store).await?; let outbox_summary = outbox_status_summary(&self._outbox, now_ms).await?; + let private_summary = self._private_store.status_summary().await?; Ok(StorageStatusReceipt { storage: self.storage_kind(), paths: self.storage_paths.clone(), @@ -615,6 +641,10 @@ impl RadrootsClient { last_attempt_at_ms: outbox_summary.last_attempt_at_ms, last_error: outbox_summary.last_error, }, + private_store: SdkPrivateStoreStorageStatus { + store: private_store_status, + farm_private_locations: private_summary.farm_private_locations, + }, }) } @@ -624,17 +654,27 @@ impl RadrootsClient { ) -> 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 private_store_integrity = + private_store_sqlite_integrity_result(self._private_store.pool()).await?; let checked_paths = self .storage_paths .as_ref() - .map(|paths| vec![paths.event_store_path.clone(), paths.outbox_path.clone()]) + .map(|paths| { + vec![ + paths.event_store_path.clone(), + paths.outbox_path.clone(), + paths.private_store_path.clone(), + ] + }) .unwrap_or_default(); Ok(IntegrityReceipt { checked_paths, event_store_ok: event_store_integrity.ok, outbox_ok: outbox_integrity.ok, + private_store_ok: private_store_integrity.ok, event_store_result: event_store_integrity.result, outbox_result: outbox_integrity.result, + private_store_result: private_store_integrity.result, }) } @@ -649,16 +689,22 @@ impl RadrootsClient { let backup_paths = RadrootsSdkStoragePaths { event_store_path: request.destination.join(EVENT_STORE_BACKUP_FILE), outbox_path: request.destination.join(OUTBOX_BACKUP_FILE), + private_store_path: request.destination.join(PRIVATE_STORE_BACKUP_FILE), }; let manifest_backup_paths = RadrootsSdkStoragePaths { event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE), outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE), + private_store_path: PathBuf::from(PRIVATE_STORE_BACKUP_FILE), }; let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE); let source_status = self.storage_status(StorageStatusRequest::new()).await?; - let backup_verification = - backup_sqlite_stores(self._event_store.pool(), self._outbox.pool(), &backup_paths) - .await?; + let backup_verification = backup_sqlite_stores( + self._event_store.pool(), + self._outbox.pool(), + self._private_store.pool(), + &backup_paths, + ) + .await?; let manifest = SdkBackupManifest { manifest_kind: SDK_STORAGE_MANIFEST_KIND, manifest_version: SDK_STORAGE_MANIFEST_VERSION, @@ -715,6 +761,7 @@ impl RadrootsClient { destination_paths: Some(destination_paths), event_store_path: archive.event_store_path, outbox_path: archive.outbox_path, + private_store_path: archive.private_store_path, manifest_path: archive.manifest_path, manifest: archive.manifest, verification: archive.verification, @@ -752,6 +799,20 @@ async fn outbox_sqlite_status( } #[cfg(feature = "runtime")] +async fn private_store_sqlite_status( + private_store: &SdkPrivateStore, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + private_sqlite_store_status( + private_store.pool(), + SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT, + private_store.pragma_journal_mode().await?, + private_store.pragma_foreign_keys().await? != 0, + private_store.pragma_busy_timeout().await?, + ) + .await +} + +#[cfg(feature = "runtime")] async fn event_store_status_summary( event_store: &RadrootsEventStore, ) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> { @@ -770,6 +831,7 @@ async fn outbox_status_summary( async fn backup_sqlite_stores( event_store_pool: &SqlitePool, outbox_pool: &SqlitePool, + private_store_pool: &SqlitePool, backup_paths: &RadrootsSdkStoragePaths, ) -> Result<SdkBackupVerification, RadrootsSdkError> { sqlite_vacuum_into( @@ -779,6 +841,12 @@ async fn backup_sqlite_stores( ) .await?; sqlite_vacuum_into(outbox_pool, &backup_paths.outbox_path, "outbox").await?; + sqlite_vacuum_into( + private_store_pool, + &backup_paths.private_store_path, + "private store", + ) + .await?; verify_backup_paths(backup_paths).await } @@ -795,6 +863,7 @@ fn write_backup_receipt( state: SdkBackupState::Completed, event_store_path: Some(backup_paths.event_store_path), outbox_path: Some(backup_paths.outbox_path), + private_store_path: Some(backup_paths.private_store_path), manifest_path: Some(manifest_path), manifest, }) @@ -848,9 +917,15 @@ async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, Radr )?; let outbox_path = restore_archive_member_path(&source_root, &manifest.backup_paths.outbox_path, "outbox")?; + let private_store_path = restore_archive_member_path( + &source_root, + &manifest.backup_paths.private_store_path, + "private store", + )?; let verification = verify_backup_paths(&RadrootsSdkStoragePaths { event_store_path: event_store_path.clone(), outbox_path: outbox_path.clone(), + private_store_path: private_store_path.clone(), }) .await?; validate_restore_verification(&verification, &manifest.backup_verification)?; @@ -858,6 +933,7 @@ async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, Radr source, event_store_path, outbox_path, + private_store_path, manifest_path, manifest, verification, @@ -969,7 +1045,7 @@ fn validate_restore_verification( actual: &SdkBackupVerification, manifest: &SdkBackupVerification, ) -> Result<(), RadrootsSdkError> { - if !actual.event_store_ok || !actual.outbox_ok { + if !actual.event_store_ok || !actual.outbox_ok || !actual.private_store_ok { return Err(RadrootsSdkError::InvalidRequest { message: "restore backup stores failed integrity checks".to_owned(), }); @@ -1054,6 +1130,7 @@ fn preflight_restore_destination( Ok(RadrootsSdkStoragePaths { event_store_path: destination.join(EVENT_STORE_BACKUP_FILE), outbox_path: destination.join(OUTBOX_BACKUP_FILE), + private_store_path: destination.join(PRIVATE_STORE_BACKUP_FILE), }) } @@ -1090,6 +1167,7 @@ async fn restore_archive_to_destination( let staging_paths = RadrootsSdkStoragePaths { event_store_path: staging.join(EVENT_STORE_BACKUP_FILE), outbox_path: staging.join(OUTBOX_BACKUP_FILE), + private_store_path: staging.join(PRIVATE_STORE_BACKUP_FILE), }; if let Err(error) = copy_restore_archive_to_staging(archive, &staging_paths).await { let _ = remove_existing_restore_path(&staging); @@ -1152,6 +1230,11 @@ async fn copy_restore_archive_to_staging( "event store", )?; copy_restore_file(&archive.outbox_path, &staging_paths.outbox_path, "outbox")?; + copy_restore_file( + &archive.private_store_path, + &staging_paths.private_store_path, + "private store", + )?; let staging_verification = verify_backup_paths(staging_paths).await?; validate_restore_verification(&staging_verification, &archive.verification) } @@ -1265,6 +1348,7 @@ fn rollback_restore_destination(destination: &Path, previous: &Path, previous_in struct OpenedRuntimeStorage { event_store: RadrootsEventStore, outbox: RadrootsOutbox, + private_store: SdkPrivateStore, paths: Option<RadrootsSdkStoragePaths>, } @@ -1276,6 +1360,7 @@ async fn open_storage( RadrootsSdkStorageConfig::Memory => Ok(OpenedRuntimeStorage { event_store: RadrootsEventStore::open_memory().await?, outbox: RadrootsOutbox::open_memory().await?, + private_store: SdkPrivateStore::open_memory().await?, paths: None, }), RadrootsSdkStorageConfig::Directory(path) => open_directory_storage(path).await, @@ -1291,10 +1376,12 @@ async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, Rad let paths = RadrootsSdkStoragePaths { event_store_path: path.join("event_store.sqlite"), outbox_path: path.join("outbox.sqlite"), + private_store_path: path.join("private.sqlite"), }; Ok(OpenedRuntimeStorage { event_store: RadrootsEventStore::open_file(&paths.event_store_path).await?, outbox: RadrootsOutbox::open_file(&paths.outbox_path).await?, + private_store: SdkPrivateStore::open_file(&paths.private_store_path).await?, paths: Some(paths), }) } @@ -1325,6 +1412,25 @@ async fn sqlite_store_status( } #[cfg(feature = "runtime")] +async fn private_sqlite_store_status( + pool: &SqlitePool, + schema_version: i64, + journal_mode: String, + foreign_keys_enabled: bool, + busy_timeout_ms: i64, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + let integrity = private_store_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> { @@ -1342,6 +1448,23 @@ async fn sqlite_integrity_result( } #[cfg(feature = "runtime")] +async fn private_store_sqlite_integrity_result( + pool: &SqlitePool, +) -> Result<SqliteIntegrityResult, RadrootsSdkError> { + let results = sqlx::query_scalar::<_, String>("PRAGMA integrity_check") + .fetch_all(pool) + .await + .map_err(|error| RadrootsSdkError::PrivateStore { + 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() => { @@ -1407,15 +1530,21 @@ async fn verify_backup_paths( ) -> Result<SdkBackupVerification, RadrootsSdkError> { let event_store = RadrootsEventStore::open_file(&paths.event_store_path).await?; let outbox = RadrootsOutbox::open_file(&paths.outbox_path).await?; + let private_store = SdkPrivateStore::open_file(&paths.private_store_path).await?; let event_store_integrity = sqlite_integrity_result(event_store.pool()).await?; let outbox_integrity = sqlite_integrity_result(outbox.pool()).await?; + let private_store_integrity = + private_store_sqlite_integrity_result(private_store.pool()).await?; let event_summary = event_store.status_summary().await?; let outbox_summary = outbox.status_summary(i64::MAX).await?; + let private_summary = private_store.status_summary().await?; Ok(SdkBackupVerification { event_store_ok: event_store_integrity.ok, outbox_ok: outbox_integrity.ok, + private_store_ok: private_store_integrity.ok, event_store_events: event_summary.total_events, outbox_events: outbox_summary.total_events, + private_farm_locations: private_summary.farm_private_locations, }) } diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -14,11 +14,14 @@ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; use radroots_sdk::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest, - PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsClient, - RadrootsSdkError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, - RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, - SdkRelayTargetSet, SdkRelayUrlPolicy, + FarmPrivateLocationUpsertRequest, Geocoder, PushOutboxEventState, PushOutboxRelayOutcomeKind, + PushOutboxRequest, RadrootsClient, RadrootsSdkError, RadrootsSdkErrorClass, + RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationFailure, + RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkExactLocation, SdkIdempotencyKey, + SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, + StorageStatusRequest, }; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; #[path = "support/serializer_failure.rs"] mod serializer_failure; @@ -127,6 +130,70 @@ async fn directory_sdk_with_relays(relays: &[&str]) -> (tempfile::TempDir, Radro (tempdir, sdk) } +async fn fixture_geocoder(tempdir: &tempfile::TempDir) -> Geocoder { + let path = tempdir.path().join("geonames-fixture.db"); + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("geonames fixture pool"); + sqlx::raw_sql( + r#" + CREATE TABLE countries( + id TEXT, + name TEXT, + PRIMARY KEY (id) + ); + CREATE TABLE admin1( + country_id TEXT, + id INTEGER, + name TEXT, + PRIMARY KEY (country_id, id) + ); + CREATE TABLE features( + id INTEGER, + name TEXT, + country_id TEXT, + admin1_id INTEGER, + PRIMARY KEY (id) + ); + CREATE TABLE coordinates( + feature_id INTEGER, + latitude REAL, + longitude REAL, + PRIMARY KEY (feature_id) + ); + CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude); + CREATE VIEW geonames AS + SELECT + features.id, + features.name, + admin1.id AS admin1_id, + admin1.name AS admin1_name, + countries.id AS country_id, + countries.name AS country_name, + coordinates.latitude AS latitude, + coordinates.longitude AS longitude + FROM features + LEFT JOIN countries ON features.country_id = countries.id + LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id + JOIN coordinates ON features.id = coordinates.feature_id; + INSERT INTO countries (id, name) VALUES ('FX', 'Fixture Country'); + INSERT INTO admin1 (country_id, id, name) VALUES ('FX', 1, 'Fixture Region'); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (1, 'Fixture Town', 'FX', 1); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 12.25, -34.50); + "#, + ) + .execute(&pool) + .await + .expect("seed geonames fixture"); + pool.close().await; + Geocoder::open_path(path).expect("open geonames fixture") +} + #[tokio::test] async fn farm_prepare_publish_is_side_effect_free() { let (_tempdir, sdk) = directory_sdk().await; @@ -190,6 +257,147 @@ async fn farm_prepare_publish_rejects_non_farmer_actor() { } #[tokio::test] +async fn farm_private_location_upsert_stores_exact_location_and_public_locality_without_events() { + let (tempdir, sdk) = directory_sdk().await; + let geocoder = fixture_geocoder(&tempdir).await; + let request = FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_A_D_TAG, + SdkExactLocation::new(12.26, -34.51), + ) + .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123)); + + let receipt = sdk + .farms() + .upsert_private_location_with_geocoder(request, &geocoder) + .await + .expect("upsert private location"); + + assert_eq!( + receipt.farm_addr.as_str(), + format!("{KIND_FARM}:{FARMER}:{FARM_A_D_TAG}") + ); + assert_eq!(receipt.farm_pubkey, FARMER); + assert_eq!(receipt.farm_d_tag, FARM_A_D_TAG); + assert_eq!(receipt.exact_location, SdkExactLocation::new(12.26, -34.51)); + assert_eq!(receipt.public_locality.primary, "Fixture Town"); + assert_eq!( + receipt.public_locality.city.as_deref(), + Some("Fixture Town") + ); + assert_eq!( + receipt.public_locality.region.as_deref(), + Some("Fixture Region") + ); + assert_eq!( + receipt.public_locality.country.as_deref(), + Some("Fixture Country") + ); + assert_eq!(receipt.public_locality.geohash5, "e4pmw"); + assert_eq!(receipt.geonames_feature_id, Some(1)); + assert_eq!(receipt.geonames_country_id.as_deref(), Some("FX")); + assert_eq!(receipt.updated_at_ms, 1_700_000_123_000); + let farm_public = receipt.public_locality.to_farm_public_location(); + assert_eq!(farm_public.primary, "Fixture Town"); + assert_eq!(farm_public.geohash, "e4pmw"); + let listing_public = receipt.public_locality.to_listing_public_location(); + assert_eq!(listing_public.primary, "Fixture Town"); + assert_eq!(listing_public.geohash, "e4pmw"); + + let stored = sdk + .farms() + .private_location(&receipt.farm_addr) + .await + .expect("private location read") + .expect("stored private location"); + assert_eq!(stored, receipt); + let status = sdk + .storage_status(StorageStatusRequest::new()) + .await + .expect("status"); + assert_eq!(status.private_store.farm_private_locations, 1); + assert_eq!(status.event_store.total_events, 0); + assert_eq!(status.outbox.total_events, 0); + + let clock_receipt = sdk + .farms() + .upsert_private_location_with_geocoder( + FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_B_D_TAG, + SdkExactLocation::new(12.26, -34.51), + ), + &geocoder, + ) + .await + .expect("upsert with sdk clock"); + assert_eq!(clock_receipt.updated_at_ms, 1_700_000_000_000); +} + +#[tokio::test] +async fn farm_private_location_requires_farmer_role_and_valid_coordinates() { + let (tempdir, sdk) = directory_sdk().await; + let geocoder = fixture_geocoder(&tempdir).await; + let non_farmer = sdk + .farms() + .upsert_private_location_with_geocoder( + FarmPrivateLocationUpsertRequest::new( + non_farmer_actor(), + FARM_B_D_TAG, + SdkExactLocation::new(12.26, -34.51), + ), + &geocoder, + ) + .await + .expect_err("non farmer"); + assert!(matches!( + non_farmer, + RadrootsSdkError::UnauthorizedActor { .. } + )); + + let invalid = sdk + .farms() + .upsert_private_location_with_geocoder( + FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_B_D_TAG, + SdkExactLocation::new(91.0, -34.51), + ), + &geocoder, + ) + .await + .expect_err("invalid coordinates"); + assert!(matches!(invalid, RadrootsSdkError::InvalidRequest { .. })); +} + +#[tokio::test] +async fn farm_private_location_requires_configured_geonames_for_default_upsert() { + let (_tempdir, sdk) = directory_sdk().await; + let error = sdk + .farms() + .upsert_private_location(FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_C_D_TAG, + SdkExactLocation::new(12.26, -34.51), + )) + .await + .expect_err("missing geonames config"); + + assert!(matches!( + error, + RadrootsSdkError::GeoNames { + kind: RadrootsSdkGeoNamesErrorKind::Configuration, + .. + } + )); + assert_eq!(error.class(), RadrootsSdkErrorClass::Configuration); + assert_eq!( + error.recovery_actions(), + vec![RadrootsSdkRecoveryAction::ConfigureGeoNamesCache] + ); +} + +#[tokio::test] async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_profile_event() { let (_tempdir, sdk) = directory_sdk().await; let request = FarmEnqueuePublishRequest::new( diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -5,9 +5,10 @@ use radroots_sdk::{ RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig, RadrootsSdkTimestamp, RestoreRequest, SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkBackupState, SdkBackupVerification, - SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, SdkRelayTargetPolicy, - SdkRelayTargetSet, SdkRelayUrlPolicy, SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, - StorageStatusReceipt, StorageStatusRequest, + SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, + SdkPrivateStoreStorageStatus, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, + SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt, + StorageStatusRequest, }; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::path::{Path, PathBuf}; @@ -149,8 +150,13 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { paths.outbox_path, tempdir.path().join("sdk-runtime").join("outbox.sqlite") ); + assert_eq!( + paths.private_store_path, + tempdir.path().join("sdk-runtime").join("private.sqlite") + ); assert!(paths.event_store_path.exists()); assert!(paths.outbox_path.exists()); + assert!(paths.private_store_path.exists()); let event_tables = sqlite_table_names(&paths.event_store_path).await; assert!(event_tables.iter().any(|name| name == "nostr_events")); assert!(event_tables.iter().any(|name| name == "nostr_event_tags")); @@ -162,6 +168,12 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { let outbox_tables = sqlite_table_names(&paths.outbox_path).await; assert!(outbox_tables.iter().any(|name| name == "outbox_operations")); assert!(!outbox_tables.iter().any(|name| name == "outbox_operation")); + let private_tables = sqlite_table_names(&paths.private_store_path).await; + assert!( + private_tables + .iter() + .any(|name| name == "sdk_private_farm_location") + ); } #[tokio::test] @@ -180,13 +192,17 @@ async fn sdk_memory_storage_status_and_integrity_report_canonical_stores() { assert_eq!(status.paths, None); assert_eq!(status.event_store.store.schema_version, 1); assert_eq!(status.outbox.store.schema_version, 1); + assert_eq!(status.private_store.store.schema_version, 1); assert!(status.event_store.store.foreign_keys_enabled); assert!(status.outbox.store.foreign_keys_enabled); + assert!(status.private_store.store.foreign_keys_enabled); assert_eq!(status.event_store.total_events, 0); assert_eq!(status.outbox.total_events, 0); + assert_eq!(status.private_store.farm_private_locations, 0); assert_eq!(status.outbox.failed_terminal_events, 0); assert!(status.event_store.store.integrity_ok); assert!(status.outbox.store.integrity_ok); + assert!(status.private_store.store.integrity_ok); let integrity = sdk .integrity(IntegrityRequest::new()) @@ -195,8 +211,10 @@ async fn sdk_memory_storage_status_and_integrity_report_canonical_stores() { assert!(integrity.checked_paths.is_empty()); assert!(integrity.event_store_ok); assert!(integrity.outbox_ok); + assert!(integrity.private_store_ok); assert_eq!(integrity.event_store_result, "ok"); assert_eq!(integrity.outbox_result, "ok"); + assert_eq!(integrity.private_store_result, "ok"); } #[tokio::test] @@ -658,7 +676,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { last_event_updated_at_ms: Some(1_700_000_000_000), }, outbox: SdkOutboxStorageStatus { - store, + store: store.clone(), total_events: 3, pending_events: 1, retryable_events: 1, @@ -669,6 +687,10 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { last_attempt_at_ms: Some(1_700_000_000_000), last_error: Some("relay publish incomplete".to_owned()), }, + private_store: SdkPrivateStoreStorageStatus { + store, + farm_private_locations: 4, + }, }) .expect("status receipt"), serde_json::json!({ @@ -707,6 +729,17 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { "publishing_events": 0, "last_attempt_at_ms": 1700000000000i64, "last_error": "relay publish incomplete" + }, + "private_store": { + "store": { + "schema_version": 1, + "journal_mode": "wal", + "foreign_keys_enabled": true, + "busy_timeout_ms": 5000, + "integrity_ok": true, + "integrity_result": "ok" + }, + "farm_private_locations": 4 } }) ); @@ -744,15 +777,19 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { serde_json::to_value(SdkBackupVerification { event_store_ok: true, outbox_ok: true, + private_store_ok: true, event_store_events: 2, outbox_events: 3, + private_farm_locations: 4, }) .expect("backup verification"), serde_json::json!({ "event_store_ok": true, "outbox_ok": true, + "private_store_ok": true, "event_store_events": 2, - "outbox_events": 3 + "outbox_events": 3, + "private_farm_locations": 4 }) ); assert_eq!( diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -603,11 +603,13 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { integrity.checked_paths, vec![ source_paths.event_store_path.clone(), - source_paths.outbox_path.clone() + source_paths.outbox_path.clone(), + source_paths.private_store_path.clone() ] ); assert!(integrity.event_store_ok); assert!(integrity.outbox_ok); + assert!(integrity.private_store_ok); let backup_destination = tempdir.path().join("backup"); let backup = sdk @@ -619,9 +621,14 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { .as_ref() .expect("event store backup"); let outbox_path = backup.outbox_path.as_ref().expect("outbox backup"); + let private_store_path = backup + .private_store_path + .as_ref() + .expect("private store backup"); let manifest_path = backup.manifest_path.as_ref().expect("manifest"); assert!(event_store_path.exists()); assert!(outbox_path.exists()); + assert!(private_store_path.exists()); assert!(manifest_path.exists()); assert_eq!( backup.manifest.manifest_kind, @@ -635,11 +642,28 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { backup.manifest.backup_paths.outbox_path, PathBuf::from("outbox.sqlite") ); + assert_eq!( + backup.manifest.backup_paths.private_store_path, + PathBuf::from("private.sqlite") + ); 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_eq!( + backup + .manifest + .source_status + .private_store + .farm_private_locations, + 0 + ); assert!(backup.manifest.backup_verification.event_store_ok); assert!(backup.manifest.backup_verification.outbox_ok); + assert!(backup.manifest.backup_verification.private_store_ok); + assert_eq!( + backup.manifest.backup_verification.private_farm_locations, + 0 + ); let restore_archive = RadrootsClient::inspect_restore_archive(backup_destination.clone()) .await @@ -657,6 +681,12 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { restore_archive.outbox_path, outbox_path.canonicalize().expect("outbox canonical") ); + assert_eq!( + restore_archive.private_store_path, + private_store_path + .canonicalize() + .expect("private store canonical") + ); let backup_event_store = RadrootsEventStore::open_file(event_store_path) .await @@ -738,6 +768,13 @@ async fn runtime_backup_rejects_empty_destination_and_overwrites_file_destinatio .exists() ); assert!(receipt.outbox_path.as_ref().expect("outbox").exists()); + assert!( + receipt + .private_store_path + .as_ref() + .expect("private store") + .exists() + ); } #[cfg(unix)] diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -139,6 +139,39 @@ fn farm_request_builders_reject_invalid_options_and_timestamp_bounds() { )); } +#[test] +fn farm_public_locality_derivation_covers_country_fallback_and_empty_names() { + let reverse = GeocoderReverseResult { + id: 1, + name: " Fixture Town ".to_owned(), + admin1_id: None, + admin1_name: None, + country_id: "FX".to_owned(), + country_name: None, + latitude: 12.25, + longitude: -34.50, + }; + let locality = public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &reverse) + .expect("locality"); + assert_eq!(locality.primary, "Fixture Town"); + assert_eq!(locality.city.as_deref(), Some("Fixture Town")); + assert_eq!(locality.region, None); + assert_eq!(locality.country.as_deref(), Some("FX")); + assert_eq!(locality.geohash5, "e4pmw"); + + let blank_name = GeocoderReverseResult { + name: " ".to_owned(), + ..reverse + }; + assert!(matches!( + public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &blank_name), + Err(RadrootsSdkError::GeoNames { + kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, + .. + }) + )); +} + #[tokio::test] async fn farm_client_prepare_resolves_default_and_explicit_created_at() { let sdk = crate::RadrootsClient::builder() diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -31,6 +31,14 @@ fn assert_outbox_error<T>(result: Result<T, RadrootsSdkError>) { } } +fn assert_private_store_error<T>(result: Result<T, RadrootsSdkError>) { + match result { + Err(RadrootsSdkError::PrivateStore { .. }) => {} + Err(other) => panic!("expected private store error, got {other:?}"), + Ok(_) => panic!("expected private store error"), + } +} + fn sqlite_status() -> SdkSqliteStoreStatus { SdkSqliteStoreStatus { schema_version: 1, @@ -66,6 +74,10 @@ fn storage_status() -> StorageStatusReceipt { last_attempt_at_ms: None, last_error: None, }, + private_store: SdkPrivateStoreStorageStatus { + store: sqlite_status(), + farm_private_locations: 0, + }, } } @@ -73,8 +85,10 @@ fn verification(event_store_ok: bool, outbox_ok: bool) -> SdkBackupVerification SdkBackupVerification { event_store_ok, outbox_ok, + private_store_ok: true, event_store_events: 0, outbox_events: 0, + private_farm_locations: 0, } } @@ -112,12 +126,75 @@ fn manifest() -> SdkBackupManifest { backup_paths: RadrootsSdkStoragePaths { event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE), outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE), + private_store_path: PathBuf::from(PRIVATE_STORE_BACKUP_FILE), }, source_status: storage_status(), backup_verification: verification(true, true), } } +fn private_farm_location_record() -> crate::private_store::SdkPrivateFarmLocationRecord { + crate::private_store::SdkPrivateFarmLocationRecord { + farm_addr: radroots_events::ids::RadrootsAddressableCoordinate::parse(format!( + "{}:{}:{}", + radroots_events::kinds::KIND_FARM, + "a".repeat(64), + "AAAAAAAAAAAAAAAAAAAAAA" + )) + .expect("farm addr"), + farm_pubkey: "a".repeat(64), + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + latitude: 12.26, + longitude: -34.51, + locality_primary: "Fixture Town".to_owned(), + locality_city: Some("Fixture Town".to_owned()), + locality_region: Some("Fixture Region".to_owned()), + locality_country: Some("Fixture Country".to_owned()), + geohash5: "e4pmw".to_owned(), + geonames_feature_id: Some(1), + geonames_country_id: Some("FX".to_owned()), + updated_at_ms: 1_700_000_123_000, + } +} + +#[tokio::test] +async fn private_store_validates_location_rows_and_round_trips_valid_records() { + let store = SdkPrivateStore::open_memory().await.expect("private store"); + let record = private_farm_location_record(); + store + .upsert_farm_location(&record) + .await + .expect("valid private farm location"); + assert_eq!( + store + .farm_location(&record.farm_addr) + .await + .expect("lookup"), + Some(record.clone()) + ); + + let mut invalid_coordinates = record.clone(); + invalid_coordinates.latitude = f64::NAN; + assert!(matches!( + store.upsert_farm_location(&invalid_coordinates).await, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + let mut blank_locality = record.clone(); + blank_locality.locality_primary = " ".to_owned(); + assert!(matches!( + store.upsert_farm_location(&blank_locality).await, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + let mut invalid_geohash = record; + invalid_geohash.geohash5 = "abcd".to_owned(); + assert!(matches!( + store.upsert_farm_location(&invalid_geohash).await, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} + #[tokio::test] async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures() { let memory = open_storage(&RadrootsSdkStorageConfig::Memory) @@ -127,6 +204,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( let memory_sdk = RadrootsClient { _event_store: memory.event_store, _outbox: memory.outbox, + _private_store: memory.private_store, storage_paths: None, geonames: None, clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), @@ -145,9 +223,11 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( let directory_paths = directory_storage.paths.expect("directory paths"); assert!(directory_paths.event_store_path.exists()); assert!(directory_paths.outbox_path.exists()); + assert!(directory_paths.private_store_path.exists()); let directory_sdk = RadrootsClient { _event_store: directory_storage.event_store, _outbox: directory_storage.outbox, + _private_store: directory_storage.private_store, storage_paths: Some(directory_paths), geonames: None, clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), @@ -172,6 +252,12 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( fs::create_dir(&outbox_directory).expect("outbox dir"); fs::create_dir(outbox_directory.join(OUTBOX_BACKUP_FILE)).expect("outbox file slot dir"); assert_outbox_error(open_directory_storage(&outbox_directory).await); + + let private_store_directory = tempdir.path().join("private-store-directory"); + fs::create_dir(&private_store_directory).expect("private store dir"); + fs::create_dir(private_store_directory.join(PRIVATE_STORE_BACKUP_FILE)) + .expect("private store file slot dir"); + assert_private_store_error(open_directory_storage(&private_store_directory).await); } #[tokio::test] @@ -855,11 +941,13 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { let backup_paths = RadrootsSdkStoragePaths { event_store_path: tempdir.path().join("closed-event-store-backup.sqlite"), outbox_path: tempdir.path().join("closed-event-store-outbox.sqlite"), + private_store_path: tempdir.path().join("closed-event-store-private.sqlite"), }; assert_event_store_error( backup_sqlite_stores( storage.event_store.pool(), storage.outbox.pool(), + storage.private_store.pool(), &backup_paths, ) .await, @@ -872,11 +960,13 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { let outbox_closed_paths = RadrootsSdkStoragePaths { event_store_path: tempdir.path().join("open-event-store-backup.sqlite"), outbox_path: tempdir.path().join("closed-outbox-backup.sqlite"), + private_store_path: tempdir.path().join("outbox-closed-private.sqlite"), }; assert_event_store_error( backup_sqlite_stores( outbox_closed_storage.event_store.pool(), outbox_closed_storage.outbox.pool(), + outbox_closed_storage.private_store.pool(), &outbox_closed_paths, ) .await, @@ -887,6 +977,7 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { RadrootsSdkStoragePaths { event_store_path: tempdir.path().join(EVENT_STORE_BACKUP_FILE), outbox_path: tempdir.path().join(OUTBOX_BACKUP_FILE), + private_store_path: tempdir.path().join(PRIVATE_STORE_BACKUP_FILE), }, tempdir.path().to_path_buf(), manifest(), @@ -925,6 +1016,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() source: tempdir.path().join("missing-archive"), event_store_path: tempdir.path().join("missing-event-store.sqlite"), outbox_path: tempdir.path().join("missing-outbox.sqlite"), + private_store_path: tempdir.path().join("missing-private.sqlite"), manifest_path: tempdir.path().join(BACKUP_MANIFEST_FILE), manifest: manifest(), verification: verification(true, true), @@ -932,6 +1024,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let staging_paths = RadrootsSdkStoragePaths { event_store_path: tempdir.path().join("staging-event-store.sqlite"), outbox_path: tempdir.path().join("staging-outbox.sqlite"), + private_store_path: tempdir.path().join("staging-private.sqlite"), }; assert!( io_message(copy_restore_archive_to_staging(&missing_archive, &staging_paths).await) @@ -949,10 +1042,12 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let corrupt_archive = RestoreArchive { event_store_path: tempdir.path().join("corrupt-event-store.sqlite"), outbox_path: tempdir.path().join("corrupt-outbox.sqlite"), + private_store_path: tempdir.path().join("corrupt-private.sqlite"), ..missing_archive.clone() }; fs::write(&corrupt_archive.event_store_path, b"not sqlite").expect("corrupt event store"); fs::write(&corrupt_archive.outbox_path, b"not sqlite").expect("corrupt outbox"); + fs::write(&corrupt_archive.private_store_path, b"not sqlite").expect("corrupt private store"); assert_event_store_error( copy_restore_archive_to_staging(&corrupt_archive, &staging_paths).await, ); @@ -988,6 +1083,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let protected_paths = RadrootsSdkStoragePaths { event_store_path: protected_destination.join(EVENT_STORE_BACKUP_FILE), outbox_path: protected_destination.join(OUTBOX_BACKUP_FILE), + private_store_path: protected_destination.join(PRIVATE_STORE_BACKUP_FILE), }; set_mode(&protected_parent, 0o500); let protected_result = @@ -1022,6 +1118,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let missing_outbox_paths = RadrootsSdkStoragePaths { event_store_path: archive.event_store_path.clone(), outbox_path: tempdir.path().to_path_buf(), + private_store_path: archive.private_store_path.clone(), }; assert!(verify_backup_paths(&missing_outbox_paths).await.is_err()); @@ -1029,6 +1126,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let invalid_paths = RadrootsSdkStoragePaths { event_store_path: invalid_destination.join(EVENT_STORE_BACKUP_FILE), outbox_path: invalid_destination.join(OUTBOX_BACKUP_FILE), + private_store_path: invalid_destination.join(PRIVATE_STORE_BACKUP_FILE), }; let invalid_restore_message = io_message( restore_archive_to_destination(&archive, &invalid_destination, &invalid_paths).await, @@ -1101,6 +1199,7 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let wrong_destination_paths = RadrootsSdkStoragePaths { event_store_path: populated_archive.event_store_path.clone(), outbox_path: populated_archive.outbox_path.clone(), + private_store_path: populated_archive.private_store_path.clone(), }; assert!( invalid_request_message(