commit 5cf9993e6ad8b807623170709ec2ddeb95b07574
parent 46ec9a40f3981d00654663abe226b8f33cd7eb39
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:
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(),