use super::frame;
use crate::wal::{Mutation, WalEntry, WalError};
use crate::wasm::detect::{OpfsCapability, detect_opfs};
use indexed_db_futures::database::Database as IdbDatabase;
use indexed_db_futures::prelude::{Build, BuildPrimitive, QuerySource};
use indexed_db_futures::transaction::TransactionMode;
use indexed_db_futures::typed_array::{Uint8Array, Uint8ArraySlice};
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions,
FileSystemReadWriteOptions, FileSystemSyncAccessHandle, WorkerGlobalScope,
};
const IDB_VERSION: u32 = 1;
const WAL_STORE: &str = "wal";
const WAL_RECORD_KEY: &str = "frames";
pub trait WalBackend {
fn append(&mut self, entry: &WalEntry) -> Result<(), OpfsWalError>;
fn append_mutation(&mut self, mutation: &Mutation) -> Result<(), OpfsWalError> {
self.append(&WalEntry::from(mutation))
}
fn read(&self) -> Result<Vec<WalEntry>, OpfsWalError>;
fn truncate(&mut self) -> Result<(), OpfsWalError>;
}
pub enum BrowserWal {
Opfs(OpfsWal),
IndexedDb(IndexedDbWal),
}
impl BrowserWal {
pub async fn open(name: &str) -> Result<Self, OpfsWalError> {
match detect_opfs() {
OpfsCapability::OpfsAvailable => Ok(Self::Opfs(OpfsWal::open(name).await?)),
OpfsCapability::OpfsUnavailable => Ok(Self::IndexedDb(IndexedDbWal::open(name).await?)),
}
}
#[must_use]
pub const fn is_opfs(&self) -> bool {
matches!(self, Self::Opfs(_))
}
}
impl WalBackend for BrowserWal {
fn append(&mut self, entry: &WalEntry) -> Result<(), OpfsWalError> {
match self {
Self::Opfs(wal) => wal.append(entry),
Self::IndexedDb(wal) => wal.append(entry),
}
}
fn read(&self) -> Result<Vec<WalEntry>, OpfsWalError> {
match self {
Self::Opfs(wal) => wal.read(),
Self::IndexedDb(wal) => wal.read(),
}
}
fn truncate(&mut self) -> Result<(), OpfsWalError> {
match self {
Self::Opfs(wal) => wal.truncate(),
Self::IndexedDb(wal) => wal.truncate(),
}
}
}
pub struct OpfsWal {
handle: FileSystemSyncAccessHandle,
write_offset: f64,
}
impl OpfsWal {
pub async fn open(name: &str) -> Result<Self, OpfsWalError> {
let root = opfs_root().await?;
let options = FileSystemGetFileOptions::new();
options.set_create(true);
let file_handle: FileSystemFileHandle =
JsFuture::from(root.get_file_handle_with_options(name, &options))
.await
.map_err(|error| OpfsWalError::from_js(&error))?
.dyn_into()
.map_err(|_| OpfsWalError::Opfs("file handle cast failed".to_owned()))?;
let handle: FileSystemSyncAccessHandle =
JsFuture::from(file_handle.create_sync_access_handle())
.await
.map_err(|error| OpfsWalError::from_js(&error))?
.dyn_into()
.map_err(|_| OpfsWalError::Opfs("sync access handle cast failed".to_owned()))?;
let write_offset = handle
.get_size()
.map_err(|error| OpfsWalError::from_js(&error))?;
Ok(Self {
handle,
write_offset,
})
}
}
impl WalBackend for OpfsWal {
fn append(&mut self, entry: &WalEntry) -> Result<(), OpfsWalError> {
let bytes = frame::frame_entry(entry);
let options = FileSystemReadWriteOptions::new();
options.set_at(self.write_offset);
let written = self
.handle
.write_with_u8_array_and_options(&bytes, &options)
.map_err(|error| OpfsWalError::from_js(&error))?;
self.handle
.flush()
.map_err(|error| OpfsWalError::from_js(&error))?;
self.write_offset += written;
Ok(())
}
fn read(&self) -> Result<Vec<WalEntry>, OpfsWalError> {
let size = self
.handle
.get_size()
.map_err(|error| OpfsWalError::from_js(&error))?;
let len = usize_from_f64(size)?;
let mut buffer = vec![0_u8; len];
let options = FileSystemReadWriteOptions::new();
options.set_at(0.0);
self.handle
.read_with_u8_array_and_options(&mut buffer, &options)
.map_err(|error| OpfsWalError::from_js(&error))?;
frame::decode_entries(&buffer).map_err(OpfsWalError::Wal)
}
fn truncate(&mut self) -> Result<(), OpfsWalError> {
self.handle
.truncate_with_u32(0)
.map_err(|error| OpfsWalError::from_js(&error))?;
self.handle
.flush()
.map_err(|error| OpfsWalError::from_js(&error))?;
self.write_offset = 0.0;
Ok(())
}
}
pub struct IndexedDbWal {
db: IdbDatabase,
frames: Vec<u8>,
}
impl IndexedDbWal {
pub async fn open(name: &str) -> Result<Self, OpfsWalError> {
let db = IdbDatabase::open(name.to_owned())
.with_version(IDB_VERSION)
.with_on_upgrade_needed(|_event, db| {
let has_store = db.object_store_names().any(|store| store == WAL_STORE);
if !has_store {
db.create_object_store(WAL_STORE).build()?;
}
Ok(())
})
.await
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
let frames = load_frames(&db).await?;
Ok(Self { db, frames })
}
async fn persist(&self) -> Result<(), OpfsWalError> {
let tx = self
.db
.transaction(WAL_STORE)
.with_mode(TransactionMode::Readwrite)
.build()
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
let store = tx
.object_store(WAL_STORE)
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
store
.put(Uint8ArraySlice::new(&self.frames))
.with_key(WAL_RECORD_KEY)
.without_key_type()
.primitive()
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?
.await
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
drop(store);
tx.commit()
.await
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))
}
}
impl WalBackend for IndexedDbWal {
fn append(&mut self, entry: &WalEntry) -> Result<(), OpfsWalError> {
self.frames.extend_from_slice(&frame::frame_entry(entry));
spawn_persist(self);
Ok(())
}
fn read(&self) -> Result<Vec<WalEntry>, OpfsWalError> {
frame::decode_entries(&self.frames).map_err(OpfsWalError::Wal)
}
fn truncate(&mut self) -> Result<(), OpfsWalError> {
self.frames.clear();
spawn_persist(self);
Ok(())
}
}
fn spawn_persist(wal: &IndexedDbWal) {
wasm_bindgen_futures::spawn_local({
let frames = wal.frames.clone();
let db = wal.db.clone();
async move {
let staged = IndexedDbWal { db, frames };
let _ = staged.persist().await;
}
});
}
async fn load_frames(db: &IdbDatabase) -> Result<Vec<u8>, OpfsWalError> {
let tx = db
.transaction(WAL_STORE)
.with_mode(TransactionMode::Readonly)
.build()
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
let store = tx
.object_store(WAL_STORE)
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
let record: Option<Uint8Array> = store
.get(WAL_RECORD_KEY)
.primitive()
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?
.await
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
drop(store);
tx.commit()
.await
.map_err(|error| OpfsWalError::IndexedDb(error.to_string()))?;
Ok(record.map(|bytes| bytes.to_vec()).unwrap_or_default())
}
async fn opfs_root() -> Result<FileSystemDirectoryHandle, OpfsWalError> {
let global = js_sys::global();
let scope: WorkerGlobalScope = global
.dyn_into()
.map_err(|_| OpfsWalError::NotWorkerScope)?;
let storage = scope.navigator().storage();
JsFuture::from(storage.get_directory())
.await
.map_err(|error| OpfsWalError::from_js(&error))?
.dyn_into()
.map_err(|_| OpfsWalError::Opfs("directory handle cast failed".to_owned()))
}
fn usize_from_f64(value: f64) -> Result<usize, OpfsWalError> {
if !(value.is_finite() && value >= 0.0 && value.fract() == 0.0 && value <= MAX_SAFE_INTEGER) {
return Err(OpfsWalError::Opfs(format!(
"invalid OPFS file size: {value}"
)));
}
let as_i64 = value as i64;
usize::try_from(as_i64)
.map_err(|_| OpfsWalError::Opfs(format!("OPFS file size exceeds usize: {value}")))
}
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_992.0;
#[derive(Debug)]
pub enum OpfsWalError {
NotWorkerScope,
Opfs(String),
IndexedDb(String),
Wal(WalError),
}
impl OpfsWalError {
fn from_js(value: &JsValue) -> Self {
Self::Opfs(value.as_string().unwrap_or_else(|| format!("{value:?}")))
}
}
impl core::fmt::Display for OpfsWalError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NotWorkerScope => write!(
formatter,
"OPFS synchronous access requires a web worker scope"
),
Self::Opfs(error) => write!(formatter, "OPFS error: {error}"),
Self::IndexedDb(error) => write!(formatter, "IndexedDB WAL error: {error}"),
Self::Wal(error) => write!(formatter, "WAL codec error: {error}"),
}
}
}
impl std::error::Error for OpfsWalError {}
impl From<WalError> for OpfsWalError {
fn from(error: WalError) -> Self {
Self::Wal(error)
}
}