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:
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()),
}
}
}