app

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

commit 2ccc39500555c57f8f167e52d68ae0b42d8d5f7d
parent 94b0add29d1154b1a714472a1d711ddbca50c2c9
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 02:02:50 +0000

app-core: add web datastore implementation

- implement encrypted web datastore with idb backing
- add key/param helpers, json merge, and backup import/export
- map idb/crypto failures to datastore errors
- add unit tests for param keys and non-wasm errors

Diffstat:
Mcrates/core/src/datastore/mod.rs | 2++
Acrates/core/src/datastore/web.rs | 520+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 522 insertions(+), 0 deletions(-)

diff --git a/crates/core/src/datastore/mod.rs b/crates/core/src/datastore/mod.rs @@ -1,5 +1,6 @@ pub mod error; pub mod types; +pub mod web; pub use error::{RadrootsClientDatastoreError, RadrootsClientDatastoreErrorMessage}; pub use types::{ @@ -9,3 +10,4 @@ pub use types::{ RadrootsClientDatastoreResult, RadrootsClientDatastoreValue, }; +pub use web::RadrootsClientWebDatastore; diff --git a/crates/core/src/datastore/web.rs b/crates/core/src/datastore/web.rs @@ -0,0 +1,520 @@ +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::backup::RadrootsClientBackupDatastorePayload; +#[cfg(target_arch = "wasm32")] +use crate::crypto::RadrootsClientCryptoError; +use crate::idb::{IDB_CONFIG_DATASTORE, RadrootsClientIdbConfig}; +#[cfg(target_arch = "wasm32")] +use crate::idb::RadrootsClientIdbStoreError; +use crate::idb::{RadrootsClientWebEncryptedStore, RadrootsClientWebEncryptedStoreConfig}; + +use super::{ + RadrootsClientDatastore, + RadrootsClientDatastoreEntries, + RadrootsClientDatastoreError, + RadrootsClientDatastoreResult, +}; + +#[cfg(target_arch = "wasm32")] +use super::RadrootsClientDatastoreEntry; + +const DATASTORE_STORE_PREFIX: &str = "datastore"; +const DEFAULT_IV_LENGTH: u32 = 12; + +pub struct RadrootsClientWebDatastore { + encrypted_store: RadrootsClientWebEncryptedStore, +} + +impl RadrootsClientWebDatastore { + pub fn new(config: Option<RadrootsClientIdbConfig>) -> Self { + let idb_config = config.unwrap_or(IDB_CONFIG_DATASTORE); + let store_id = format!( + "{}:{}:{}", + DATASTORE_STORE_PREFIX, idb_config.database, idb_config.store + ); + let encrypted_store = RadrootsClientWebEncryptedStore::new( + RadrootsClientWebEncryptedStoreConfig { + idb_config, + store_id, + legacy_key: None, + iv_length: Some(DEFAULT_IV_LENGTH), + crypto_service: None, + }, + ); + Self { encrypted_store } + } + + #[cfg(target_arch = "wasm32")] + async fn decrypt_value( + &self, + store_key: &str, + stored: crate::idb::RadrootsClientIdbValue, + ) -> RadrootsClientDatastoreResult<String> { + if let Some(text) = stored.as_string() { + let encrypted = self + .encrypted_store + .encrypt_bytes(text.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(store_key, &encrypted).await?; + return Ok(text); + } + let Some(bytes) = crate::idb::idb_value_as_bytes(&stored) else { + return Err(RadrootsClientDatastoreError::NoResult); + }; + let outcome = self + .encrypted_store + .decrypt_record(&bytes) + .await + .map_err(map_crypto_error)?; + if let Some(reencrypted) = outcome.reencrypted { + self.store_encrypted(store_key, &reencrypted).await?; + } + String::from_utf8(outcome.plaintext) + .map_err(|_| RadrootsClientDatastoreError::NoResult) + } + + #[cfg(target_arch = "wasm32")] + async fn store_encrypted( + &self, + store_key: &str, + bytes: &[u8], + ) -> RadrootsClientDatastoreResult<()> { + let value = js_sys::Uint8Array::from(bytes); + crate::idb::idb_set( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + store_key, + &value.into(), + ) + .await + .map_err(map_idb_error) + } + + fn param_key(key: &str, key_param: &str) -> String { + format!("{key}:{key_param}") + } +} + +#[async_trait(?Send)] +impl RadrootsClientDatastore for RadrootsClientWebDatastore { + fn get_config(&self) -> RadrootsClientIdbConfig { + self.encrypted_store.get_config() + } + + fn get_store_id(&self) -> &str { + self.encrypted_store.get_store_id() + } + + async fn init(&self) -> RadrootsClientDatastoreResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + self.encrypted_store + .ensure_store() + .await + .map_err(map_crypto_error)?; + Ok(()) + } + } + + async fn set(&self, key: &str, value: &str) -> RadrootsClientDatastoreResult<String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (key, value); + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let encrypted = self + .encrypted_store + .encrypt_bytes(value.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(key, &encrypted).await?; + Ok(value.to_string()) + } + } + + async fn get(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key; + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + key, + ) + .await + .map_err(map_idb_error)?; + let Some(stored) = stored else { + return Err(RadrootsClientDatastoreError::NoResult); + }; + self.decrypt_value(key, stored).await + } + } + + async fn set_obj<T>(&self, key: &str, value: &T) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (key, value); + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let serialized = serde_json::to_string(value) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + let encrypted = self + .encrypted_store + .encrypt_bytes(serialized.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(key, &encrypted).await?; + Ok(value.clone()) + } + } + + async fn update_obj<T>(&self, key: &str, value: &T) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (key, value); + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + key, + ) + .await + .map_err(map_idb_error)?; + let mut base = if let Some(stored) = stored { + let decrypted = self.decrypt_value(key, stored).await?; + serde_json::from_str(&decrypted) + .map_err(|_| RadrootsClientDatastoreError::NoResult)? + } else { + serde_json::Value::Object(Default::default()) + }; + let update = serde_json::to_value(value) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + merge_json(&mut base, update); + let updated: T = serde_json::from_value(base) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + let serialized = serde_json::to_string(&updated) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + let encrypted = self + .encrypted_store + .encrypt_bytes(serialized.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(key, &encrypted).await?; + Ok(updated) + } + } + + async fn get_obj<T>(&self, key: &str) -> RadrootsClientDatastoreResult<T> + where + T: DeserializeOwned, + { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key; + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + key, + ) + .await + .map_err(map_idb_error)?; + let Some(stored) = stored else { + return Err(RadrootsClientDatastoreError::NoResult); + }; + let decrypted = self.decrypt_value(key, stored).await?; + serde_json::from_str(&decrypted) + .map_err(|_| RadrootsClientDatastoreError::NoResult) + } + } + + async fn del_obj(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key; + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_del( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + key, + ) + .await + .map_err(map_idb_error)?; + Ok(key.to_string()) + } + } + + async fn del(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + self.del_obj(key).await + } + + async fn del_pref(&self, key_prefix: &str) -> RadrootsClientDatastoreResult<Vec<String>> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = key_prefix; + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let keys = crate::idb::idb_keys( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + ) + .await + .map_err(map_idb_error)?; + let prefixed: Vec<String> = keys + .into_iter() + .filter(|key| key.starts_with(key_prefix)) + .collect(); + for key in &prefixed { + crate::idb::idb_del( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + key, + ) + .await + .map_err(map_idb_error)?; + } + Ok(prefixed) + } + } + + async fn set_param( + &self, + key: &str, + key_param: &str, + value: &str, + ) -> RadrootsClientDatastoreResult<String> { + let store_key = Self::param_key(key, key_param); + self.set(&store_key, value).await + } + + async fn get_param( + &self, + key: &str, + key_param: &str, + ) -> RadrootsClientDatastoreResult<String> { + let store_key = Self::param_key(key, key_param); + self.get(&store_key).await + } + + async fn keys(&self) -> RadrootsClientDatastoreResult<Vec<String>> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_keys( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + ) + .await + .map_err(map_idb_error) + } + } + + async fn entries(&self) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let keys = crate::idb::idb_keys( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + ) + .await + .map_err(map_idb_error)?; + let mut out = Vec::new(); + for key in keys { + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &key, + ) + .await + .map_err(map_idb_error)?; + let value = if let Some(stored) = stored { + Some(self.decrypt_value(&key, stored).await?) + } else { + None + }; + out.push(RadrootsClientDatastoreEntry::new(key, value)); + } + Ok(out) + } + } + + async fn reset(&self) -> RadrootsClientDatastoreResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + crate::idb::idb_clear( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + ) + .await + .map_err(map_idb_error)?; + let index = crate::crypto::crypto_registry_get_store_index( + self.encrypted_store.get_store_id(), + ) + .await + .map_err(map_crypto_error)?; + if let Some(index) = index { + crate::crypto::crypto_registry_clear_store_index( + self.encrypted_store.get_store_id(), + ) + .await + .map_err(map_crypto_error)?; + for key_id in index.key_ids { + crate::crypto::crypto_registry_clear_key_entry(&key_id) + .await + .map_err(map_crypto_error)?; + } + } + Ok(()) + } + } + + async fn export_backup( + &self, + ) -> RadrootsClientDatastoreResult<RadrootsClientBackupDatastorePayload> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + let keys = self.keys().await?; + let mut entries = Vec::new(); + for key in keys { + let stored = crate::idb::idb_get( + self.encrypted_store.get_config().database, + self.encrypted_store.get_config().store, + &key, + ) + .await + .map_err(map_idb_error)?; + let Some(stored) = stored else { + return Err(RadrootsClientDatastoreError::NoResult); + }; + let value = self.decrypt_value(&key, stored).await?; + entries.push(crate::backup::RadrootsClientBackupDatastoreEntry { + key, + value, + }); + } + Ok(RadrootsClientBackupDatastorePayload { entries }) + } + } + + async fn import_backup( + &self, + payload: RadrootsClientBackupDatastorePayload, + ) -> RadrootsClientDatastoreResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = payload; + return Err(RadrootsClientDatastoreError::IdbUndefined); + } + #[cfg(target_arch = "wasm32")] + { + for entry in payload.entries { + let encrypted = self + .encrypted_store + .encrypt_bytes(entry.value.as_bytes()) + .await + .map_err(map_crypto_error)?; + self.store_encrypted(&entry.key, &encrypted).await?; + } + Ok(()) + } + } +} + +#[cfg(target_arch = "wasm32")] +fn map_crypto_error(err: RadrootsClientCryptoError) -> RadrootsClientDatastoreError { + match err { + RadrootsClientCryptoError::IdbUndefined | RadrootsClientCryptoError::CryptoUndefined => { + RadrootsClientDatastoreError::IdbUndefined + } + _ => RadrootsClientDatastoreError::NoResult, + } +} + +#[cfg(target_arch = "wasm32")] +fn map_idb_error(err: RadrootsClientIdbStoreError) -> RadrootsClientDatastoreError { + match err { + RadrootsClientIdbStoreError::IdbUndefined => RadrootsClientDatastoreError::IdbUndefined, + _ => RadrootsClientDatastoreError::NoResult, + } +} + +#[cfg(target_arch = "wasm32")] +fn merge_json(base: &mut serde_json::Value, update: serde_json::Value) { + match (base, update) { + (serde_json::Value::Object(base_map), serde_json::Value::Object(update_map)) => { + for (key, value) in update_map { + base_map.insert(key, value); + } + } + (base_value, update_value) => { + *base_value = update_value; + } + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsClientWebDatastore; + use crate::datastore::RadrootsClientDatastore; + + #[test] + fn param_key_uses_colon_separator() { + let key = RadrootsClientWebDatastore::param_key("alpha", "beta"); + assert_eq!(key, "alpha:beta"); + } + + #[test] + fn non_wasm_get_errors() { + let store = RadrootsClientWebDatastore::new(None); + let err = futures::executor::block_on(store.get("key")) + .expect_err("idb undefined"); + assert_eq!(err, crate::datastore::RadrootsClientDatastoreError::IdbUndefined); + } +}