app

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

commit 3480e0192957a43047f1dc41683434de3957819b
parent b6dc30950462f401104df6a6bd7e69c3113d12e3
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 18:03:39 +0000

app: store state records with migration

- write state records with checksum validation and revisions
- migrate legacy app state on read and reuse record APIs
- surface state errors in init and logging contexts
- update test datastores and lockfile dependencies

Diffstat:
MCargo.lock | 2++
Mapp/src/bootstrap.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mapp/src/data.rs | 2+-
Mapp/src/health.rs | 24+++++++++++++++++++++++-
Mapp/src/init.rs | 50+++++++++++++++++++++++++++++++++++++++++++++-----
Mapp/src/logging.rs | 1+
6 files changed, 133 insertions(+), 22 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1646,6 +1646,7 @@ dependencies = [ "console_error_panic_hook", "futures", "gloo-timers", + "hex", "js-sys", "leptos", "leptos_router", @@ -1653,6 +1654,7 @@ dependencies = [ "radroots-log", "serde", "serde_json", + "sha2", "tracing-wasm", "uuid", "wasm-bindgen", diff --git a/app/src/bootstrap.rs b/app/src/bootstrap.rs @@ -6,48 +6,94 @@ use radroots_app_core::notifications::RadrootsClientNotificationsPermission; use crate::{ app_datastore_obj_key_state, app_log_debug_emit, + app_state_record_new, + app_state_record_validate, + app_state_timestamp_ms, RadrootsAppState, + RadrootsAppStateError, + RadrootsAppStateRecord, RadrootsAppInitError, RadrootsAppInitResult, RadrootsAppKeyMapConfig, }; -pub async fn app_datastore_write_state<T: RadrootsClientDatastore>( +pub async fn app_datastore_write_state_record<T: RadrootsClientDatastore>( datastore: &T, key_maps: &RadrootsAppKeyMapConfig, - data: &RadrootsAppState, -) -> RadrootsAppInitResult<RadrootsAppState> { + record: &RadrootsAppStateRecord, +) -> RadrootsAppInitResult<RadrootsAppStateRecord> { let key = app_datastore_obj_key_state(key_maps).map_err(RadrootsAppInitError::Config)?; let value = datastore - .set_obj(key, data) + .set_obj(key, record) .await .map_err(RadrootsAppInitError::Datastore)?; let _ = app_log_debug_emit("log.app.bootstrap.state", "write", Some(key.to_string())); Ok(value) } +pub async fn app_datastore_read_state_record<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppInitResult<RadrootsAppStateRecord> { + let key = app_datastore_obj_key_state(key_maps).map_err(RadrootsAppInitError::Config)?; + match datastore.get_obj::<RadrootsAppStateRecord>(key).await { + Ok(record) => { + app_state_record_validate(&record).map_err(RadrootsAppInitError::State)?; + let _ = + app_log_debug_emit("log.app.bootstrap.state", "read", Some(key.to_string())); + Ok(record) + } + Err(RadrootsClientDatastoreError::NoResult) => { + match datastore.get_obj::<RadrootsAppState>(key).await { + Ok(state) => { + let record = app_state_record_new(state, 1, app_state_timestamp_ms()); + let value = app_datastore_write_state_record(datastore, key_maps, &record) + .await?; + Ok(value) + } + Err(RadrootsClientDatastoreError::NoResult) => { + Err(RadrootsAppInitError::State(RadrootsAppStateError::Missing)) + } + Err(err) => Err(RadrootsAppInitError::Datastore(err)), + } + } + Err(err) => Err(RadrootsAppInitError::Datastore(err)), + } +} + +pub async fn app_datastore_write_state<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + data: &RadrootsAppState, +) -> RadrootsAppInitResult<RadrootsAppState> { + let now_ms = app_state_timestamp_ms(); + let record = match app_datastore_read_state_record(datastore, key_maps).await { + Ok(existing) => app_state_record_new(data.clone(), existing.revision + 1, now_ms), + Err(RadrootsAppInitError::State(RadrootsAppStateError::Missing)) => { + app_state_record_new(data.clone(), 1, now_ms) + } + Err(err) => return Err(err), + }; + let value = app_datastore_write_state_record(datastore, key_maps, &record).await?; + Ok(value.state) +} + pub async fn app_datastore_read_state<T: RadrootsClientDatastore>( datastore: &T, key_maps: &RadrootsAppKeyMapConfig, ) -> RadrootsAppInitResult<RadrootsAppState> { - let key = app_datastore_obj_key_state(key_maps).map_err(RadrootsAppInitError::Config)?; - let value = datastore - .get_obj::<RadrootsAppState>(key) - .await - .map_err(RadrootsAppInitError::Datastore)?; - let _ = app_log_debug_emit("log.app.bootstrap.state", "read", Some(key.to_string())); - Ok(value) + let record = app_datastore_read_state_record(datastore, key_maps).await?; + Ok(record.state) } pub async fn app_datastore_has_state<T: RadrootsClientDatastore>( datastore: &T, key_maps: &RadrootsAppKeyMapConfig, ) -> RadrootsAppInitResult<bool> { - let key = app_datastore_obj_key_state(key_maps).map_err(RadrootsAppInitError::Config)?; - match datastore.get_obj::<RadrootsAppState>(key).await { + match app_datastore_read_state_record(datastore, key_maps).await { Ok(_) => Ok(true), - Err(RadrootsClientDatastoreError::NoResult) => Ok(false), - Err(err) => Err(RadrootsAppInitError::Datastore(err)), + Err(RadrootsAppInitError::State(RadrootsAppStateError::Missing)) => Ok(false), + Err(err) => Err(err), } } diff --git a/app/src/data.rs b/app/src/data.rs @@ -38,7 +38,7 @@ impl Default for RadrootsAppState { pub const APP_STATE_SCHEMA_VERSION: u32 = 1; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsAppStateError { Missing, Corrupt, diff --git a/app/src/health.rs b/app/src/health.rs @@ -378,7 +378,7 @@ mod tests { RadrootsAppHealthReport, }; use crate::app_log_buffer_drain; - use crate::RadrootsAppKeyMapConfig; + use crate::{RadrootsAppKeyMapConfig, RadrootsAppStateRecord}; use async_trait::async_trait; use radroots_app_core::datastore::{ RadrootsClientDatastore, @@ -398,6 +398,7 @@ mod tests { use radroots_app_core::idb::IDB_CONFIG_DATASTORE; use radroots_app_core::backup::RadrootsClientBackupDatastorePayload; use radroots_app_core::idb::RadrootsClientIdbConfig; + use std::cell::RefCell; use std::sync::Mutex; #[test] @@ -470,6 +471,7 @@ mod tests { struct TestDatastore { get_result: RadrootsClientDatastoreResult<String>, app_data: Option<crate::RadrootsAppState>, + record: RefCell<Option<RadrootsAppStateRecord>>, } fn datastore_err<T>() -> RadrootsClientDatastoreResult<T> { @@ -502,6 +504,12 @@ mod tests { where T: serde::Serialize + serde::de::DeserializeOwned + Clone, { + let encoded = serde_json::to_string(_value) + .map_err(|_| RadrootsClientDatastoreError::IdbUndefined)?; + if let Ok(parsed) = serde_json::from_str::<RadrootsAppStateRecord>(&encoded) { + *self.record.borrow_mut() = Some(parsed); + return Ok(_value.clone()); + } datastore_err() } @@ -516,6 +524,13 @@ mod tests { where T: serde::de::DeserializeOwned, { + if let Some(record) = self.record.borrow().as_ref() { + let serialized = serde_json::to_string(record) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + if let Ok(parsed) = serde_json::from_str(&serialized) { + return Ok(parsed); + } + } let Some(data) = self.app_data.as_ref() else { return Err(RadrootsClientDatastoreError::NoResult); }; @@ -636,6 +651,7 @@ mod tests { let datastore = TestDatastore { get_result: Err(RadrootsClientDatastoreError::NoResult), app_data: None, + record: RefCell::new(None), }; let keystore = TestKeystore { read_result: Err(RadrootsClientKeystoreError::MissingKey), @@ -655,6 +671,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: None, + record: RefCell::new(None), }; let keystore = TestKeystore { read_result: Err(RadrootsClientKeystoreError::MissingKey), @@ -674,6 +691,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: None, + record: RefCell::new(None), }; let keystore = TestKeystore { read_result: Ok("secret".to_string()), @@ -692,6 +710,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: Some(crate::RadrootsAppState::default()), + record: RefCell::new(None), }; let key_maps = crate::app_key_maps_default(); let result = futures::executor::block_on(app_health_check_state_active_key( @@ -709,6 +728,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: Some(state), + record: RefCell::new(None), }; let key_maps = crate::app_key_maps_default(); let result = futures::executor::block_on(app_health_check_state_active_key( @@ -726,6 +746,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: Some(state), + record: RefCell::new(None), }; let key_maps = crate::app_key_maps_default(); let result = futures::executor::block_on(app_health_check_state_active_key( @@ -742,6 +763,7 @@ mod tests { let datastore = TestDatastore { get_result: Ok("pub".to_string()), app_data: None, + record: RefCell::new(None), }; let key_maps = crate::app_key_maps_default(); let result = futures::executor::block_on(app_health_check_state_active_key_with_state( diff --git a/app/src/init.rs b/app/src/init.rs @@ -27,6 +27,7 @@ use crate::{ app_assets_sql_wasm_url, app_log_debug_emit, app_state_is_initialized, + RadrootsAppStateError, RadrootsAppConfig, RadrootsAppConfigError, RadrootsAppKeyMapConfig, @@ -251,6 +252,7 @@ pub enum RadrootsAppInitError { Keystore(RadrootsClientKeystoreError), Config(RadrootsAppConfigError), Assets(RadrootsAppInitAssetError), + State(RadrootsAppStateError), } pub type RadrootsAppInitErrorMessage = &'static str; @@ -263,6 +265,7 @@ impl RadrootsAppInitError { RadrootsAppInitError::Keystore(_) => "error.app.init.keystore", RadrootsAppInitError::Config(_) => "error.app.init.config", RadrootsAppInitError::Assets(_) => "error.app.init.assets", + RadrootsAppInitError::State(err) => err.message(), } } } @@ -431,7 +434,14 @@ mod tests { RadrootsAppInitStage, RadrootsAppInitAssetError, }; - use crate::{app_config_default, app_key_maps_default, RadrootsAppConfig, RadrootsAppState}; + use crate::{ + app_config_default, + app_key_maps_default, + RadrootsAppConfig, + RadrootsAppState, + RadrootsAppStateError, + RadrootsAppStateRecord, + }; use radroots_app_core::datastore::{ RadrootsClientDatastore, RadrootsClientDatastoreEntries, @@ -473,6 +483,10 @@ mod tests { RadrootsAppInitError::Assets(RadrootsAppInitAssetError::FetchUnavailable), "error.app.init.assets", ), + ( + RadrootsAppInitError::State(RadrootsAppStateError::Missing), + "error.app.state.missing", + ), ]; for (err, expected) in cases { assert_eq!(err.message(), *expected); @@ -617,8 +631,11 @@ mod tests { assert_eq!(result, RadrootsAppInitAssetError::FetchUnavailable); } + use std::cell::RefCell; + struct SetupDatastore { state: Option<RadrootsAppState>, + record: RefCell<Option<RadrootsAppStateRecord>>, } #[async_trait(?Send)] @@ -646,11 +663,17 @@ mod tests { async fn set_obj<T>( &self, _key: &str, - _value: &T, + value: &T, ) -> RadrootsClientDatastoreResult<T> where T: Serialize + DeserializeOwned + Clone, { + let encoded = serde_json::to_string(value) + .map_err(|_| RadrootsClientDatastoreError::IdbUndefined)?; + if let Ok(parsed) = serde_json::from_str::<RadrootsAppStateRecord>(&encoded) { + *self.record.borrow_mut() = Some(parsed); + return Ok(value.clone()); + } Err(RadrootsClientDatastoreError::IdbUndefined) } @@ -669,6 +692,13 @@ mod tests { where T: DeserializeOwned, { + if let Some(record) = self.record.borrow().as_ref() { + let encoded = serde_json::to_string(record) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + if let Ok(parsed) = serde_json::from_str(&encoded) { + return Ok(parsed); + } + }; let Some(state) = self.state.as_ref() else { return Err(RadrootsClientDatastoreError::NoResult); }; @@ -772,7 +802,10 @@ mod tests { #[test] fn app_init_needs_setup_when_state_missing() { - let datastore = SetupDatastore { state: None }; + let datastore = SetupDatastore { + state: None, + record: RefCell::new(None), + }; let keystore = SetupKeystore { read_result: Ok("secret".to_string()), }; @@ -790,6 +823,7 @@ mod tests { fn app_init_needs_setup_when_state_incomplete() { let datastore = SetupDatastore { state: Some(RadrootsAppState::default()), + record: RefCell::new(None), }; let keystore = SetupKeystore { read_result: Ok("secret".to_string()), @@ -809,7 +843,10 @@ mod tests { let mut state = RadrootsAppState::default(); state.active_key = "pub".to_string(); state.eula_date = "2025-01-01T00:00:00Z".to_string(); - let datastore = SetupDatastore { state: Some(state) }; + let datastore = SetupDatastore { + state: Some(state), + record: RefCell::new(None), + }; let keystore = SetupKeystore { read_result: Err(RadrootsClientKeystoreError::MissingKey), }; @@ -828,7 +865,10 @@ mod tests { let mut state = RadrootsAppState::default(); state.active_key = "pub".to_string(); state.eula_date = "2025-01-01T00:00:00Z".to_string(); - let datastore = SetupDatastore { state: Some(state) }; + let datastore = SetupDatastore { + state: Some(state), + record: RefCell::new(None), + }; let keystore = SetupKeystore { read_result: Ok("secret".to_string()), }; diff --git a/app/src/logging.rs b/app/src/logging.rs @@ -167,6 +167,7 @@ impl RadrootsAppLoggableError for RadrootsAppInitError { RadrootsAppInitError::Keystore(err) => Some(err.to_string()), RadrootsAppInitError::Config(err) => err.log_context().or_else(|| Some(err.message().to_string())), RadrootsAppInitError::Assets(err) => Some(err.message().to_string()), + RadrootsAppInitError::State(err) => Some(err.message().to_string()), } } }