app

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

commit 7a10b3a5bb7b140b0e2e372b1b9599297e38d24e
parent ca1eeb5ab858b311820463586d1014584b5faf3d
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 01:37:20 +0000

app-core: add idb store helpers

- add idb store error type and exports
- implement idb store ensure/bootstrap/exists for wasm
- add non-wasm fallbacks and unit tests
- enable web-sys indexeddb features for idb access

Diffstat:
MCargo.toml | 14+++++++++++++-
Acrates/core/src/idb/error.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/core/src/idb/mod.rs | 4++++
Acrates/core/src/idb/store.rs | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 370 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -19,7 +19,19 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" getrandom = "0.2" js-sys = "0.3.77" -web-sys = { version = "0.3.77", features = ["Crypto", "CryptoKey", "SubtleCrypto", "Window"] } +web-sys = { version = "0.3.77", features = [ + "Crypto", + "CryptoKey", + "SubtleCrypto", + "Window", + "DomException", + "DomStringList", + "Event", + "IdbDatabase", + "IdbFactory", + "IdbObjectStore", + "IdbOpenDbRequest", +] } wasm-bindgen-futures = "0.4" base64 = "0.22" diff --git a/crates/core/src/idb/error.rs b/crates/core/src/idb/error.rs @@ -0,0 +1,57 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsClientIdbStoreError { + IdbUndefined, + OperationFailure, + VersionError, +} + +pub type RadrootsClientIdbStoreErrorMessage = &'static str; + +impl RadrootsClientIdbStoreError { + pub const fn message(self) -> RadrootsClientIdbStoreErrorMessage { + match self { + RadrootsClientIdbStoreError::IdbUndefined => "error.client.idb.idb_undefined", + RadrootsClientIdbStoreError::OperationFailure => { + "error.client.idb.operation_failure" + } + RadrootsClientIdbStoreError::VersionError => "error.client.idb.version_error", + } + } +} + +impl fmt::Display for RadrootsClientIdbStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for RadrootsClientIdbStoreError {} + +#[cfg(test)] +mod tests { + use super::RadrootsClientIdbStoreError; + + #[test] + fn message_matches_spec() { + let cases = [ + ( + RadrootsClientIdbStoreError::IdbUndefined, + "error.client.idb.idb_undefined", + ), + ( + RadrootsClientIdbStoreError::OperationFailure, + "error.client.idb.operation_failure", + ), + ( + RadrootsClientIdbStoreError::VersionError, + "error.client.idb.version_error", + ), + ]; + for (err, expected) in cases { + assert_eq!(err.message(), expected); + assert_eq!(err.to_string(), expected); + } + } +} diff --git a/crates/core/src/idb/mod.rs b/crates/core/src/idb/mod.rs @@ -1,4 +1,6 @@ pub mod config; +pub mod error; +pub mod store; pub mod types; pub mod value; @@ -24,3 +26,5 @@ pub use config::{ }; pub use types::RadrootsClientIdbConfig; pub use value::{idb_value_as_bytes, RadrootsClientIdbValue}; +pub use error::{RadrootsClientIdbStoreError, RadrootsClientIdbStoreErrorMessage}; +pub use store::{idb_store_bootstrap, idb_store_ensure, idb_store_exists}; diff --git a/crates/core/src/idb/store.rs b/crates/core/src/idb/store.rs @@ -0,0 +1,296 @@ +#[cfg(target_arch = "wasm32")] +use crate::idb::{RADROOTS_IDB_DATABASE, RADROOTS_IDB_STORES}; + +use super::RadrootsClientIdbStoreError; + +#[cfg(target_arch = "wasm32")] +use js_sys::{Array, Promise, Reflect}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::closure::Closure; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::{JsCast, JsValue}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; +#[cfg(target_arch = "wasm32")] +use web_sys::{IdbDatabase, IdbFactory}; + +#[cfg(target_arch = "wasm32")] +fn idb_factory() -> Result<IdbFactory, RadrootsClientIdbStoreError> { + let window = web_sys::window().ok_or(RadrootsClientIdbStoreError::IdbUndefined)?; + let factory = window + .indexed_db() + .map_err(|_| RadrootsClientIdbStoreError::OperationFailure)?; + factory.ok_or(RadrootsClientIdbStoreError::IdbUndefined) +} + +#[cfg(target_arch = "wasm32")] +async fn idb_database_exists( + factory: &IdbFactory, + database: &str, +) -> Result<bool, RadrootsClientIdbStoreError> { + let promise = match factory.databases() { + Ok(promise) => promise, + Err(_) => return Ok(true), + }; + let value = JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientIdbStoreError::OperationFailure)?; + let list = Array::from(&value); + for entry in list.iter() { + let name = Reflect::get(&entry, &JsValue::from_str("name")) + .ok() + .and_then(|value| value.as_string()); + if name.as_deref() == Some(database) { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(target_arch = "wasm32")] +fn idb_missing_stores(db: &IdbDatabase, stores: &[String]) -> Vec<String> { + let names = db.object_store_names(); + stores + .iter() + .filter(|store| !names.contains(store)) + .cloned() + .collect() +} + +#[cfg(target_arch = "wasm32")] +fn map_open_error(err: JsValue) -> RadrootsClientIdbStoreError { + let Some(exception) = err.dyn_ref::<web_sys::DomException>() else { + return RadrootsClientIdbStoreError::OperationFailure; + }; + if exception.name() == "VersionError" { + RadrootsClientIdbStoreError::VersionError + } else { + RadrootsClientIdbStoreError::OperationFailure + } +} + +#[cfg(target_arch = "wasm32")] +async fn idb_open( + database: &str, + version: Option<u32>, + stores: &[String], +) -> Result<IdbDatabase, RadrootsClientIdbStoreError> { + let factory = idb_factory()?; + let request = match version { + Some(version) => factory + .open_with_u32(database, version) + .map_err(|_| RadrootsClientIdbStoreError::OperationFailure)?, + None => factory + .open(database) + .map_err(|_| RadrootsClientIdbStoreError::OperationFailure)?, + }; + let stores = stores.to_vec(); + let promise = Promise::new(&mut |resolve, reject| { + let request_success = request.clone(); + let resolve = resolve.clone(); + let reject_success = reject.clone(); + let on_success = Closure::wrap(Box::new(move |_event: web_sys::Event| { + match request_success.result() { + Ok(value) => { + let _ = resolve.call1(&JsValue::UNDEFINED, &value); + } + Err(err) => { + let _ = reject_success.call1(&JsValue::UNDEFINED, &err); + } + } + }) as Box<dyn FnMut(_)>); + request.set_onsuccess(Some(on_success.as_ref().unchecked_ref())); + on_success.forget(); + + let request_error = request.clone(); + let reject_error = reject.clone(); + let on_error = Closure::wrap(Box::new(move |_event: web_sys::Event| { + let err = request_error + .error() + .map(JsValue::from) + .unwrap_or_else(|| JsValue::from_str("idb_open_failed")); + let _ = reject_error.call1(&JsValue::UNDEFINED, &err); + }) as Box<dyn FnMut(_)>); + request.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + + let request_upgrade = request.clone(); + let stores_upgrade = stores.clone(); + let reject_upgrade = reject.clone(); + let on_upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| { + if stores_upgrade.is_empty() { + return; + } + let Ok(value) = request_upgrade.result() else { + let _ = reject_upgrade.call1( + &JsValue::UNDEFINED, + &JsValue::from_str("idb_open_failed"), + ); + return; + }; + let Ok(db) = value.dyn_into::<IdbDatabase>() else { + let _ = reject_upgrade.call1( + &JsValue::UNDEFINED, + &JsValue::from_str("idb_open_failed"), + ); + return; + }; + let names = db.object_store_names(); + for store in &stores_upgrade { + if names.contains(store) { + continue; + } + if db.create_object_store(store).is_err() { + let _ = reject_upgrade.call1( + &JsValue::UNDEFINED, + &JsValue::from_str("idb_store_create_failed"), + ); + return; + } + } + }) as Box<dyn FnMut(_)>); + request.set_onupgradeneeded(Some(on_upgrade.as_ref().unchecked_ref())); + on_upgrade.forget(); + }); + let value = JsFuture::from(promise).await.map_err(map_open_error)?; + value + .dyn_into::<IdbDatabase>() + .map_err(|_| RadrootsClientIdbStoreError::OperationFailure) +} + +#[cfg(target_arch = "wasm32")] +async fn idb_store_ensure_all( + database: &str, + stores: &[String], +) -> Result<(), RadrootsClientIdbStoreError> { + if stores.is_empty() { + return Ok(()); + } + let mut target_stores = stores.to_vec(); + target_stores.sort(); + target_stores.dedup(); + let mut attempt = 0; + while attempt < 5 { + attempt += 1; + let db = idb_open(database, None, &[]).await?; + let missing = idb_missing_stores(&db, &target_stores); + let version = db.version() as u32; + db.close(); + if missing.is_empty() { + return Ok(()); + } + let next_version = version.saturating_add(1); + match idb_open(database, Some(next_version), &missing).await { + Ok(upgraded) => { + let still_missing = idb_missing_stores(&upgraded, &target_stores); + upgraded.close(); + if still_missing.is_empty() { + return Ok(()); + } + } + Err(RadrootsClientIdbStoreError::VersionError) => continue, + Err(err) => return Err(err), + } + } + Err(RadrootsClientIdbStoreError::OperationFailure) +} + +#[cfg(target_arch = "wasm32")] +pub async fn idb_store_ensure( + database: &str, + store: &str, +) -> Result<(), RadrootsClientIdbStoreError> { + if database == RADROOTS_IDB_DATABASE { + idb_store_bootstrap(database, None).await?; + if RADROOTS_IDB_STORES.contains(&store) { + return Ok(()); + } + } + idb_store_ensure_all(database, &[store.to_string()]).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn idb_store_bootstrap( + database: &str, + stores: Option<&[&str]>, +) -> Result<(), RadrootsClientIdbStoreError> { + let target_stores: Vec<String> = match stores { + Some(stores) => stores.iter().map(|store| (*store).to_string()).collect(), + None if database == RADROOTS_IDB_DATABASE => RADROOTS_IDB_STORES + .iter() + .map(|store| (*store).to_string()) + .collect(), + None => Vec::new(), + }; + if target_stores.is_empty() { + return Ok(()); + } + idb_store_ensure_all(database, &target_stores).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn idb_store_exists( + database: &str, + store: &str, +) -> Result<bool, RadrootsClientIdbStoreError> { + let factory = idb_factory()?; + let known = idb_database_exists(&factory, database).await?; + if !known { + return Ok(false); + } + let db = idb_open(database, None, &[]).await?; + let exists = db.object_store_names().contains(store); + db.close(); + Ok(exists) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn idb_store_ensure( + _database: &str, + _store: &str, +) -> Result<(), RadrootsClientIdbStoreError> { + Err(RadrootsClientIdbStoreError::IdbUndefined) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn idb_store_bootstrap( + _database: &str, + _stores: Option<&[&str]>, +) -> Result<(), RadrootsClientIdbStoreError> { + Err(RadrootsClientIdbStoreError::IdbUndefined) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn idb_store_exists( + _database: &str, + _store: &str, +) -> Result<bool, RadrootsClientIdbStoreError> { + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::{idb_store_bootstrap, idb_store_ensure, idb_store_exists}; + use crate::idb::RadrootsClientIdbStoreError; + + #[test] + fn non_wasm_returns_idb_undefined() { + let err = futures::executor::block_on(idb_store_ensure("db", "store")) + .expect_err("idb undefined"); + assert_eq!(err, RadrootsClientIdbStoreError::IdbUndefined); + } + + #[test] + fn non_wasm_bootstrap_returns_idb_undefined() { + let err = futures::executor::block_on(idb_store_bootstrap("db", None)) + .expect_err("idb undefined"); + assert_eq!(err, RadrootsClientIdbStoreError::IdbUndefined); + } + + #[test] + fn non_wasm_exists_returns_false() { + let exists = futures::executor::block_on(idb_store_exists("db", "store")) + .expect("exists"); + assert!(!exists); + } +}