//! Minimal IndexedDB access: open, read every replica entry, apply a batch of changes in one //! transaction, and keep the device key record. //! //! Database `kestrove` (or the name passed to `VaultCore.open`), version 1: //! * `kv`: replica entries, string key → `Uint8Array` (ciphertext envelopes and bookkeeping only); //! * `device`: one record `"device"` → `{ deviceId, name, platform, privateKey: CryptoKey, publicKey }`. //! The private key is a non-extractable WebCrypto key: the browser stores it, script never sees its bytes. use js_sys::{Array, Promise, Reflect, Uint8Array}; use wasm_bindgen::{prelude::*, JsCast}; use wasm_bindgen_futures::JsFuture; use web_sys::{ IdbDatabase, IdbFactory, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransaction, IdbTransactionMode, }; use crate::store::Change; const VERSION: u32 = 1; pub const KV: &str = "kv"; pub const DEVICE: &str = "device"; pub const DEVICE_KEY: &str = "device"; fn factory() -> Result { let f = Reflect::get(&js_sys::global(), &JsValue::from_str("indexedDB"))?; if f.is_undefined() || f.is_null() { return Err(JsValue::from_str("IndexedDB is not available in this context")); } f.dyn_into::() } /// Resolves with `request.result` on success, rejects with `request.error`. async fn request(r: &IdbRequest) -> Result { let promise = Promise::new(&mut |resolve, reject| { let ok_req = r.clone(); let on_ok = Closure::once_into_js(move |_: web_sys::Event| { let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED)); }); let err_req = r.clone(); let on_err = Closure::once_into_js(move |_: web_sys::Event| { let e = err_req.error().ok().flatten().map(JsValue::from).unwrap_or_else(|| "IndexedDB error".into()); let _ = reject.call1(&JsValue::NULL, &e); }); r.set_onsuccess(Some(on_ok.unchecked_ref())); r.set_onerror(Some(on_err.unchecked_ref())); }); JsFuture::from(promise).await } /// Resolves when the transaction commits; rejects when it aborts or fails. async fn done(tx: &IdbTransaction) -> Result<(), JsValue> { let promise = Promise::new(&mut |resolve, reject| { let on_ok = Closure::once_into_js(move |_: web_sys::Event| { let _ = resolve.call0(&JsValue::NULL); }); let err_tx = tx.clone(); let reject2 = reject.clone(); let on_err = Closure::once_into_js(move |_: web_sys::Event| { let e = err_tx.error().map(JsValue::from).unwrap_or_else(|| "IndexedDB transaction failed".into()); let _ = reject.call1(&JsValue::NULL, &e); }); let on_abort = Closure::once_into_js(move |_: web_sys::Event| { let _ = reject2.call1(&JsValue::NULL, &"IndexedDB transaction aborted".into()); }); tx.set_oncomplete(Some(on_ok.unchecked_ref())); tx.set_onerror(Some(on_err.unchecked_ref())); tx.set_onabort(Some(on_abort.unchecked_ref())); }); JsFuture::from(promise).await.map(|_| ()) } pub async fn open(name: &str) -> Result { let req: IdbOpenDbRequest = factory()?.open_with_u32(name, VERSION)?; let upgrade_req = req.clone(); let on_upgrade = Closure::once_into_js(move |_: web_sys::Event| { if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::()) { let names = db.object_store_names(); for store in [KV, DEVICE] { if !names.contains(store) { let _ = db.create_object_store(store); } } } }); req.set_onupgradeneeded(Some(on_upgrade.unchecked_ref())); request(&req).await?.dyn_into::() } fn store(db: &IdbDatabase, name: &str, mode: IdbTransactionMode) -> Result<(IdbTransaction, IdbObjectStore), JsValue> { let tx = db.transaction_with_str_and_mode(name, mode)?; let s = tx.object_store(name)?; Ok((tx, s)) } /// Every replica entry. pub async fn load_entries(db: &IdbDatabase) -> Result)>, JsValue> { let (_tx, s) = store(db, KV, IdbTransactionMode::Readonly)?; let keys: Array = request(&s.get_all_keys()?).await?.dyn_into()?; let values: Array = request(&s.get_all()?).await?.dyn_into()?; if keys.length() != values.length() { return Err(JsValue::from_str("IndexedDB changed while loading")); } let mut out = Vec::with_capacity(keys.length() as usize); for (k, v) in keys.iter().zip(values.iter()) { let k = k.as_string().ok_or_else(|| JsValue::from_str("non-string key in IndexedDB"))?; out.push((k, v.dyn_into::()?.to_vec())); } Ok(out) } /// Applies changes atomically (one readwrite transaction). pub async fn apply(db: &IdbDatabase, changes: Vec) -> Result<(), JsValue> { if changes.is_empty() { return Ok(()); } let (tx, s) = store(db, KV, IdbTransactionMode::Readwrite)?; for c in changes { match c { Change::Clear => { s.clear()?; } Change::Put(k, v) => { s.put_with_key(&Uint8Array::from(v.as_slice()), &JsValue::from_str(&k))?; } Change::Delete(k) => { s.delete(&JsValue::from_str(&k))?; } } } done(&tx).await } pub async fn get_device(db: &IdbDatabase) -> Result { let (_tx, s) = store(db, DEVICE, IdbTransactionMode::Readonly)?; request(&s.get(&JsValue::from_str(DEVICE_KEY))?).await } pub async fn put_device(db: &IdbDatabase, record: &JsValue) -> Result<(), JsValue> { let (tx, s) = store(db, DEVICE, IdbTransactionMode::Readwrite)?; s.put_with_key(record, &JsValue::from_str(DEVICE_KEY))?; done(&tx).await }