app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 1dad49751d9ea58bae97c6f33dc783f5ee6078ea
parent 201753744a978a2ca956cced626c8aef5f4df18d
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 02:58:19 +0000

app: align runtime paths and activation persistence

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 39++++++++++++++++++++++++++++++---------
Mcrates/shared/core/src/lib.rs | 6+++++-
Mcrates/shared/core/src/paths.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/shared/models/src/lib.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acrates/shared/sqlite/migrations/0003_account_surface_activation.sql | 9+++++++++
Acrates/shared/sqlite/src/activation.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/lib.rs | 31++++++++++++++++++++++++++++++-
Mcrates/shared/sqlite/src/migrations.rs | 4++++
8 files changed, 514 insertions(+), 58 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1,7 +1,7 @@ use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use radroots_app_core::{AppRuntimePathsError, AppRuntimeRoots}; +use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError}; use radroots_app_models::{ AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection, SettingsPreference, SettingsSection, TodayAgendaProjection, @@ -170,8 +170,8 @@ impl fmt::Debug for DesktopAppRuntimeState { impl DesktopAppRuntimeState { fn try_bootstrap() -> Result<Self, DesktopAppRuntimeBootstrapError> { - let roots = AppRuntimeRoots::current_desktop()?; - let database_path = roots.data.join(APP_DATABASE_FILE_NAME); + let paths = AppDesktopRuntimePaths::current_desktop()?; + let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; let today_projection = sqlite_store.load_today_agenda(None)?; @@ -215,7 +215,10 @@ enum DesktopAppRuntimeBootstrapError { mod tests { use std::path::PathBuf; - use radroots_app_core::{AppRuntimeHostEnvironment, AppRuntimePlatform, AppRuntimeRoots}; + use radroots_app_core::{ + AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, + SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, + }; use radroots_app_models::{ AppActivityKind, AppStartupGate, FarmReadiness, FarmSummary, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, @@ -229,8 +232,8 @@ mod tests { use super::{APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeState}; #[test] - fn desktop_namespace_uses_canonical_app_data_root() { - let roots = AppRuntimeRoots::for_desktop( + fn desktop_namespace_uses_canonical_app_and_shared_runtime_roots() { + let paths = AppDesktopRuntimePaths::for_desktop( AppRuntimePlatform::Macos, AppRuntimeHostEnvironment { home_dir: Some(PathBuf::from("/Users/treesap")), @@ -240,17 +243,35 @@ mod tests { .expect("interactive user roots should resolve"); assert_eq!( - roots.data, + paths.app.data, PathBuf::from("/Users/treesap/.radroots/data/apps/app") ); assert_eq!( - roots.logs, + paths.app.logs, PathBuf::from("/Users/treesap/.radroots/logs/apps/app") ); assert_eq!( - roots.data.join(APP_DATABASE_FILE_NAME), + paths.app.data.join(APP_DATABASE_FILE_NAME), PathBuf::from("/Users/treesap/.radroots/data/apps/app/app.sqlite3") ); + assert_eq!( + paths.shared_accounts.data_root, + PathBuf::from("/Users/treesap/.radroots/data/shared/accounts") + ); + assert_eq!( + paths.shared_accounts.secrets_root, + PathBuf::from("/Users/treesap/.radroots/secrets/shared/accounts") + ); + assert_eq!( + paths.shared_accounts.store_path, + PathBuf::from("/Users/treesap/.radroots/data/shared/accounts") + .join(SHARED_ACCOUNTS_STORE_FILE_NAME) + ); + assert_eq!( + paths.shared_identity.default_identity_path, + PathBuf::from("/Users/treesap/.radroots/secrets/shared/identities") + .join(SHARED_IDENTITY_FILE_NAME) + ); } #[test] diff --git a/crates/shared/core/src/lib.rs b/crates/shared/core/src/lib.rs @@ -11,7 +11,11 @@ pub use logging::{ }; pub use paths::{ APP_RUNTIME_NAMESPACE, APP_RUNTIME_NAMESPACE_KIND, APP_RUNTIME_NAMESPACE_VALUE, - AppRuntimeHostEnvironment, AppRuntimePathsError, AppRuntimePlatform, AppRuntimeRoots, + AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePathsError, AppRuntimePlatform, + AppRuntimeRoots, AppSharedAccountsPaths, AppSharedIdentityPaths, SHARED_ACCOUNTS_NAMESPACE, + SHARED_ACCOUNTS_NAMESPACE_KIND, SHARED_ACCOUNTS_NAMESPACE_VALUE, + SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITIES_NAMESPACE, SHARED_IDENTITIES_NAMESPACE_KIND, + SHARED_IDENTITIES_NAMESPACE_VALUE, SHARED_IDENTITY_FILE_NAME, }; pub use runtime::{ APP_HOST_PLATFORM, APP_ID, APP_NAME, APP_PLATFORM_RUNTIME, APP_PROJECTION_SOURCE, diff --git a/crates/shared/core/src/paths.rs b/crates/shared/core/src/paths.rs @@ -8,6 +8,14 @@ use std::{ pub const APP_RUNTIME_NAMESPACE_KIND: &str = "apps"; pub const APP_RUNTIME_NAMESPACE_VALUE: &str = "app"; pub const APP_RUNTIME_NAMESPACE: &str = "apps/app"; +pub const SHARED_ACCOUNTS_NAMESPACE_KIND: &str = "shared"; +pub const SHARED_ACCOUNTS_NAMESPACE_VALUE: &str = "accounts"; +pub const SHARED_ACCOUNTS_NAMESPACE: &str = "shared/accounts"; +pub const SHARED_ACCOUNTS_STORE_FILE_NAME: &str = "store.json"; +pub const SHARED_IDENTITIES_NAMESPACE_KIND: &str = "shared"; +pub const SHARED_IDENTITIES_NAMESPACE_VALUE: &str = "identities"; +pub const SHARED_IDENTITIES_NAMESPACE: &str = "shared/identities"; +pub const SHARED_IDENTITY_FILE_NAME: &str = "default.json"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AppRuntimePlatform { @@ -64,49 +72,35 @@ pub struct AppRuntimeRoots { pub secrets: PathBuf, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSharedAccountsPaths { + pub data_root: PathBuf, + pub secrets_root: PathBuf, + pub store_path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppSharedIdentityPaths { + pub default_identity_path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppDesktopRuntimePaths { + pub app: AppRuntimeRoots, + pub shared_accounts: AppSharedAccountsPaths, + pub shared_identity: AppSharedIdentityPaths, +} + impl AppRuntimeRoots { pub fn current_desktop() -> Result<Self, AppRuntimePathsError> { - Self::for_desktop( - AppRuntimePlatform::current(), - AppRuntimeHostEnvironment::from_current_process(), - ) + AppDesktopRuntimePaths::current_desktop().map(|paths| paths.app) } pub fn for_desktop( platform: AppRuntimePlatform, host_environment: AppRuntimeHostEnvironment, ) -> Result<Self, AppRuntimePathsError> { - let roots = match platform { - AppRuntimePlatform::Linux | AppRuntimePlatform::Macos => { - let home_dir = host_environment - .home_dir - .ok_or(AppRuntimePathsError::MissingHomeDir { platform })?; - Self::from_base_root(home_dir.join(".radroots")) - } - AppRuntimePlatform::Windows => { - let appdata_dir = host_environment - .appdata_dir - .ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?; - let localappdata_dir = host_environment - .localappdata_dir - .ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?; - let config_root = appdata_dir.join("Radroots"); - let local_root = localappdata_dir.join("Radroots"); - Self { - config: config_root.join("config"), - data: local_root.join("data"), - cache: local_root.join("cache"), - logs: local_root.join("logs"), - run: local_root.join("run"), - secrets: config_root.join("secrets"), - } - } - AppRuntimePlatform::Other(_) => { - return Err(AppRuntimePathsError::UnsupportedPlatform { platform }); - } - }; - - Ok(roots.namespaced_app()) + Ok(resolve_desktop_base_roots(platform, host_environment)?.namespaced_app()) } pub fn from_base_root(base_root: impl AsRef<Path>) -> Self { @@ -122,7 +116,15 @@ impl AppRuntimeRoots { } pub fn namespaced_app(&self) -> Self { - let namespace = PathBuf::from(APP_RUNTIME_NAMESPACE_KIND).join(APP_RUNTIME_NAMESPACE_VALUE); + self.namespaced(APP_RUNTIME_NAMESPACE_KIND, APP_RUNTIME_NAMESPACE_VALUE) + } + + fn namespaced_shared(&self, value: &str) -> Self { + self.namespaced(SHARED_ACCOUNTS_NAMESPACE_KIND, value) + } + + fn namespaced(&self, kind: &str, value: &str) -> Self { + let namespace = PathBuf::from(kind).join(value); Self { config: self.config.join(&namespace), data: self.data.join(&namespace), @@ -134,6 +136,73 @@ impl AppRuntimeRoots { } } +impl AppDesktopRuntimePaths { + pub fn current_desktop() -> Result<Self, AppRuntimePathsError> { + Self::for_desktop( + AppRuntimePlatform::current(), + AppRuntimeHostEnvironment::from_current_process(), + ) + } + + pub fn for_desktop( + platform: AppRuntimePlatform, + host_environment: AppRuntimeHostEnvironment, + ) -> Result<Self, AppRuntimePathsError> { + let base_roots = resolve_desktop_base_roots(platform, host_environment)?; + let shared_accounts = base_roots.namespaced_shared(SHARED_ACCOUNTS_NAMESPACE_VALUE); + let shared_identity = base_roots.namespaced_shared(SHARED_IDENTITIES_NAMESPACE_VALUE); + + Ok(Self { + app: base_roots.namespaced_app(), + shared_accounts: AppSharedAccountsPaths { + data_root: shared_accounts.data.clone(), + secrets_root: shared_accounts.secrets.clone(), + store_path: shared_accounts.data.join(SHARED_ACCOUNTS_STORE_FILE_NAME), + }, + shared_identity: AppSharedIdentityPaths { + default_identity_path: shared_identity.secrets.join(SHARED_IDENTITY_FILE_NAME), + }, + }) + } +} + +fn resolve_desktop_base_roots( + platform: AppRuntimePlatform, + host_environment: AppRuntimeHostEnvironment, +) -> Result<AppRuntimeRoots, AppRuntimePathsError> { + let roots = match platform { + AppRuntimePlatform::Linux | AppRuntimePlatform::Macos => { + let home_dir = host_environment + .home_dir + .ok_or(AppRuntimePathsError::MissingHomeDir { platform })?; + AppRuntimeRoots::from_base_root(home_dir.join(".radroots")) + } + AppRuntimePlatform::Windows => { + let appdata_dir = host_environment + .appdata_dir + .ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?; + let localappdata_dir = host_environment + .localappdata_dir + .ok_or(AppRuntimePathsError::MissingWindowsUserDirs)?; + let config_root = appdata_dir.join("Radroots"); + let local_root = localappdata_dir.join("Radroots"); + AppRuntimeRoots { + config: config_root.join("config"), + data: local_root.join("data"), + cache: local_root.join("cache"), + logs: local_root.join("logs"), + run: local_root.join("run"), + secrets: config_root.join("secrets"), + } + } + AppRuntimePlatform::Other(_) => { + return Err(AppRuntimePathsError::UnsupportedPlatform { platform }); + } + }; + + Ok(roots) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AppRuntimePathsError { MissingHomeDir { platform: AppRuntimePlatform }, @@ -169,13 +238,14 @@ mod tests { use std::path::PathBuf; use super::{ - APP_RUNTIME_NAMESPACE, AppRuntimeHostEnvironment, AppRuntimePathsError, AppRuntimePlatform, - AppRuntimeRoots, + APP_RUNTIME_NAMESPACE, AppDesktopRuntimePaths, AppRuntimeHostEnvironment, + AppRuntimePathsError, AppRuntimePlatform, AppRuntimeRoots, SHARED_ACCOUNTS_NAMESPACE, + SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITIES_NAMESPACE, SHARED_IDENTITY_FILE_NAME, }; #[test] fn desktop_runtime_roots_use_canonical_macos_namespace() { - let roots = AppRuntimeRoots::for_desktop( + let paths = AppDesktopRuntimePaths::for_desktop( AppRuntimePlatform::Macos, AppRuntimeHostEnvironment { home_dir: Some(PathBuf::from("/Users/treesap")), @@ -185,13 +255,33 @@ mod tests { .expect("macos roots should resolve"); assert_eq!( - roots.data, + paths.app.data, PathBuf::from("/Users/treesap/.radroots/data").join(APP_RUNTIME_NAMESPACE) ); assert_eq!( - roots.logs, + paths.app.logs, PathBuf::from("/Users/treesap/.radroots/logs").join(APP_RUNTIME_NAMESPACE) ); + assert_eq!( + paths.shared_accounts.data_root, + PathBuf::from("/Users/treesap/.radroots/data").join(SHARED_ACCOUNTS_NAMESPACE) + ); + assert_eq!( + paths.shared_accounts.secrets_root, + PathBuf::from("/Users/treesap/.radroots/secrets").join(SHARED_ACCOUNTS_NAMESPACE) + ); + assert_eq!( + paths.shared_accounts.store_path, + PathBuf::from("/Users/treesap/.radroots/data") + .join(SHARED_ACCOUNTS_NAMESPACE) + .join(SHARED_ACCOUNTS_STORE_FILE_NAME) + ); + assert_eq!( + paths.shared_identity.default_identity_path, + PathBuf::from("/Users/treesap/.radroots/secrets") + .join(SHARED_IDENTITIES_NAMESPACE) + .join(SHARED_IDENTITY_FILE_NAME) + ); } #[test] diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -298,6 +298,37 @@ pub struct AccountSummary { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AccountSurfaceActivationProjection { + pub account_id: String, + pub selected_surface: SelectedSurfaceProjection, + pub farmer_activation: FarmerActivationProjection, +} + +impl AccountSurfaceActivationProjection { + pub fn new( + account_id: impl Into<String>, + selected_surface: SelectedSurfaceProjection, + farmer_activation: FarmerActivationProjection, + ) -> Self { + let active_surface = if farmer_activation.is_active() { + selected_surface.active_surface + } else { + ActiveSurface::Personal + }; + + Self { + account_id: account_id.into(), + selected_surface: SelectedSurfaceProjection::new(active_surface), + farmer_activation, + } + } + + pub const fn active_surface(&self) -> ActiveSurface { + self.selected_surface.active_surface + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct SelectedAccountProjection { pub account: AccountSummary, pub selected_surface: SelectedSurfaceProjection, @@ -323,11 +354,32 @@ impl SelectedAccountProjection { } } + pub fn from_surface_activation( + account: AccountSummary, + activation: AccountSurfaceActivationProjection, + ) -> Self { + Self::new( + account, + activation.selected_surface, + activation.farmer_activation, + ) + } + pub const fn active_surface(&self) -> ActiveSurface { self.selected_surface.active_surface } } +impl From<&SelectedAccountProjection> for AccountSurfaceActivationProjection { + fn from(value: &SelectedAccountProjection) -> Self { + Self::new( + value.account.account_id.clone(), + value.selected_surface, + value.farmer_activation.clone(), + ) + } +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppStartupGate { @@ -587,12 +639,12 @@ impl TodayAgendaProjection { #[cfg(test)] mod tests { use super::{ - AccountCustody, AccountSummary, ActiveSurface, ActivityEventId, AppActivityContext, - AppActivityEvent, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId, - FarmerActivationProjection, FarmerSection, IdentityBlockedReason, OrderListRow, - ProductListRow, SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, - SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, - TodaySummary, + AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, + ActivityEventId, AppActivityContext, AppActivityEvent, AppActivityKind, + AppIdentityProjection, AppStartupGate, FarmId, FarmerActivationProjection, FarmerSection, + IdentityBlockedReason, OrderListRow, ProductListRow, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -675,6 +727,40 @@ mod tests { } #[test] + fn account_surface_activation_projection_normalizes_to_personal_without_farm_binding() { + let projection = AccountSurfaceActivationProjection::new( + "acct_04", + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::inactive(), + ); + + assert_eq!(projection.account_id, "acct_04"); + assert_eq!(projection.active_surface(), ActiveSurface::Personal); + assert!(!projection.farmer_activation.is_active()); + } + + #[test] + fn selected_account_projection_round_trips_through_surface_activation_state() { + let selected_account = SelectedAccountProjection::new( + AccountSummary { + account_id: "acct_roundtrip".to_owned(), + npub: "npub1roundtrip".to_owned(), + label: Some("Roundtrip".to_owned()), + custody: AccountCustody::LocalManaged, + }, + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + let activation = AccountSurfaceActivationProjection::from(&selected_account); + let restored = SelectedAccountProjection::from_surface_activation( + selected_account.account.clone(), + activation, + ); + + assert_eq!(restored, selected_account); + } + + #[test] fn startup_gate_tracks_setup_personal_farmer_and_blocked_states() { let farmer_identity = AppIdentityProjection::ready( Vec::new(), diff --git a/crates/shared/sqlite/migrations/0003_account_surface_activation.sql b/crates/shared/sqlite/migrations/0003_account_surface_activation.sql @@ -0,0 +1,9 @@ +CREATE TABLE account_surface_activations ( + account_id TEXT PRIMARY KEY NOT NULL, + selected_surface TEXT NOT NULL CHECK (selected_surface IN ('personal', 'farmer')), + farmer_farm_id TEXT, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_account_surface_activations_updated_at + ON account_surface_activations(updated_at DESC, account_id DESC); diff --git a/crates/shared/sqlite/src/activation.rs b/crates/shared/sqlite/src/activation.rs @@ -0,0 +1,213 @@ +use radroots_app_models::{ + AccountSurfaceActivationProjection, ActiveSurface, FarmId, FarmerActivationProjection, + SelectedSurfaceProjection, +}; +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::AppSqliteError; + +pub struct AppActivationRepository<'a> { + connection: &'a Connection, +} + +impl<'a> AppActivationRepository<'a> { + pub const fn new(connection: &'a Connection) -> Self { + Self { connection } + } + + pub fn load_surface_activation( + &self, + account_id: &str, + ) -> Result<Option<AccountSurfaceActivationProjection>, AppSqliteError> { + let row = self + .connection + .query_row( + "SELECT account_id, selected_surface, farmer_farm_id + FROM account_surface_activations + WHERE account_id = ?1 + LIMIT 1", + [account_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option<String>>(2)?, + )) + }, + ) + .optional() + .map_err(|source| AppSqliteError::Query { + operation: "load account surface activation", + source, + })?; + + row.map(|(account_id, selected_surface, farmer_farm_id)| { + Ok(AccountSurfaceActivationProjection::new( + account_id, + SelectedSurfaceProjection::new(parse_active_surface( + "account_surface_activations.selected_surface", + selected_surface, + )?), + FarmerActivationProjection { + farm_id: parse_optional_farm_id( + "account_surface_activations.farmer_farm_id", + farmer_farm_id, + )?, + }, + )) + }) + .transpose() + } + + pub fn save_surface_activation( + &self, + projection: &AccountSurfaceActivationProjection, + ) -> Result<(), AppSqliteError> { + self.connection + .execute( + "INSERT INTO account_surface_activations ( + account_id, + selected_surface, + farmer_farm_id, + updated_at + ) VALUES (?1, ?2, ?3, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ON CONFLICT(account_id) DO UPDATE SET + selected_surface = excluded.selected_surface, + farmer_farm_id = excluded.farmer_farm_id, + updated_at = excluded.updated_at", + params![ + projection.account_id, + projection.active_surface().storage_key(), + projection + .farmer_activation + .farm_id + .map(|farm_id| farm_id.to_string()), + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "save account surface activation", + source, + })?; + + Ok(()) + } + + pub fn clear_surface_activation(&self, account_id: &str) -> Result<(), AppSqliteError> { + self.connection + .execute( + "DELETE FROM account_surface_activations WHERE account_id = ?1", + [account_id], + ) + .map_err(|source| AppSqliteError::Query { + operation: "clear account surface activation", + source, + })?; + + Ok(()) + } +} + +fn parse_active_surface( + field: &'static str, + value: String, +) -> Result<ActiveSurface, AppSqliteError> { + match value.as_str() { + "personal" => Ok(ActiveSurface::Personal), + "farmer" => Ok(ActiveSurface::Farmer), + _ => Err(AppSqliteError::DecodeEnum { field, value }), + } +} + +fn parse_optional_farm_id( + field: &'static str, + value: Option<String>, +) -> Result<Option<FarmId>, AppSqliteError> { + value + .map(|value| { + value + .parse() + .map_err(|_| AppSqliteError::DecodeId { field, value }) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use radroots_app_models::{ + AccountSurfaceActivationProjection, ActiveSurface, FarmId, FarmerActivationProjection, + SelectedSurfaceProjection, + }; + + use crate::{AppSqliteStore, DatabaseTarget}; + + #[test] + fn load_surface_activation_returns_none_for_unknown_account() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + + let projection = store + .load_surface_activation("acct_missing") + .expect("missing activation should load"); + + assert_eq!(projection, None); + } + + #[test] + fn surface_activation_round_trips_farmer_binding() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let projection = AccountSurfaceActivationProjection::new( + "acct_farmer", + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + + store + .save_surface_activation(&projection) + .expect("surface activation should save"); + + let loaded = store + .load_surface_activation("acct_farmer") + .expect("surface activation should load") + .expect("surface activation should exist"); + + assert_eq!(loaded, projection); + } + + #[test] + fn surface_activation_upsert_and_clear_are_explicit() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let first = AccountSurfaceActivationProjection::new( + "acct_surface", + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::active(FarmId::new()), + ); + let second = AccountSurfaceActivationProjection::new( + "acct_surface", + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + FarmerActivationProjection::inactive(), + ); + + store + .save_surface_activation(&first) + .expect("initial surface activation should save"); + store + .save_surface_activation(&second) + .expect("updated surface activation should save"); + + let loaded = store + .load_surface_activation("acct_surface") + .expect("updated surface activation should load") + .expect("updated surface activation should exist"); + assert_eq!(loaded.active_surface(), ActiveSurface::Personal); + assert_eq!(loaded, second); + + store + .clear_surface_activation("acct_surface") + .expect("surface activation should clear"); + assert_eq!( + store + .load_surface_activation("acct_surface") + .expect("cleared surface activation should load"), + None + ); + } +} diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +mod activation; mod activity; mod error; mod migrations; @@ -8,10 +9,12 @@ mod today; use std::{fs, path::PathBuf, time::Duration}; use radroots_app_models::{ - AppActivityContext, AppActivityEvent, AppActivityKind, FarmId, TodayAgendaProjection, + AccountSurfaceActivationProjection, AppActivityContext, AppActivityEvent, AppActivityKind, + FarmId, TodayAgendaProjection, }; use rusqlite::Connection; +pub use activation::AppActivationRepository; pub use activity::{ APP_ACTIVITY_CONTEXT_LIMIT, APP_ACTIVITY_RETENTION_LIMIT, AppActivityRepository, }; @@ -61,6 +64,10 @@ impl AppSqliteStore { AppActivityRepository::new(&self.connection) } + pub fn activation_repository(&self) -> AppActivationRepository<'_> { + AppActivationRepository::new(&self.connection) + } + pub fn load_today_agenda( &self, farm_id: Option<FarmId>, @@ -85,6 +92,27 @@ impl AppSqliteStore { ) -> Result<AppActivityContext, AppSqliteError> { self.activity_repository().load_context(limit) } + + pub fn load_surface_activation( + &self, + account_id: &str, + ) -> Result<Option<AccountSurfaceActivationProjection>, AppSqliteError> { + self.activation_repository() + .load_surface_activation(account_id) + } + + pub fn save_surface_activation( + &self, + projection: &AccountSurfaceActivationProjection, + ) -> Result<(), AppSqliteError> { + self.activation_repository() + .save_surface_activation(projection) + } + + pub fn clear_surface_activation(&self, account_id: &str) -> Result<(), AppSqliteError> { + self.activation_repository() + .clear_surface_activation(account_id) + } } fn open_connection(target: &DatabaseTarget) -> Result<Connection, AppSqliteError> { @@ -214,6 +242,7 @@ mod tests { assert!(table_exists(connection, "local_conflicts")); assert!(table_exists(connection, "sync_checkpoints")); assert!(table_exists(connection, "activity_events")); + assert!(table_exists(connection, "account_surface_activations")); 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 @@ -12,6 +12,10 @@ const MIGRATIONS: &[Migration] = &[ version: 2, sql: include_str!("../migrations/0002_activity_journal.sql"), }, + Migration { + version: 3, + sql: include_str!("../migrations/0003_account_surface_activation.sql"), + }, ]; pub fn latest_schema_version() -> u32 {