sal/src/hero_vault/kvs/indexed_db_store.rs
Sameh Abouelsaad ae687f17f5 feat: Add WebAssembly support and IndexedDB storage
- Add WebAssembly target support to build for web browsers.
- Implement IndexedDB-backed key-value store for WASM.
- Add new WASM dependencies to Cargo.toml.
- Improve large number handling in Rhai example script.
- Refactor key-value store to handle both SlateDB and IndexedDB.
2025-05-11 19:46:21 +03:00

701 lines
31 KiB
Rust

//! IndexedDB-backed key-value store implementation for WebAssembly.
use crate::hero_vault::kvs::error::{KvsError, Result};
use serde::{de::DeserializeOwned, Serialize};
use std::sync::{Arc, Mutex};
use cfg_if::cfg_if;
// This implementation is only available for WebAssembly
cfg_if! {
if #[cfg(target_arch = "wasm32")] {
use wasm_bindgen::prelude::*;
use js_sys::{Promise, Object, Reflect, Array};
use web_sys::{
IdbDatabase, IdbOpenDbRequest, IdbFactory,
IdbTransaction, IdbObjectStore, IdbKeyRange,
window
};
use std::collections::HashMap;
use wasm_bindgen_futures::JsFuture;
/// A key-value store backed by IndexedDB for WebAssembly environments.
#[derive(Clone)]
pub struct IndexedDbStore {
/// The name of the store
name: String,
/// The IndexedDB database
db: Arc<Mutex<Option<IdbDatabase>>>,
/// Cache of key-value pairs to avoid frequent IndexedDB accesses
cache: Arc<Mutex<HashMap<String, String>>>,
/// Whether the store is encrypted
encrypted: bool,
/// Object store name within IndexedDB
store_name: String,
}
impl IndexedDbStore {
/// Creates a new IndexedDbStore.
///
/// Note: In WebAssembly, this function must be called in an async context.
pub async fn new(name: &str, encrypted: bool) -> Result<Self> {
let window = window().ok_or_else(|| KvsError::Other("No window object available".to_string()))?;
let indexed_db = window.indexed_db()
.map_err(|_| KvsError::Other("Failed to get IndexedDB factory".to_string()))?
.ok_or_else(|| KvsError::Other("IndexedDB not available".to_string()))?;
// The store name in IndexedDB
let store_name = "kvs-data";
// Open the database
let db_name = format!("hero-vault-{}", name);
let open_request = indexed_db.open_with_u32(&db_name, 1)
.map_err(|_| KvsError::Other("Failed to open IndexedDB database".to_string()))?;
// Set up database schema on upgrade needed
let store_name_clone = store_name.clone();
let upgrade_needed_closure = Closure::wrap(Box::new(move |event: web_sys::IdbVersionChangeEvent| {
let db = event.target()
.and_then(|target| target.dyn_into::<IdbOpenDbRequest>().ok())
.and_then(|request| request.result().ok())
.and_then(|result| result.dyn_into::<IdbDatabase>().ok());
if let Some(db) = db {
// Create the object store if it doesn't exist
if !Array::from(&db.object_store_names()).includes(&JsValue::from_str(&store_name_clone)) {
db.create_object_store(&store_name_clone)
.expect("Failed to create object store");
}
}
}) as Box<dyn FnMut(_)>);
open_request.set_onupgradeneeded(Some(upgrade_needed_closure.as_ref().unchecked_ref()));
upgrade_needed_closure.forget();
// Wait for the database to open
let request_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
resolve.call0(&JsValue::NULL)
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
open_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
open_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
JsFuture::from(request_promise)
.await
.map_err(|_| KvsError::Other("Failed to open IndexedDB database".to_string()))?;
// Get the database object
let db = open_request.result()
.map_err(|_| KvsError::Other("Failed to get IndexedDB database".to_string()))?
.dyn_into::<IdbDatabase>()
.map_err(|_| KvsError::Other("Invalid database object".to_string()))?;
// Initialize the cache by loading all keys and values
let cache = Arc::new(Mutex::new(HashMap::new()));
// Create the store
let store = IndexedDbStore {
name: name.to_string(),
db: Arc::new(Mutex::new(Some(db))),
cache,
encrypted,
store_name: store_name.to_string(),
};
// Initialize the cache
store.initialize_cache().await?;
Ok(store)
}
/// Initializes the cache by loading all keys and values from IndexedDB.
async fn initialize_cache(&self) -> Result<()> {
// Get the database
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readonly")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Open a cursor to iterate through all entries
let cursor_request = store.open_cursor()
.map_err(|_| KvsError::Other("Failed to open cursor".to_string()))?;
// Load all entries into the cache
let cache = Arc::clone(&self.cache);
let load_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
resolve.call0(&JsValue::NULL)
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
let onsuccess = Closure::wrap(Box::new(move |event: web_sys::Event| {
let cursor = event
.target()
.and_then(|target| target.dyn_into::<web_sys::IdbRequest>().ok())
.and_then(|request| request.result().ok())
.and_then(|result| result.dyn_into::<web_sys::IdbCursorWithValue>().ok());
if let Some(cursor) = cursor {
// Get the key and value
let key = cursor.key().as_string()
.expect("Failed to get key as string");
let value = cursor.value()
.as_string()
.expect("Failed to get value as string");
// Add to cache
let mut cache_lock = cache.lock().unwrap();
cache_lock.insert(key, value);
// Continue to next entry
cursor.continue_()
.expect("Failed to continue cursor");
} else {
// No more entries, resolve the promise
success_callback.as_ref().unchecked_ref::<js_sys::Function>()
.call0(&JsValue::NULL)
.expect("Failed to call success callback");
}
}) as Box<dyn FnMut(_)>);
cursor_request.set_onsuccess(Some(onsuccess.as_ref().unchecked_ref()));
cursor_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
onsuccess.forget();
error_callback.forget();
});
JsFuture::from(load_promise)
.await
.map_err(|_| KvsError::Other("Failed to load cache".to_string()))?;
Ok(())
}
/// Sets a value in IndexedDB and updates the cache.
async fn set_in_db<K, V>(&self, key: K, value: &V) -> Result<()>
where
K: ToString,
V: Serialize,
{
let key_str = key.to_string();
let serialized = serde_json::to_string(value)?;
// Get the database
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readwrite")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Put the value in the store
let put_request = store.put_with_key(&JsValue::from_str(&serialized), &JsValue::from_str(&key_str))
.map_err(|_| KvsError::Other("Failed to put value in store".to_string()))?;
// Wait for the request to complete
let put_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
resolve.call0(&JsValue::NULL)
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
put_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
put_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
JsFuture::from(put_promise)
.await
.map_err(|_| KvsError::Other("Failed to put value in store".to_string()))?;
// Update the cache
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.insert(key_str, serialized);
Ok(())
}
/// Gets a value from the cache or IndexedDB.
async fn get_from_db<K>(&self, key: K) -> Result<Option<String>>
where
K: ToString,
{
let key_str = key.to_string();
// Check the cache first
{
let cache_lock = self.cache.lock().unwrap();
if let Some(value) = cache_lock.get(&key_str) {
return Ok(Some(value.clone()));
}
}
// If not in cache, get from IndexedDB
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readonly")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Get the value from the store
let get_request = store.get(&JsValue::from_str(&key_str))
.map_err(|_| KvsError::Other("Failed to get value from store".to_string()))?;
// Wait for the request to complete
let value = JsFuture::from(get_request.into())
.await
.map_err(|_| KvsError::Other("Failed to get value from store".to_string()))?;
if value.is_undefined() || value.is_null() {
return Ok(None);
}
let value_str = value.as_string()
.ok_or_else(|| KvsError::Deserialization("Failed to convert value to string".to_string()))?;
// Update the cache
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.insert(key_str, value_str.clone());
Ok(Some(value_str))
}
/// Deletes a value from IndexedDB and the cache.
async fn delete_from_db<K>(&self, key: K) -> Result<bool>
where
K: ToString,
{
let key_str = key.to_string();
// Check if the key exists in cache
let exists_in_cache = {
let cache_lock = self.cache.lock().unwrap();
cache_lock.contains_key(&key_str)
};
// Get the database
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readwrite")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Check if the key exists in IndexedDB
let key_range = IdbKeyRange::only(&JsValue::from_str(&key_str))
.map_err(|_| KvsError::Other("Failed to create key range".to_string()))?;
let count_request = store.count_with_key(&key_range)
.map_err(|_| KvsError::Other("Failed to count key".to_string()))?;
let count_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |event: web_sys::Event| {
let request = event
.target()
.and_then(|target| target.dyn_into::<web_sys::IdbRequest>().ok())
.expect("Failed to get request");
resolve.call1(&JsValue::NULL, &request.result().unwrap())
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
count_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
count_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
let count = JsFuture::from(count_promise)
.await
.map_err(|_| KvsError::Other("Failed to count key".to_string()))?;
let exists_in_db = count.as_f64().unwrap_or(0.0) > 0.0;
if !exists_in_cache && !exists_in_db {
return Ok(false);
}
// Delete the key from IndexedDB
let delete_request = store.delete(&JsValue::from_str(&key_str))
.map_err(|_| KvsError::Other("Failed to delete key".to_string()))?;
let delete_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
resolve.call0(&JsValue::NULL)
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
delete_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
delete_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
JsFuture::from(delete_promise)
.await
.map_err(|_| KvsError::Other("Failed to delete key".to_string()))?;
// Remove from cache
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.remove(&key_str);
Ok(true)
}
/// Gets all keys from IndexedDB.
async fn get_all_keys(&self) -> Result<Vec<String>> {
// Try to get keys from cache first
{
let cache_lock = self.cache.lock().unwrap();
if !cache_lock.is_empty() {
return Ok(cache_lock.keys().cloned().collect());
}
}
// Get the database
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readonly")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Get all keys
let keys_request = store.get_all_keys()
.map_err(|_| KvsError::Other("Failed to get keys".to_string()))?;
let keys_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |event: web_sys::Event| {
let request = event
.target()
.and_then(|target| target.dyn_into::<web_sys::IdbRequest>().ok())
.expect("Failed to get request");
resolve.call1(&JsValue::NULL, &request.result().unwrap())
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
keys_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
keys_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
let keys_array = JsFuture::from(keys_promise)
.await
.map_err(|_| KvsError::Other("Failed to get keys".to_string()))?;
let keys_array = Array::from(&keys_array);
let mut keys = Vec::new();
for i in 0..keys_array.length() {
let key = keys_array.get(i);
if let Some(key_str) = key.as_string() {
keys.push(key_str);
}
}
Ok(keys)
}
/// Clears all key-value pairs from the store.
async fn clear_db(&self) -> Result<()> {
// Get the database
let db_guard = self.db.lock().unwrap();
let db = db_guard.as_ref()
.ok_or_else(|| KvsError::Other("Database not initialized".to_string()))?;
// Create a transaction
let transaction = db.transaction_with_str_and_mode(&self.store_name, "readwrite")
.map_err(|_| KvsError::Other("Failed to create transaction".to_string()))?;
// Get the object store
let store = transaction.object_store(&self.store_name)
.map_err(|_| KvsError::Other("Failed to get object store".to_string()))?;
// Clear the store
let clear_request = store.clear()
.map_err(|_| KvsError::Other("Failed to clear store".to_string()))?;
let clear_promise = Promise::new(&mut |resolve, reject| {
let success_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
resolve.call0(&JsValue::NULL)
.expect("Failed to resolve promise");
}) as Box<dyn FnMut(_)>);
let error_callback = Closure::wrap(Box::new(move |_event: web_sys::Event| {
reject.call0(&JsValue::NULL)
.expect("Failed to reject promise");
}) as Box<dyn FnMut(_)>);
clear_request.set_onsuccess(Some(success_callback.as_ref().unchecked_ref()));
clear_request.set_onerror(Some(error_callback.as_ref().unchecked_ref()));
success_callback.forget();
error_callback.forget();
});
JsFuture::from(clear_promise)
.await
.map_err(|_| KvsError::Other("Failed to clear store".to_string()))?;
// Clear the cache
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.clear();
Ok(())
}
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
} else {
// For non-WebAssembly targets, provide a placeholder implementation
use std::fmt;
/// A placeholder struct for IndexedDbStore on non-WebAssembly platforms.
#[derive(Clone)]
pub struct IndexedDbStore {
name: String,
}
impl IndexedDbStore {
/// Creates a new IndexedDbStore.
pub fn new(_name: &str, _encrypted: bool) -> Result<Self> {
Err(KvsError::Other("IndexedDbStore is only available in WebAssembly".to_string()))
}
}
impl fmt::Debug for IndexedDbStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IndexedDbStore")
.field("name", &self.name)
.field("note", &"Placeholder for non-WebAssembly platforms")
.finish()
}
}
}
}
// Only provide the full KVStore implementation for WebAssembly
#[cfg(target_arch = "wasm32")]
impl KVStore for IndexedDbStore {
fn set<K, V>(&self, key: K, value: &V) -> Result<()>
where
K: ToString,
V: Serialize,
{
// For WebAssembly, we need to use the async version but in a synchronous context
let key_str = key.to_string();
let serialized = serde_json::to_string(value)?;
// Update the cache immediately
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.insert(key_str.clone(), serialized.clone());
// Start the async operation but don't wait for it
wasm_bindgen_futures::spawn_local(async move {
let db_guard = self.db.lock().unwrap();
if let Some(db) = db_guard.as_ref() {
// Create a transaction
if let Ok(transaction) = db.transaction_with_str_and_mode(&self.store_name, "readwrite") {
// Get the object store
if let Ok(store) = transaction.object_store(&self.store_name) {
// Put the value in the store
let _ = store.put_with_key(
&JsValue::from_str(&serialized),
&JsValue::from_str(&key_str)
);
}
}
}
});
Ok(())
}
fn get<K, V>(&self, key: K) -> Result<V>
where
K: ToString,
V: DeserializeOwned,
{
// For WebAssembly, we need to use the cache for synchronous operations
let key_str = key.to_string();
// Check the cache first
let cache_lock = self.cache.lock().unwrap();
if let Some(value) = cache_lock.get(&key_str) {
let value = serde_json::from_str(value)?;
return Ok(value);
}
// If not in cache, we can't do a synchronous IndexedDB request
// This is a limitation of WebAssembly integration
Err(KvsError::KeyNotFound(key_str))
}
fn delete<K>(&self, key: K) -> Result<()>
where
K: ToString,
{
let key_str = key.to_string();
// Remove from cache immediately
let mut cache_lock = self.cache.lock().unwrap();
if cache_lock.remove(&key_str).is_none() {
return Err(KvsError::KeyNotFound(key_str.clone()));
}
// Start the async operation but don't wait for it
wasm_bindgen_futures::spawn_local(async move {
let db_guard = self.db.lock().unwrap();
if let Some(db) = db_guard.as_ref() {
// Create a transaction
if let Ok(transaction) = db.transaction_with_str_and_mode(&self.store_name, "readwrite") {
// Get the object store
if let Ok(store) = transaction.object_store(&self.store_name) {
// Delete the key
let _ = store.delete(&JsValue::from_str(&key_str));
}
}
}
});
Ok(())
}
fn contains<K>(&self, key: K) -> Result<bool>
where
K: ToString,
{
let key_str = key.to_string();
// Check the cache first
let cache_lock = self.cache.lock().unwrap();
Ok(cache_lock.contains_key(&key_str))
}
fn keys(&self) -> Result<Vec<String>> {
// Return keys from cache
let cache_lock = self.cache.lock().unwrap();
Ok(cache_lock.keys().cloned().collect())
}
fn clear(&self) -> Result<()> {
// Clear the cache immediately
let mut cache_lock = self.cache.lock().unwrap();
cache_lock.clear();
// Start the async operation but don't wait for it
wasm_bindgen_futures::spawn_local(async move {
let db_guard = self.db.lock().unwrap();
if let Some(db) = db_guard.as_ref() {
// Create a transaction
if let Ok(transaction) = db.transaction_with_str_and_mode(&self.store_name, "readwrite") {
// Get the object store
if let Ok(store) = transaction.object_store(&self.store_name) {
// Clear the store
let _ = store.clear();
}
}
}
});
Ok(())
}
fn name(&self) -> &str {
&self.name
}
fn is_encrypted(&self) -> bool {
self.encrypted
}
}
// For creating and managing IndexedDbStore instances
#[cfg(target_arch = "wasm32")]
pub async fn create_indexeddb_store(name: &str, encrypted: bool) -> Result<IndexedDbStore> {
IndexedDbStore::new(name, encrypted).await
}
#[cfg(target_arch = "wasm32")]
pub async fn open_indexeddb_store(name: &str, _password: Option<&str>) -> Result<IndexedDbStore> {
// For IndexedDB we don't use the password parameter since encryption is handled differently
// We just open the store with the given name
IndexedDbStore::new(name, false).await
}