use std::cell::RefCell;
use std::rc::Rc;
use async_trait::async_trait;
use idb::{
CursorDirection, Database, DatabaseEvent, Factory, KeyRange, ObjectStoreParams,
TransactionMode,
};
use js_sys::Uint8Array;
use wasm_bindgen::JsValue;
use crate::resolver::backends::codec::{decode_blob, encode_blob, entry_has_tag, DecodeError};
use crate::resolver::cache::{CacheBackend, CacheError, CachedEntry};
const ENTRIES_STORE: &str = "entries";
#[derive(Clone)]
pub struct IndexedDbBackend {
db: Rc<RefCell<Option<Database>>>,
namespace: Rc<str>,
}
impl IndexedDbBackend {
pub async fn new(database_name: &str, namespace: &str) -> Result<Self, CacheError> {
if namespace.is_empty() {
return Err(CacheError::Backend(
"IndexedDbBackend namespace must be non-empty (cross-user isolation)".to_string(),
));
}
if namespace.contains(':') {
return Err(CacheError::Backend(
"IndexedDbBackend namespace must not contain ':' (key separator)".to_string(),
));
}
let factory =
Factory::new().map_err(|e| CacheError::Backend(format!("idb factory: {e}")))?;
let mut open_request = factory
.open(database_name, Some(1))
.map_err(|e| CacheError::Backend(format!("idb open request: {e}")))?;
open_request.on_upgrade_needed(|event| {
let db = match event.database() {
Ok(db) => db,
Err(_) => return,
};
if !db.store_names().iter().any(|n| n == ENTRIES_STORE) {
let _ = db.create_object_store(ENTRIES_STORE, ObjectStoreParams::new());
}
});
let db: Database = open_request
.await
.map_err(|e| CacheError::Backend(format!("idb open: {e}")))?;
Ok(Self {
db: Rc::new(RefCell::new(Some(db))),
namespace: Rc::from(namespace),
})
}
fn db_key(&self, key: u64) -> String {
format!("{}:{:016x}", self.namespace, key)
}
fn namespace_lower_bound(&self) -> String {
format!("{}:", self.namespace)
}
fn namespace_upper_bound(&self) -> String {
format!("{};", self.namespace)
}
fn with_db<F, R>(&self, op: F) -> Result<R, CacheError>
where
F: FnOnce(&Database) -> Result<R, CacheError>,
{
let guard = self.db.borrow();
let db = guard
.as_ref()
.ok_or_else(|| CacheError::Backend("database closed".to_string()))?;
op(db)
}
}
impl std::fmt::Debug for IndexedDbBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IndexedDbBackend")
.field("namespace", &&*self.namespace)
.field("open", &self.db.borrow().is_some())
.finish()
}
}
#[async_trait(?Send)]
impl CacheBackend for IndexedDbBackend {
async fn get(&self, key: u64) -> Option<CachedEntry> {
let db_key = self.db_key(key);
let raw: Option<Vec<u8>> = match self.read_blob(&db_key).await {
Ok(blob) => blob,
Err(_) => return None,
};
let raw = raw?;
match decode_blob(&raw) {
Ok(entry) => Some(entry),
Err(DecodeError::VersionMismatch) => {
let _ = self.invalidate(key).await;
None
}
Err(_) => None,
}
}
async fn put(&self, key: u64, entry: CachedEntry) -> Result<(), CacheError> {
let bytes = encode_blob(&entry)
.map_err(|e| CacheError::Backend(format!("encode entry: {e}")))?;
let db_key = self.db_key(key);
let (req, tx) = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadWrite)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let value = Uint8Array::from(bytes.as_slice());
let key_js = JsValue::from_str(&db_key);
let req = store
.put(&value, Some(&key_js))
.map_err(|e| classify_idb_error("put", &e))?;
Ok((req, tx))
})?;
req.await
.map_err(|e| classify_idb_error("put-await", &e))?;
tx.await
.map_err(|e| CacheError::Backend(format!("idb tx commit: {e}")))?;
Ok(())
}
async fn invalidate(&self, key: u64) -> Result<(), CacheError> {
let db_key = self.db_key(key);
let (req, tx) = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadWrite)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let req = store
.delete(JsValue::from_str(&db_key))
.map_err(|e| CacheError::Backend(format!("idb delete: {e}")))?;
Ok((req, tx))
})?;
req.await
.map_err(|e| CacheError::Backend(format!("idb delete-await: {e}")))?;
tx.await
.map_err(|e| CacheError::Backend(format!("idb tx commit: {e}")))?;
Ok(())
}
async fn invalidate_by_tag(&self, tag: &str) -> Result<(), CacheError> {
let matching = self.collect_keys_with_tag(tag).await?;
if matching.is_empty() {
return Ok(());
}
let (reqs, tx) = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadWrite)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let mut reqs = Vec::with_capacity(matching.len());
for key in &matching {
let req = store
.delete(JsValue::from_str(key))
.map_err(|e| CacheError::Backend(format!("idb delete: {e}")))?;
reqs.push(req);
}
Ok((reqs, tx))
})?;
for req in reqs {
req.await
.map_err(|e| CacheError::Backend(format!("idb delete-await: {e}")))?;
}
tx.await
.map_err(|e| CacheError::Backend(format!("idb tx commit: {e}")))?;
Ok(())
}
async fn clear(&self) -> Result<(), CacheError> {
let lower = self.namespace_lower_bound();
let upper = self.namespace_upper_bound();
let mut keys = Vec::new();
let cursor_opt = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadOnly)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let range = KeyRange::bound(
&JsValue::from_str(&lower),
&JsValue::from_str(&upper),
Some(false),
Some(true),
)
.map_err(|e| CacheError::Backend(format!("idb range: {e}")))?;
let req = store
.open_cursor(Some(range.into()), Some(CursorDirection::Next))
.map_err(|e| CacheError::Backend(format!("idb open cursor: {e}")))?;
Ok(req)
})?;
let mut maybe_cursor = cursor_opt
.await
.map_err(|e| CacheError::Backend(format!("idb cursor await: {e}")))?;
while let Some(cursor) = maybe_cursor {
let key_js = cursor
.key()
.map_err(|e| CacheError::Backend(format!("idb cursor key: {e}")))?;
if let Some(key_str) = key_js.as_string() {
keys.push(key_str);
}
maybe_cursor = cursor
.next(None)
.map_err(|e| CacheError::Backend(format!("idb cursor next: {e}")))?
.await
.map_err(|e| CacheError::Backend(format!("idb cursor next-await: {e}")))?;
}
if keys.is_empty() {
return Ok(());
}
let (reqs, tx) = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadWrite)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let mut reqs = Vec::with_capacity(keys.len());
for k in &keys {
let req = store
.delete(JsValue::from_str(k))
.map_err(|e| CacheError::Backend(format!("idb delete: {e}")))?;
reqs.push(req);
}
Ok((reqs, tx))
})?;
for req in reqs {
req.await
.map_err(|e| CacheError::Backend(format!("idb delete-await: {e}")))?;
}
tx.await
.map_err(|e| CacheError::Backend(format!("idb tx commit: {e}")))?;
Ok(())
}
async fn shutdown(&self) {
let mut guard = self.db.borrow_mut();
if let Some(db) = guard.as_ref() {
db.close();
}
*guard = None;
}
}
impl IndexedDbBackend {
async fn read_blob(&self, db_key: &str) -> Result<Option<Vec<u8>>, CacheError> {
let (req, tx) = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadOnly)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let req = store
.get(JsValue::from_str(db_key))
.map_err(|e| CacheError::Backend(format!("idb get: {e}")))?;
Ok((req, tx))
})?;
let value = req
.await
.map_err(|e| CacheError::Backend(format!("idb get-await: {e}")))?;
let _ = tx.await;
match value {
None => Ok(None),
Some(jv) => {
if jv.is_undefined() || jv.is_null() {
return Ok(None);
}
let arr = Uint8Array::new(&jv);
Ok(Some(arr.to_vec()))
}
}
}
async fn collect_keys_with_tag(&self, tag: &str) -> Result<Vec<String>, CacheError> {
let lower = self.namespace_lower_bound();
let upper = self.namespace_upper_bound();
let cursor_req = self.with_db(|db| {
let tx = db
.transaction(&[ENTRIES_STORE], TransactionMode::ReadOnly)
.map_err(|e| CacheError::Backend(format!("idb tx: {e}")))?;
let store = tx
.object_store(ENTRIES_STORE)
.map_err(|e| CacheError::Backend(format!("idb store: {e}")))?;
let range = KeyRange::bound(
&JsValue::from_str(&lower),
&JsValue::from_str(&upper),
Some(false),
Some(true),
)
.map_err(|e| CacheError::Backend(format!("idb range: {e}")))?;
let req = store
.open_cursor(Some(range.into()), Some(CursorDirection::Next))
.map_err(|e| CacheError::Backend(format!("idb open cursor: {e}")))?;
Ok(req)
})?;
let mut maybe_cursor = cursor_req
.await
.map_err(|e| CacheError::Backend(format!("idb cursor await: {e}")))?;
let mut matching = Vec::new();
while let Some(cursor) = maybe_cursor {
let key_js = cursor
.key()
.map_err(|e| CacheError::Backend(format!("idb cursor key: {e}")))?;
let value_js = cursor
.value()
.map_err(|e| CacheError::Backend(format!("idb cursor value: {e}")))?;
if let (Some(key_str), Some(arr)) = (
key_js.as_string(),
(!value_js.is_undefined() && !value_js.is_null())
.then(|| Uint8Array::new(&value_js)),
) {
let bytes = arr.to_vec();
if entry_has_tag(&bytes, tag) {
matching.push(key_str);
}
}
maybe_cursor = cursor
.next(None)
.map_err(|e| CacheError::Backend(format!("idb cursor next: {e}")))?
.await
.map_err(|e| CacheError::Backend(format!("idb cursor next-await: {e}")))?;
}
Ok(matching)
}
}
fn classify_idb_error(stage: &str, err: &idb::Error) -> CacheError {
let msg = err.to_string();
let lc = msg.to_lowercase();
if lc.contains("quota") {
CacheError::Backend(format!("idb {stage}: storage quota exceeded ({msg})"))
} else {
CacheError::Backend(format!("idb {stage}: {msg}"))
}
}