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:
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 {