commit e73658326106bd70fcd05fe441bb2d171e304d71
parent ebb2b3a1eb185b1b42c77587f192ec5307c5e358
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 06:47:56 +0000
sqlite: add farm setup repository seam
Diffstat:
4 files changed, 449 insertions(+), 1 deletion(-)
diff --git a/crates/shared/sqlite/migrations/0004_account_farm_setup.sql b/crates/shared/sqlite/migrations/0004_account_farm_setup.sql
@@ -0,0 +1,30 @@
+CREATE TABLE account_farm_setups (
+ account_id TEXT PRIMARY KEY NOT NULL,
+ farm_name TEXT NOT NULL,
+ location_or_service_area TEXT NOT NULL,
+ pickup_enabled INTEGER NOT NULL CHECK (pickup_enabled IN (0, 1)),
+ delivery_enabled INTEGER NOT NULL CHECK (delivery_enabled IN (0, 1)),
+ shipping_enabled INTEGER NOT NULL CHECK (shipping_enabled IN (0, 1)),
+ saved_farm_id TEXT,
+ saved_farm_display_name TEXT,
+ saved_farm_readiness TEXT CHECK (
+ saved_farm_readiness IS NULL
+ OR saved_farm_readiness IN ('incomplete', 'ready')
+ ),
+ updated_at TEXT NOT NULL,
+ CHECK (
+ (
+ saved_farm_id IS NULL
+ AND saved_farm_display_name IS NULL
+ AND saved_farm_readiness IS NULL
+ )
+ OR (
+ saved_farm_id IS NOT NULL
+ AND saved_farm_display_name IS NOT NULL
+ AND saved_farm_readiness IS NOT NULL
+ )
+ )
+);
+
+CREATE INDEX idx_account_farm_setups_updated_at
+ ON account_farm_setups(updated_at DESC, account_id DESC);
diff --git a/crates/shared/sqlite/src/farm_setup.rs b/crates/shared/sqlite/src/farm_setup.rs
@@ -0,0 +1,390 @@
+use std::collections::BTreeSet;
+
+use radroots_app_models::{
+ FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
+};
+use rusqlite::{Connection, OptionalExtension, params};
+
+use crate::AppSqliteError;
+
+pub struct AppFarmSetupRepository<'a> {
+ connection: &'a Connection,
+}
+
+impl<'a> AppFarmSetupRepository<'a> {
+ pub const fn new(connection: &'a Connection) -> Self {
+ Self { connection }
+ }
+
+ pub fn load_farm_setup(&self, account_id: &str) -> Result<FarmSetupProjection, AppSqliteError> {
+ let row = self
+ .connection
+ .query_row(
+ "SELECT
+ farm_name,
+ location_or_service_area,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ saved_farm_id,
+ saved_farm_display_name,
+ saved_farm_readiness
+ FROM account_farm_setups
+ WHERE account_id = ?1
+ LIMIT 1",
+ [account_id],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, i64>(3)?,
+ row.get::<_, i64>(4)?,
+ row.get::<_, Option<String>>(5)?,
+ row.get::<_, Option<String>>(6)?,
+ row.get::<_, Option<String>>(7)?,
+ ))
+ },
+ )
+ .optional()
+ .map_err(|source| AppSqliteError::Query {
+ operation: "load account farm setup",
+ source,
+ })?;
+
+ let Some((
+ farm_name,
+ location_or_service_area,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ saved_farm_id,
+ saved_farm_display_name,
+ saved_farm_readiness,
+ )) = row
+ else {
+ return Ok(FarmSetupProjection::not_started());
+ };
+
+ let mut order_methods = BTreeSet::new();
+ if parse_sqlite_bool("account_farm_setups.pickup_enabled", pickup_enabled)? {
+ order_methods.insert(FarmOrderMethod::Pickup);
+ }
+ if parse_sqlite_bool("account_farm_setups.delivery_enabled", delivery_enabled)? {
+ order_methods.insert(FarmOrderMethod::Delivery);
+ }
+ if parse_sqlite_bool("account_farm_setups.shipping_enabled", shipping_enabled)? {
+ order_methods.insert(FarmOrderMethod::Shipping);
+ }
+
+ let saved_farm =
+ parse_saved_farm(saved_farm_id, saved_farm_display_name, saved_farm_readiness)?;
+
+ Ok(FarmSetupProjection::new(
+ FarmSetupDraft::new(farm_name, location_or_service_area, order_methods),
+ saved_farm,
+ ))
+ }
+
+ pub fn save_farm_setup(
+ &self,
+ account_id: &str,
+ projection: &FarmSetupProjection,
+ ) -> Result<(), AppSqliteError> {
+ if !projection.has_saved_farm() && projection.draft.is_empty() {
+ return self.clear_farm_setup(account_id);
+ }
+
+ self.connection
+ .execute(
+ "INSERT INTO account_farm_setups (
+ account_id,
+ farm_name,
+ location_or_service_area,
+ pickup_enabled,
+ delivery_enabled,
+ shipping_enabled,
+ saved_farm_id,
+ saved_farm_display_name,
+ saved_farm_readiness,
+ updated_at
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ ON CONFLICT(account_id) DO UPDATE SET
+ farm_name = excluded.farm_name,
+ location_or_service_area = excluded.location_or_service_area,
+ pickup_enabled = excluded.pickup_enabled,
+ delivery_enabled = excluded.delivery_enabled,
+ shipping_enabled = excluded.shipping_enabled,
+ saved_farm_id = excluded.saved_farm_id,
+ saved_farm_display_name = excluded.saved_farm_display_name,
+ saved_farm_readiness = excluded.saved_farm_readiness,
+ updated_at = excluded.updated_at",
+ params![
+ account_id,
+ projection.draft.farm_name,
+ projection.draft.location_or_service_area,
+ i64::from(
+ projection
+ .draft
+ .order_methods
+ .contains(&FarmOrderMethod::Pickup)
+ ),
+ i64::from(
+ projection
+ .draft
+ .order_methods
+ .contains(&FarmOrderMethod::Delivery)
+ ),
+ i64::from(
+ projection
+ .draft
+ .order_methods
+ .contains(&FarmOrderMethod::Shipping)
+ ),
+ projection
+ .saved_farm
+ .as_ref()
+ .map(|farm| farm.farm_id.to_string()),
+ projection
+ .saved_farm
+ .as_ref()
+ .map(|farm| farm.display_name.clone()),
+ projection
+ .saved_farm
+ .as_ref()
+ .map(|farm| farm_readiness_storage_key(farm.readiness)),
+ ],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "save account farm setup",
+ source,
+ })?;
+
+ Ok(())
+ }
+
+ pub fn clear_farm_setup(&self, account_id: &str) -> Result<(), AppSqliteError> {
+ self.connection
+ .execute(
+ "DELETE FROM account_farm_setups WHERE account_id = ?1",
+ [account_id],
+ )
+ .map_err(|source| AppSqliteError::Query {
+ operation: "clear account farm setup",
+ source,
+ })?;
+
+ Ok(())
+ }
+}
+
+fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteError> {
+ match value {
+ 0 => Ok(false),
+ 1 => Ok(true),
+ _ => Err(AppSqliteError::DecodeEnum {
+ field,
+ value: value.to_string(),
+ }),
+ }
+}
+
+fn parse_saved_farm(
+ farm_id: Option<String>,
+ display_name: Option<String>,
+ readiness: Option<String>,
+) -> Result<Option<FarmSummary>, AppSqliteError> {
+ match (farm_id, display_name, readiness) {
+ (Some(farm_id), Some(display_name), Some(readiness)) => Ok(Some(FarmSummary {
+ farm_id: farm_id.parse().map_err(|_| AppSqliteError::DecodeId {
+ field: "account_farm_setups.saved_farm_id",
+ value: farm_id,
+ })?,
+ display_name,
+ readiness: parse_farm_readiness("account_farm_setups.saved_farm_readiness", readiness)?,
+ })),
+ (None, None, None) => Ok(None),
+ (Some(_), None, _) => Err(AppSqliteError::MissingColumn {
+ field: "account_farm_setups.saved_farm_display_name",
+ }),
+ (Some(_), _, None) => Err(AppSqliteError::MissingColumn {
+ field: "account_farm_setups.saved_farm_readiness",
+ }),
+ (None, Some(_), _) => Err(AppSqliteError::MissingColumn {
+ field: "account_farm_setups.saved_farm_id",
+ }),
+ (None, _, Some(_)) => Err(AppSqliteError::MissingColumn {
+ field: "account_farm_setups.saved_farm_id",
+ }),
+ }
+}
+
+fn parse_farm_readiness(
+ field: &'static str,
+ value: String,
+) -> Result<FarmReadiness, AppSqliteError> {
+ match value.as_str() {
+ "incomplete" => Ok(FarmReadiness::Incomplete),
+ "ready" => Ok(FarmReadiness::Ready),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn farm_readiness_storage_key(readiness: FarmReadiness) -> &'static str {
+ match readiness {
+ FarmReadiness::Incomplete => "incomplete",
+ FarmReadiness::Ready => "ready",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ env, fs,
+ path::PathBuf,
+ time::{SystemTime, UNIX_EPOCH},
+ };
+
+ use radroots_app_models::{
+ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
+ };
+
+ use crate::{AppSqliteStore, DatabaseTarget};
+
+ #[test]
+ fn load_farm_setup_returns_not_started_when_account_is_missing() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+
+ let projection = store
+ .load_farm_setup("acct_missing")
+ .expect("missing setup should load");
+
+ assert_eq!(projection, FarmSetupProjection::not_started());
+ }
+
+ #[test]
+ fn farm_setup_round_trips_incomplete_draft() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let projection = FarmSetupProjection::from_draft(FarmSetupDraft::new(
+ "North field farm",
+ "",
+ [FarmOrderMethod::Pickup, FarmOrderMethod::Shipping],
+ ));
+
+ store
+ .save_farm_setup("acct_farm_draft", &projection)
+ .expect("farm setup should save");
+
+ let loaded = store
+ .load_farm_setup("acct_farm_draft")
+ .expect("farm setup should load");
+
+ assert_eq!(loaded, projection);
+ }
+
+ #[test]
+ fn farm_setup_round_trips_saved_farm_state() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let saved_farm = FarmSummary {
+ farm_id: FarmId::new(),
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ };
+ let projection = FarmSetupProjection::new(
+ FarmSetupDraft::new(
+ "North field farm",
+ "Asheville, NC",
+ [FarmOrderMethod::Pickup],
+ ),
+ Some(saved_farm.clone()),
+ );
+
+ store
+ .save_farm_setup("acct_saved_farm", &projection)
+ .expect("saved farm setup should save");
+
+ let loaded = store
+ .load_farm_setup("acct_saved_farm")
+ .expect("saved farm setup should load");
+
+ assert_eq!(loaded.saved_farm, Some(saved_farm));
+ assert_eq!(loaded.readiness, projection.readiness);
+ assert_eq!(loaded.draft, projection.draft);
+ }
+
+ #[test]
+ fn clearing_farm_setup_restores_not_started_state() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+
+ store
+ .save_farm_setup(
+ "acct_clear",
+ &FarmSetupProjection::from_draft(FarmSetupDraft::new(
+ "North field farm",
+ "Asheville, NC",
+ [FarmOrderMethod::Delivery],
+ )),
+ )
+ .expect("farm setup should save");
+ store
+ .clear_farm_setup("acct_clear")
+ .expect("farm setup should clear");
+
+ assert_eq!(
+ store
+ .load_farm_setup("acct_clear")
+ .expect("cleared setup should load"),
+ FarmSetupProjection::not_started()
+ );
+ }
+
+ #[test]
+ fn file_backed_farm_setup_survives_reopen() {
+ let path = temp_database_path("farm_setup_reopen");
+ let projection = FarmSetupProjection::from_draft(FarmSetupDraft::new(
+ "North field farm",
+ "Asheville, NC",
+ [FarmOrderMethod::Pickup, FarmOrderMethod::Delivery],
+ ));
+
+ let first = AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store");
+ first
+ .save_farm_setup("acct_file_backed", &projection)
+ .expect("farm setup should save");
+ drop(first);
+
+ let reopened = AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("reopen");
+ let loaded = reopened
+ .load_farm_setup("acct_file_backed")
+ .expect("reloaded setup should load");
+
+ assert_eq!(loaded, projection);
+
+ drop(reopened);
+ remove_database_artifacts(&path);
+ }
+
+ fn temp_database_path(test_name: &str) -> PathBuf {
+ let nonce = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("time should move forward")
+ .as_nanos();
+
+ env::temp_dir()
+ .join("radroots_app_sqlite_tests")
+ .join(format!("{test_name}-{nonce}"))
+ .join("app.sqlite3")
+ }
+
+ fn remove_database_artifacts(database_path: &std::path::Path) {
+ if let Some(parent) = database_path.parent() {
+ let wal_path = database_path.with_extension("sqlite3-wal");
+ let shm_path = database_path.with_extension("sqlite3-shm");
+
+ let _ = fs::remove_file(&wal_path);
+ let _ = fs::remove_file(&shm_path);
+ let _ = fs::remove_file(database_path);
+ let _ = fs::remove_dir_all(parent);
+ }
+ }
+}
diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs
@@ -3,6 +3,7 @@
mod activation;
mod activity;
mod error;
+mod farm_setup;
mod migrations;
mod today;
@@ -10,7 +11,7 @@ use std::{fs, path::PathBuf, time::Duration};
use radroots_app_models::{
AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind,
- FarmId, TodayAgendaProjection,
+ FarmId, FarmSetupProjection, TodayAgendaProjection,
};
use rusqlite::Connection;
@@ -19,6 +20,7 @@ pub use activity::{
APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository,
};
pub use error::AppSqliteError;
+pub use farm_setup::AppFarmSetupRepository;
pub use migrations::latest_schema_version;
pub use today::{
AppTodayAgendaRepository, TODAY_AGENDA_LIST_LIMIT, TODAY_AGENDA_LOW_STOCK_THRESHOLD,
@@ -68,6 +70,10 @@ impl AppSqliteStore {
AppActivationRepository::new(&self.connection)
}
+ pub fn farm_setup_repository(&self) -> AppFarmSetupRepository<'_> {
+ AppFarmSetupRepository::new(&self.connection)
+ }
+
pub fn load_today_agenda(
&self,
farm_id: Option<FarmId>,
@@ -113,6 +119,23 @@ impl AppSqliteStore {
self.activation_repository()
.clear_surface_activation(account_id)
}
+
+ pub fn load_farm_setup(&self, account_id: &str) -> Result<FarmSetupProjection, AppSqliteError> {
+ self.farm_setup_repository().load_farm_setup(account_id)
+ }
+
+ pub fn save_farm_setup(
+ &self,
+ account_id: &str,
+ projection: &FarmSetupProjection,
+ ) -> Result<(), AppSqliteError> {
+ self.farm_setup_repository()
+ .save_farm_setup(account_id, projection)
+ }
+
+ pub fn clear_farm_setup(&self, account_id: &str) -> Result<(), AppSqliteError> {
+ self.farm_setup_repository().clear_farm_setup(account_id)
+ }
}
fn open_connection(target: &DatabaseTarget) -> Result<Connection, AppSqliteError> {
@@ -243,6 +266,7 @@ mod tests {
assert!(table_exists(connection, "sync_checkpoints"));
assert!(table_exists(connection, "activity_events"));
assert!(table_exists(connection, "account_surface_activations"));
+ assert!(table_exists(connection, "account_farm_setups"));
assert_eq!(row_count(connection, "sync_checkpoints"), 1);
drop(store);
diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs
@@ -16,6 +16,10 @@ const MIGRATIONS: &[Migration] = &[
version: 3,
sql: include_str!("../migrations/0003_account_surface_activation.sql"),
},
+ Migration {
+ version: 4,
+ sql: include_str!("../migrations/0004_account_farm_setup.sql"),
+ },
];
pub fn latest_schema_version() -> u32 {