commit 330b2fffc1512532a61eb1d79dd1514f6b4c4a44
parent 30312865604c202a6d12db2c8bd66bad72b95c2b
Author: triesap <tyson@radroots.org>
Date: Mon, 2 Feb 2026 20:06:04 +0000
app: add idempotent setup commit
- add setup commit using datastore batch writes
- return already-initialized on mismatch
- verify stored state and active key
- add commit idempotency unit tests
Diffstat:
2 files changed, 154 insertions(+), 5 deletions(-)
diff --git a/app/src/lib.rs b/app/src/lib.rs
@@ -136,6 +136,7 @@ pub use logging::{
pub use notifications::{RadrootsAppNotifications, RadrootsAppNotificationsError, RadrootsAppNotificationsResult};
pub use setup::{
app_setup_eula_date,
+ app_setup_commit,
app_setup_finalize_with_key,
app_setup_initialize,
app_setup_state_new,
diff --git a/app/src/setup.rs b/app/src/setup.rs
@@ -1,6 +1,6 @@
#![forbid(unsafe_code)]
-use radroots_app_core::datastore::RadrootsClientDatastore;
+use radroots_app_core::datastore::{RadrootsClientDatastore, RadrootsClientDatastoreEntry};
use radroots_app_core::keystore::{RadrootsClientKeystoreError, RadrootsClientKeystoreNostr};
#[cfg(target_arch = "wasm32")]
@@ -10,18 +10,22 @@ use js_sys::Date;
use chrono::{SecondsFormat, Utc};
use crate::{
- app_datastore_create_state,
app_datastore_key_nostr_key,
+ app_datastore_obj_key_state,
+ app_datastore_read_state,
app_default_relays,
app_keystore_nostr_ensure_key,
app_keystore_nostr_verify_key,
app_log_debug_emit,
+ app_state_record_new,
+ app_state_timestamp_ms,
RadrootsAppInitError,
RadrootsAppInitResult,
RadrootsAppKeyMapConfig,
RadrootsAppKeystoreError,
RadrootsAppRole,
RadrootsAppState,
+ RadrootsAppStateError,
APP_EULA_HASH,
APP_EULA_VERSION,
};
@@ -138,21 +142,83 @@ pub async fn app_setup_finalize_with_key<T: RadrootsClientDatastore>(
nip05_key: Option<String>,
role: RadrootsAppRole,
) -> RadrootsAppInitResult<RadrootsAppState> {
+ let stored_state = app_setup_commit(
+ datastore,
+ key_maps,
+ active_key.clone(),
+ eula_date,
+ nip05_key,
+ role,
+ )
+ .await?;
+ let _ = app_log_debug_emit("log.app.setup", "created", Some(format!("key={active_key}")));
+ Ok(stored_state)
+}
+
+pub async fn app_setup_commit<T: RadrootsClientDatastore>(
+ datastore: &T,
+ key_maps: &RadrootsAppKeyMapConfig,
+ active_key: String,
+ eula_date: String,
+ nip05_key: Option<String>,
+ role: RadrootsAppRole,
+) -> RadrootsAppInitResult<RadrootsAppState> {
let mut state = app_setup_state_new(active_key.clone(), eula_date, role);
state.nip05_key = nip05_key;
- let stored_state = app_datastore_create_state(datastore, key_maps, &state).await?;
+ match app_datastore_read_state(datastore, key_maps).await {
+ Ok(existing) => {
+ if existing == state {
+ let key_name =
+ app_datastore_key_nostr_key(key_maps).map_err(RadrootsAppInitError::Config)?;
+ let stored_key = datastore
+ .get(key_name)
+ .await
+ .map_err(RadrootsAppInitError::Datastore)?;
+ if stored_key != active_key {
+ return Err(RadrootsAppInitError::State(RadrootsAppStateError::Corrupt));
+ }
+ return Ok(existing);
+ }
+ return Err(RadrootsAppInitError::State(
+ RadrootsAppStateError::AlreadyExists,
+ ));
+ }
+ Err(RadrootsAppInitError::State(RadrootsAppStateError::Missing)) => {}
+ Err(err) => return Err(err),
+ }
+ let now_ms = app_state_timestamp_ms();
+ let record = app_state_record_new(state.clone(), 1, now_ms);
+ let encoded = serde_json::to_string(&record)
+ .map_err(|_| RadrootsAppInitError::State(RadrootsAppStateError::Corrupt))?;
let key_name = app_datastore_key_nostr_key(key_maps).map_err(RadrootsAppInitError::Config)?;
+ let state_key =
+ app_datastore_obj_key_state(key_maps).map_err(RadrootsAppInitError::Config)?;
+ let entries = [
+ RadrootsClientDatastoreEntry::new(state_key, Some(encoded)),
+ RadrootsClientDatastoreEntry::new(key_name, Some(active_key.clone())),
+ ];
datastore
- .set(key_name, &active_key)
+ .set_entries(&entries)
.await
.map_err(RadrootsAppInitError::Datastore)?;
- let _ = app_log_debug_emit("log.app.setup", "created", Some(format!("key={active_key}")));
+ let stored_state = app_datastore_read_state(datastore, key_maps).await?;
+ if stored_state != state {
+ return Err(RadrootsAppInitError::State(RadrootsAppStateError::Corrupt));
+ }
+ let stored_key = datastore
+ .get(key_name)
+ .await
+ .map_err(RadrootsAppInitError::Datastore)?;
+ if stored_key != active_key {
+ return Err(RadrootsAppInitError::State(RadrootsAppStateError::Corrupt));
+ }
Ok(stored_state)
}
#[cfg(test)]
mod tests {
use super::{
+ app_setup_commit,
app_setup_eula_date,
app_setup_finalize_with_key,
app_setup_initialize,
@@ -163,7 +229,9 @@ mod tests {
use crate::{
app_datastore_key_nostr_key,
app_key_maps_default,
+ RadrootsAppInitError,
RadrootsAppRole,
+ RadrootsAppStateError,
RadrootsAppStateRecord,
APP_EULA_HASH,
APP_EULA_VERSION,
@@ -173,6 +241,7 @@ mod tests {
use radroots_app_core::datastore::{
RadrootsClientDatastore,
RadrootsClientDatastoreEntries,
+ RadrootsClientDatastoreEntry,
RadrootsClientDatastoreError,
RadrootsClientDatastoreResult,
};
@@ -253,6 +322,25 @@ mod tests {
.ok_or(RadrootsClientDatastoreError::NoResult)
}
+ async fn set_entries(
+ &self,
+ entries: &[RadrootsClientDatastoreEntry],
+ ) -> RadrootsClientDatastoreResult<()> {
+ for entry in entries {
+ let Some(value) = entry.value.as_deref() else {
+ continue;
+ };
+ if let Ok(parsed) = serde_json::from_str::<RadrootsAppStateRecord>(value) {
+ *self.record.borrow_mut() = Some(parsed);
+ continue;
+ }
+ self.values
+ .borrow_mut()
+ .insert(entry.key.clone(), value.to_string());
+ }
+ Ok(())
+ }
+
async fn set_obj<T>(
&self,
_key: &str,
@@ -511,4 +599,64 @@ mod tests {
assert_eq!(stored, "pub");
assert!(datastore.record.borrow().is_some());
}
+
+ #[test]
+ fn setup_commit_is_idempotent() {
+ let datastore = TestDatastore {
+ record: RefCell::new(None),
+ values: RefCell::new(BTreeMap::new()),
+ };
+ let key_maps = app_key_maps_default();
+ let state = futures::executor::block_on(app_setup_commit(
+ &datastore,
+ &key_maps,
+ "pub".to_string(),
+ "2025-01-01T00:00:00Z".to_string(),
+ None,
+ RadrootsAppRole::default(),
+ ))
+ .expect("commit");
+ assert_eq!(state.active_key, "pub");
+ let state_again = futures::executor::block_on(app_setup_commit(
+ &datastore,
+ &key_maps,
+ "pub".to_string(),
+ "2025-01-01T00:00:00Z".to_string(),
+ None,
+ RadrootsAppRole::default(),
+ ))
+ .expect("commit");
+ assert_eq!(state_again.active_key, "pub");
+ }
+
+ #[test]
+ fn setup_commit_rejects_mismatch() {
+ let datastore = TestDatastore {
+ record: RefCell::new(None),
+ values: RefCell::new(BTreeMap::new()),
+ };
+ let key_maps = app_key_maps_default();
+ let _state = futures::executor::block_on(app_setup_commit(
+ &datastore,
+ &key_maps,
+ "pub".to_string(),
+ "2025-01-01T00:00:00Z".to_string(),
+ None,
+ RadrootsAppRole::default(),
+ ))
+ .expect("commit");
+ let err = futures::executor::block_on(app_setup_commit(
+ &datastore,
+ &key_maps,
+ "other".to_string(),
+ "2025-01-01T00:00:00Z".to_string(),
+ None,
+ RadrootsAppRole::default(),
+ ))
+ .expect_err("mismatch");
+ assert_eq!(
+ err,
+ RadrootsAppInitError::State(RadrootsAppStateError::AlreadyExists)
+ );
+ }
}