sdk

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

commit 2dad39727858c557d0f10d0d3c43c2b0ca581208
parent f0f142265096cb8da7e2ede732e7c599bd243bdb
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 23:26:43 +0000

sdk: add forward farm location set

- add SDK-owned exact, locality query, and GeoNames id farm-location set results
- persist local-only private location labels in the private store schema
- cover forward lookup, ambiguity, no-match, and label storage paths

Diffstat:
Mcrates/sdk/src/farms_runtime.rs | 341++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/src/geonames.rs | 5+++--
Mcrates/sdk/src/lib.rs | 11+++++++----
Mcrates/sdk/src/private_store.rs | 45+++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/farms_runtime.rs | 214++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/runtime_foundation.rs | 10+++++++---
Mcrates/sdk/tests/unit/private_store_tests.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/runtime_tests.rs | 10+++++++++-
8 files changed, 686 insertions(+), 21 deletions(-)

diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -4,7 +4,10 @@ use crate::workflow_runtime::enqueue_configured_signed_workflow; use crate::{ FarmsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, farm, - geonames::{Geocoder, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult}, + geonames::{ + Geocoder, GeocoderLocalityCandidate, GeocoderLocalityLookup, GeocoderLocalityQuery, + GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, + }, private_store::SdkPrivateFarmLocationRecord, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; @@ -199,6 +202,7 @@ pub struct FarmPrivateLocationUpsertRequest { pub actor: RadrootsActorContext, pub farm_d_tag: String, pub exact_location: SdkExactLocation, + pub label: Option<String>, pub updated_at: Option<RadrootsSdkTimestamp>, } @@ -213,10 +217,118 @@ impl FarmPrivateLocationUpsertRequest { actor, farm_d_tag: farm_d_tag.into(), exact_location, + label: None, updated_at: None, } } + pub fn with_label(mut self, label: impl Into<String>) -> Self { + self.label = Some(label.into()); + self + } + + 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)] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +pub enum FarmPrivateLocationInput { + Exact(SdkExactLocation), + Locality(GeocoderLocalityQuery), +} + +#[cfg(feature = "runtime")] +impl FarmPrivateLocationInput { + pub fn exact(location: SdkExactLocation) -> Self { + Self::Exact(location) + } + + pub fn city(city: impl Into<String>) -> Self { + Self::Locality(GeocoderLocalityQuery::structured(city)) + } + + pub fn query(query: impl Into<String>) -> Self { + Self::Locality(GeocoderLocalityQuery::query(query)) + } + + pub fn geonames_id(id: i64) -> Self { + Self::Locality(GeocoderLocalityQuery::feature_id(id)) + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct FarmPrivateLocationSetRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub farm_d_tag: String, + pub input: FarmPrivateLocationInput, + pub label: Option<String>, + pub updated_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl FarmPrivateLocationSetRequest { + pub fn new( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + input: FarmPrivateLocationInput, + ) -> Self { + Self { + actor, + farm_d_tag: farm_d_tag.into(), + input, + label: None, + updated_at: None, + } + } + + pub fn exact( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + exact_location: SdkExactLocation, + ) -> Self { + Self::new( + actor, + farm_d_tag, + FarmPrivateLocationInput::exact(exact_location), + ) + } + + pub fn city( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + city: impl Into<String>, + ) -> Self { + Self::new(actor, farm_d_tag, FarmPrivateLocationInput::city(city)) + } + + pub fn query( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + query: impl Into<String>, + ) -> Self { + Self::new(actor, farm_d_tag, FarmPrivateLocationInput::query(query)) + } + + pub fn geonames_id( + actor: RadrootsActorContext, + farm_d_tag: impl Into<String>, + id: i64, + ) -> Self { + Self::new(actor, farm_d_tag, FarmPrivateLocationInput::geonames_id(id)) + } + + pub fn with_label(mut self, label: impl Into<String>) -> Self { + self.label = Some(label.into()); + self + } + pub fn with_updated_at(mut self, updated_at: RadrootsSdkTimestamp) -> Self { self.updated_at = Some(updated_at); self @@ -255,6 +367,7 @@ pub struct FarmPrivateLocationReceipt { pub farm_addr: RadrootsAddressableCoordinate, pub farm_pubkey: String, pub farm_d_tag: String, + pub label: Option<String>, pub exact_location: SdkExactLocation, pub public_locality: SdkPublicLocality, pub geonames_feature_id: Option<i64>, @@ -263,6 +376,37 @@ pub struct FarmPrivateLocationReceipt { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FarmPrivateLocationLookupCandidate { + pub geonames_feature_id: i64, + pub geonames_country_id: String, + pub name: String, + pub display_name: String, + pub exact_location: SdkExactLocation, + pub region: Option<String>, + pub country: Option<String>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FarmPrivateLocationLookupReceipt { + pub farm_addr: RadrootsAddressableCoordinate, + pub farm_pubkey: String, + pub farm_d_tag: String, + pub input: FarmPrivateLocationInput, + pub candidates: Vec<FarmPrivateLocationLookupCandidate>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case", tag = "status", content = "receipt")] +pub enum FarmPrivateLocationSetResult { + Stored(FarmPrivateLocationReceipt), + NoMatch(FarmPrivateLocationLookupReceipt), + Ambiguous(FarmPrivateLocationLookupReceipt), +} + +#[cfg(feature = "runtime")] impl<'sdk> FarmsClient<'sdk> { pub fn prepare_publish( &self, @@ -376,6 +520,47 @@ impl<'sdk> FarmsClient<'sdk> { .await } + pub async fn set_private_location( + &self, + request: FarmPrivateLocationSetRequest, + ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { + let geocoder = self.sdk.geonames().open_verified()?; + self.set_private_location_with_geocoder(request, &geocoder) + .await + } + + pub async fn set_private_location_with_geocoder( + &self, + request: FarmPrivateLocationSetRequest, + geocoder: &Geocoder, + ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { + match request.input { + FarmPrivateLocationInput::Exact(exact_location) => { + let upsert = FarmPrivateLocationUpsertRequest { + actor: request.actor, + farm_d_tag: request.farm_d_tag, + exact_location, + label: request.label, + updated_at: request.updated_at, + }; + self.upsert_private_location_with_geocoder(upsert, geocoder) + .await + .map(FarmPrivateLocationSetResult::Stored) + } + FarmPrivateLocationInput::Locality(locality_query) => { + self.set_private_location_from_locality( + request.actor, + request.farm_d_tag, + locality_query, + request.label, + request.updated_at, + geocoder, + ) + .await + } + } + } + pub async fn upsert_private_location_with_geocoder( &self, request: FarmPrivateLocationUpsertRequest, @@ -383,6 +568,7 @@ impl<'sdk> FarmsClient<'sdk> { ) -> Result<FarmPrivateLocationReceipt, RadrootsSdkError> { require_farmer_actor(&request.actor, FARM_PRIVATE_LOCATION_OPERATION)?; validate_exact_location(request.exact_location)?; + let label = normalize_private_location_label(request.label)?; let updated_at_ms = match request.updated_at { Some(updated_at) => sdk_timestamp_ms(updated_at)?, None => crate::runtime::sdk_now_ms(self.sdk)?, @@ -394,6 +580,7 @@ impl<'sdk> FarmsClient<'sdk> { farm_addr: farm_addr.clone(), farm_pubkey: request.actor.pubkey().as_str().to_owned(), farm_d_tag: request.farm_d_tag, + label, latitude: request.exact_location.latitude, longitude: request.exact_location.longitude, locality_primary: public_locality.primary.clone(), @@ -412,6 +599,76 @@ impl<'sdk> FarmsClient<'sdk> { Ok(private_location_receipt_from_record(record)) } + async fn set_private_location_from_locality( + &self, + actor: RadrootsActorContext, + farm_d_tag: String, + locality_query: GeocoderLocalityQuery, + label: Option<String>, + updated_at: Option<RadrootsSdkTimestamp>, + geocoder: &Geocoder, + ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { + require_farmer_actor(&actor, FARM_PRIVATE_LOCATION_OPERATION)?; + let label = normalize_private_location_label(label)?; + let farm_addr = farm_addr(&actor, farm_d_tag.as_str())?; + let lookup = geocoder.locality(&locality_query)?; + match lookup { + GeocoderLocalityLookup::Unique { candidate } => { + let exact_location = exact_location_from_locality_candidate(&candidate); + validate_exact_location(exact_location)?; + let public_locality = public_locality_from_candidate(exact_location, &candidate)?; + let updated_at_ms = match updated_at { + Some(updated_at) => sdk_timestamp_ms(updated_at)?, + None => crate::runtime::sdk_now_ms(self.sdk)?, + }; + let record = SdkPrivateFarmLocationRecord { + farm_addr: farm_addr.clone(), + farm_pubkey: actor.pubkey().as_str().to_owned(), + farm_d_tag, + label, + latitude: exact_location.latitude, + longitude: 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(candidate.id), + geonames_country_id: Some(candidate.country_id.clone()), + updated_at_ms, + }; + self.sdk + ._private_store + .upsert_farm_location(&record) + .await?; + Ok(FarmPrivateLocationSetResult::Stored( + private_location_receipt_from_record(record), + )) + } + GeocoderLocalityLookup::NoMatch => Ok(FarmPrivateLocationSetResult::NoMatch( + farm_private_location_lookup_receipt( + farm_addr, + actor.pubkey().as_str(), + farm_d_tag, + FarmPrivateLocationInput::Locality(locality_query), + Vec::new(), + ), + )), + GeocoderLocalityLookup::Ambiguous { candidates } => Ok( + FarmPrivateLocationSetResult::Ambiguous(farm_private_location_lookup_receipt( + farm_addr, + actor.pubkey().as_str(), + farm_d_tag, + FarmPrivateLocationInput::Locality(locality_query), + candidates + .into_iter() + .map(farm_private_location_candidate) + .collect(), + )), + ), + } + } + pub async fn private_location( &self, farm_addr: &RadrootsAddressableCoordinate, @@ -590,6 +847,51 @@ fn public_locality_from_reverse( } #[cfg(feature = "runtime")] +fn public_locality_from_candidate( + exact_location: SdkExactLocation, + candidate: &GeocoderLocalityCandidate, +) -> Result<SdkPublicLocality, RadrootsSdkError> { + let primary = required_public_string(candidate.name.as_str(), "GeoNames locality name")?; + let country = optional_public_string(candidate.country_name.as_deref()) + .or_else(|| Some(candidate.country_id.clone())); + Ok(SdkPublicLocality { + primary: primary.clone(), + city: Some(primary), + region: optional_public_string(candidate.admin1_name.as_deref()), + country, + geohash5: geohash5(exact_location)?, + }) +} + +#[cfg(feature = "runtime")] +fn exact_location_from_locality_candidate( + candidate: &GeocoderLocalityCandidate, +) -> SdkExactLocation { + SdkExactLocation { + latitude: candidate.point.lat, + longitude: candidate.point.lng, + } +} + +#[cfg(feature = "runtime")] +fn normalize_private_location_label( + label: Option<String>, +) -> Result<Option<String>, RadrootsSdkError> { + match label { + Some(label) => { + let label = label.trim(); + if label.is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm private location label must not be empty".to_owned(), + }); + } + Ok(Some(label.to_owned())) + } + None => Ok(None), + } +} + +#[cfg(feature = "runtime")] fn required_public_string(value: &str, label: &str) -> Result<String, RadrootsSdkError> { let trimmed = value.trim(); if trimmed.is_empty() { @@ -658,6 +960,7 @@ fn private_location_receipt_from_record( farm_addr: record.farm_addr, farm_pubkey: record.farm_pubkey, farm_d_tag: record.farm_d_tag, + label: record.label, exact_location: SdkExactLocation { latitude: record.latitude, longitude: record.longitude, @@ -675,6 +978,42 @@ fn private_location_receipt_from_record( } } +#[cfg(feature = "runtime")] +fn farm_private_location_lookup_receipt( + farm_addr: RadrootsAddressableCoordinate, + farm_pubkey: &str, + farm_d_tag: String, + input: FarmPrivateLocationInput, + candidates: Vec<FarmPrivateLocationLookupCandidate>, +) -> FarmPrivateLocationLookupReceipt { + FarmPrivateLocationLookupReceipt { + farm_addr, + farm_pubkey: farm_pubkey.to_owned(), + farm_d_tag, + input, + candidates, + } +} + +#[cfg(feature = "runtime")] +fn farm_private_location_candidate( + candidate: GeocoderLocalityCandidate, +) -> FarmPrivateLocationLookupCandidate { + let exact_location = exact_location_from_locality_candidate(&candidate); + let region = optional_public_string(candidate.admin1_name.as_deref()); + let country = optional_public_string(candidate.country_name.as_deref()) + .or_else(|| Some(candidate.country_id.clone())); + FarmPrivateLocationLookupCandidate { + geonames_feature_id: candidate.id, + geonames_country_id: candidate.country_id, + name: candidate.name, + display_name: candidate.display_name, + exact_location, + region, + country, + } +} + #[cfg(all(test, feature = "runtime"))] #[path = "../tests/unit/farms_runtime_tests.rs"] mod tests; diff --git a/crates/sdk/src/geonames.rs b/crates/sdk/src/geonames.rs @@ -7,8 +7,9 @@ pub use radroots_geocoder::{ GEONAMES_1_0_ASSET, GEONAMES_ASSET_BYTE_SIZE, GEONAMES_ASSET_FILE_NAME, GEONAMES_ASSET_HOST, GEONAMES_ASSET_SHA256, GEONAMES_ASSET_URL, GEONAMES_ASSET_VERSION, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, GeoNamesAssetStatus, GeoNamesBlockingHttpFetcher, - Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions, - GeocoderReverseResult, + Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderLocalityCandidate, + GeocoderLocalityInput, GeocoderLocalityLookup, GeocoderLocalityQuery, GeocoderPoint, + GeocoderReverseOptions, GeocoderReverseResult, GeocoderStructuredLocalityQuery, }; use radroots_geocoder::{ ensure_default_geonames_asset_in_cache_root, ensure_geonames_asset_in_cache_root_with_fetcher, diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -70,16 +70,19 @@ pub use crate::error::{ pub use crate::farms_runtime::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmEnqueueReceipt, FarmPreparePublishRequest, FarmPrivateLocationClearReceipt, FarmPrivateLocationClearRequest, - FarmPrivateLocationReceipt, FarmPrivateLocationUpsertRequest, FarmPublishPlan, - SdkExactLocation, SdkPublicLocality, + FarmPrivateLocationInput, FarmPrivateLocationLookupCandidate, FarmPrivateLocationLookupReceipt, + FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, FarmPrivateLocationSetResult, + FarmPrivateLocationUpsertRequest, FarmPublishPlan, SdkExactLocation, SdkPublicLocality, }; #[cfg(feature = "runtime")] pub use crate::geonames::{ GEONAMES_1_0_ASSET, GEONAMES_ASSET_BYTE_SIZE, GEONAMES_ASSET_FILE_NAME, GEONAMES_ASSET_HOST, GEONAMES_ASSET_SHA256, GEONAMES_ASSET_URL, GEONAMES_ASSET_VERSION, GeoNamesAssetFetcher, GeoNamesAssetSpec, GeoNamesAssetState, GeoNamesAssetStatus, GeoNamesBlockingHttpFetcher, - Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderPoint, GeocoderReverseOptions, - GeocoderReverseResult, RadrootsGeoNamesConfig, + Geocoder, GeocoderCountryListResult, GeocoderError, GeocoderLocalityCandidate, + GeocoderLocalityInput, GeocoderLocalityLookup, GeocoderLocalityQuery, GeocoderPoint, + GeocoderReverseOptions, GeocoderReverseResult, GeocoderStructuredLocalityQuery, + RadrootsGeoNamesConfig, }; #[cfg(feature = "runtime")] pub use crate::idempotency::{SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey}; diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -7,13 +7,14 @@ use sqlx::{Row, SqlitePool}; use std::path::Path; use std::str::FromStr; -pub(crate) const SDK_PRIVATE_STORE_SCHEMA_VERSION: i64 = 1; +pub(crate) const SDK_PRIVATE_STORE_SCHEMA_VERSION: i64 = 2; 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, + label TEXT, 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, @@ -39,6 +40,7 @@ pub(crate) struct SdkPrivateFarmLocationRecord { pub farm_addr: RadrootsAddressableCoordinate, pub farm_pubkey: String, pub farm_d_tag: String, + pub label: Option<String>, pub latitude: f64, pub longitude: f64, pub locality_primary: String, @@ -121,6 +123,7 @@ impl SdkPrivateStore { farm_addr, farm_pubkey, farm_d_tag, + label, latitude, longitude, locality_primary, @@ -131,10 +134,11 @@ impl SdkPrivateStore { geonames_feature_id, geonames_country_id, updated_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(farm_addr) DO UPDATE SET farm_pubkey = excluded.farm_pubkey, farm_d_tag = excluded.farm_d_tag, + label = excluded.label, latitude = excluded.latitude, longitude = excluded.longitude, locality_primary = excluded.locality_primary, @@ -150,6 +154,7 @@ impl SdkPrivateStore { .bind(record.farm_addr.as_str()) .bind(record.farm_pubkey.as_str()) .bind(record.farm_d_tag.as_str()) + .bind(record.label.as_deref()) .bind(record.latitude) .bind(record.longitude) .bind(record.locality_primary.as_str()) @@ -176,6 +181,7 @@ impl SdkPrivateStore { farm_addr, farm_pubkey, farm_d_tag, + label, latitude, longitude, locality_primary, @@ -241,8 +247,29 @@ async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { sqlx::raw_sql(PRIVATE_STORE_MIGRATION_UP) .execute(pool) .await - .map(|_| ()) - .map_err(private_store_error) + .map_err(private_store_error)?; + ensure_farm_location_label_column(pool).await +} + +async fn ensure_farm_location_label_column(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { + let rows = sqlx::query("PRAGMA table_info(sdk_private_farm_location)") + .fetch_all(pool) + .await + .map_err(private_store_error)?; + let has_label = rows + .iter() + .map(|row| row.try_get::<String, _>("name")) + .collect::<Result<Vec<_>, _>>() + .map_err(private_store_error)? + .iter() + .any(|name| name == "label"); + if !has_label { + sqlx::query("ALTER TABLE sdk_private_farm_location ADD COLUMN label TEXT") + .execute(pool) + .await + .map_err(private_store_error)?; + } + Ok(()) } async fn query_i64(pool: &SqlitePool, sql: &str) -> Result<i64, RadrootsSdkError> { @@ -269,6 +296,7 @@ fn private_farm_location_from_row( 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)?, + label: row.try_get("label").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 @@ -315,6 +343,15 @@ fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), message: "farm public locality geohash must be precision 5".to_owned(), }); } + if record + .label + .as_deref() + .is_some_and(|label| label.trim().is_empty()) + { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm private location label must not be empty".to_owned(), + }); + } Ok(()) } diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -8,18 +8,20 @@ use radroots_events::{ contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts}, farm::RadrootsFarm, + ids::RadrootsAddressableCoordinate, kinds::{KIND_FARM, KIND_PROFILE}, }; use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; use radroots_sdk::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest, - FarmPrivateLocationUpsertRequest, Geocoder, PushOutboxEventState, PushOutboxRelayOutcomeKind, - PushOutboxRequest, RadrootsClient, RadrootsSdkError, RadrootsSdkErrorClass, - RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationFailure, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkExactLocation, SdkIdempotencyKey, - SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, - StorageStatusRequest, + FarmPrivateLocationInput, FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, + FarmPrivateLocationSetResult, FarmPrivateLocationUpsertRequest, Geocoder, + GeocoderLocalityQuery, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, + RadrootsClient, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, + RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, + SdkExactLocation, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, + SdkRelayUrlPolicy, StorageStatusRequest, }; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -114,6 +116,18 @@ fn farm(d_tag: &str, name: &str) -> RadrootsFarm { } } +fn farm_addr(actor: &RadrootsActorContext, d_tag: &str) -> RadrootsAddressableCoordinate { + RadrootsAddressableCoordinate::parse(format!("{KIND_FARM}:{}:{d_tag}", actor.pubkey())) + .expect("farm addr") +} + +fn stored_location(result: FarmPrivateLocationSetResult) -> FarmPrivateLocationReceipt { + let FarmPrivateLocationSetResult::Stored(receipt) = result else { + panic!("expected stored location"); + }; + receipt +} + async fn directory_sdk() -> (tempfile::TempDir, RadrootsClient) { directory_sdk_with_relays(&[RELAY]).await } @@ -182,9 +196,24 @@ async fn fixture_geocoder(tempdir: &tempfile::TempDir) -> Geocoder { 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 countries (id, name) VALUES ('CA', 'Canada'); + INSERT INTO countries (id, name) VALUES ('US', 'United States'); INSERT INTO admin1 (country_id, id, name) VALUES ('FX', 1, 'Fixture Region'); + INSERT INTO admin1 (country_id, id, name) VALUES ('CA', 2, 'British Columbia'); + INSERT INTO admin1 (country_id, id, name) VALUES ('CA', 3, 'Prairie Region'); + INSERT INTO admin1 (country_id, id, name) VALUES ('US', 4, 'River Region'); INSERT INTO features (id, name, country_id, admin1_id) VALUES (1, 'Fixture Town', 'FX', 1); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (3001, 'Fixture Victoria', 'CA', 2); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (3002, 'Shared Market', 'CA', 2); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (3003, 'Shared Market', 'CA', 3); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (3004, 'Identifier Grove', 'CA', 2); + INSERT INTO features (id, name, country_id, admin1_id) VALUES (3005, 'Query Hamlet', 'US', 4); INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 12.25, -34.50); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3001, 48.4359, -123.35155); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3002, 48.7, -123.2); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3003, 50.2, -110.4); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3004, 48.9, -123.4); + INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3005, 39.25, -77.5); "#, ) .execute(&pool) @@ -279,6 +308,7 @@ async fn farm_private_location_upsert_stores_exact_location_and_public_locality_ ); assert_eq!(receipt.farm_pubkey, FARMER); assert_eq!(receipt.farm_d_tag, FARM_A_D_TAG); + assert_eq!(receipt.label, None); assert_eq!(receipt.exact_location, SdkExactLocation::new(12.26, -34.51)); assert_eq!(receipt.public_locality.primary, "Fixture Town"); assert_eq!( @@ -335,6 +365,178 @@ async fn farm_private_location_upsert_stores_exact_location_and_public_locality_ } #[tokio::test] +async fn farm_private_location_set_resolves_forward_inputs_and_preserves_no_mutation_failures() { + let (tempdir, sdk) = directory_sdk().await; + let geocoder = fixture_geocoder(&tempdir).await; + let actor = farmer_actor(); + + let exact = stored_location( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::exact( + actor.clone(), + FARM_A_D_TAG, + SdkExactLocation::new(12.26, -34.51), + ) + .with_label(" main pickup point ") + .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_200)), + &geocoder, + ) + .await + .expect("exact set"), + ); + assert_eq!(exact.label.as_deref(), Some("main pickup point")); + assert_eq!(exact.geonames_feature_id, Some(1)); + assert_eq!(exact.updated_at_ms, 1_700_000_200_000); + + let city = stored_location( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::city( + actor.clone(), + FARM_B_D_TAG, + "Fixture Victoria", + ) + .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_201)), + &geocoder, + ) + .await + .expect("city set"), + ); + assert_eq!(city.geonames_feature_id, Some(3001)); + assert_eq!(city.public_locality.primary, "Fixture Victoria"); + assert_eq!( + city.public_locality.region.as_deref(), + Some("British Columbia") + ); + assert_eq!(city.public_locality.country.as_deref(), Some("Canada")); + assert_eq!( + city.exact_location, + SdkExactLocation::new(48.4359, -123.35155) + ); + + let query = stored_location( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::query( + actor.clone(), + FARM_C_D_TAG, + "Fixture Victoria, BC, CA", + ), + &geocoder, + ) + .await + .expect("query set"), + ); + assert_eq!(query.geonames_feature_id, Some(3001)); + + let selected = stored_location( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::geonames_id(actor.clone(), FARM_D_D_TAG, 3004), + &geocoder, + ) + .await + .expect("id set"), + ); + assert_eq!(selected.geonames_feature_id, Some(3004)); + assert_eq!(selected.public_locality.primary, "Identifier Grove"); + + let narrowed = stored_location( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::new( + actor.clone(), + FARM_E_D_TAG, + FarmPrivateLocationInput::Locality( + GeocoderLocalityQuery::structured("Shared Market") + .with_region("Prairie Region") + .with_country("CA"), + ), + ), + &geocoder, + ) + .await + .expect("structured narrowed set"), + ); + assert_eq!(narrowed.geonames_feature_id, Some(3003)); + assert_eq!( + narrowed.public_locality.region.as_deref(), + Some("Prairie Region") + ); + + let before_failure_status = sdk + .storage_status(StorageStatusRequest::new()) + .await + .expect("pre-failure status") + .private_store + .farm_private_locations; + + let ambiguous = sdk + .farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::new( + actor.clone(), + FARM_F_D_TAG, + FarmPrivateLocationInput::Locality( + GeocoderLocalityQuery::structured("Shared Market").with_country("CA"), + ), + ), + &geocoder, + ) + .await + .expect("ambiguous set"); + let FarmPrivateLocationSetResult::Ambiguous(ambiguous) = ambiguous else { + panic!("expected ambiguous result"); + }; + assert_eq!( + ambiguous + .candidates + .iter() + .map(|candidate| candidate.geonames_feature_id) + .collect::<Vec<_>>(), + vec![3002, 3003] + ); + + let missing = sdk + .farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::query(actor.clone(), FARM_F_D_TAG, "Missing Market, CA"), + &geocoder, + ) + .await + .expect("missing set"); + assert!(matches!(missing, FarmPrivateLocationSetResult::NoMatch(_))); + + assert_eq!( + sdk.storage_status(StorageStatusRequest::new()) + .await + .expect("post-failure status") + .private_store + .farm_private_locations, + before_failure_status + ); + assert_eq!( + sdk.farms() + .private_location(&farm_addr(&actor, FARM_F_D_TAG)) + .await + .expect("failure location lookup"), + None + ); + + assert!(matches!( + sdk.farms() + .set_private_location_with_geocoder( + FarmPrivateLocationSetRequest::city(actor, FARM_F_D_TAG, "Fixture Victoria") + .with_label(" "), + &geocoder, + ) + .await, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} + +#[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; diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -192,7 +192,7 @@ 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_eq!(status.private_store.store.schema_version, 2); assert!(status.event_store.store.foreign_keys_enabled); assert!(status.outbox.store.foreign_keys_enabled); assert!(status.private_store.store.foreign_keys_enabled); @@ -659,6 +659,10 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { integrity_ok: true, integrity_result: "ok".to_owned(), }; + let private_store = SdkSqliteStoreStatus { + schema_version: 2, + ..store.clone() + }; assert_eq!( serde_json::to_value(StorageStatusRequest::new()).expect("status request"), serde_json::json!({}) @@ -688,7 +692,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { last_error: Some("relay publish incomplete".to_owned()), }, private_store: SdkPrivateStoreStorageStatus { - store, + store: private_store, farm_private_locations: 4, }, }) @@ -732,7 +736,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { }, "private_store": { "store": { - "schema_version": 1, + "schema_version": 2, "journal_mode": "wal", "foreign_keys_enabled": true, "busy_timeout_ms": 5000, diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs @@ -1,5 +1,6 @@ use super::*; use radroots_events::ids::RadrootsAddressableCoordinate; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; fn farm_addr() -> RadrootsAddressableCoordinate { RadrootsAddressableCoordinate::parse(format!( @@ -27,6 +28,7 @@ async fn private_farm_location_row_decode_reports_each_missing_column() { "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", ), ("farm_d_tag", "'AAAAAAAAAAAAAAAAAAAAAA'"), + ("label", "'Main pickup point'"), ("latitude", "12.26"), ("longitude", "-34.51"), ("locality_primary", "'Fixture Town'"), @@ -67,3 +69,72 @@ async fn private_store_file_open_rejects_directory_paths() { Err(RadrootsSdkError::PrivateStore { .. }) )); } + +#[tokio::test] +async fn private_store_file_open_materializes_label_column_for_existing_stores() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("private.sqlite"); + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("old private store pool"); + sqlx::raw_sql( + r#" + CREATE TABLE 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 + ); + "#, + ) + .execute(&pool) + .await + .expect("old private store schema"); + pool.close().await; + + let store = SdkPrivateStore::open_file(&path).await.expect("open store"); + let record = SdkPrivateFarmLocationRecord { + farm_addr: farm_addr(), + farm_pubkey: "a".repeat(64), + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + label: Some("Main pickup point".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, + }; + store + .upsert_farm_location(&record) + .await + .expect("upsert labeled location"); + assert_eq!( + store + .farm_location(&record.farm_addr) + .await + .expect("read labeled location") + .expect("stored location") + .label + .as_deref(), + Some("Main pickup point") + ); +} diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -50,6 +50,13 @@ fn sqlite_status() -> SdkSqliteStoreStatus { } } +fn private_sqlite_status() -> SdkSqliteStoreStatus { + SdkSqliteStoreStatus { + schema_version: 2, + ..sqlite_status() + } +} + fn storage_status() -> StorageStatusReceipt { StorageStatusReceipt { storage: SdkStorageKind::Memory, @@ -75,7 +82,7 @@ fn storage_status() -> StorageStatusReceipt { last_error: None, }, private_store: SdkPrivateStoreStorageStatus { - store: sqlite_status(), + store: private_sqlite_status(), farm_private_locations: 0, }, } @@ -144,6 +151,7 @@ fn private_farm_location_record() -> crate::private_store::SdkPrivateFarmLocatio .expect("farm addr"), farm_pubkey: "a".repeat(64), farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + label: None, latitude: 12.26, longitude: -34.51, locality_primary: "Fixture Town".to_owned(),