#![cfg(all(target_arch = "wasm32", target_os = "unknown"))]
mod js;
mod mirror;
mod pool;
mod sah;
mod store;
use std::collections::BTreeSet;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use wasm_bindgen::JsValue;
use crate::env::{
Capabilities, DirEntry, Env, FileLock, FileMeta, JoinHandle, ReadFile, WriteFile, WriteMode,
};
use mirror::MirrorFs;
use pool::SahPool;
use store::{MirrorStore, OpfsStore, PoolStore};
fn describe(value: &JsValue) -> String {
value
.as_string()
.or_else(|| {
js_sys::Reflect::get(value, &JsValue::from_str("message"))
.ok()?
.as_string()
})
.unwrap_or_else(|| format!("{value:?}"))
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum OpfsError {
#[error("OPFS is unavailable in this realm: {0}")]
Unavailable(String),
#[error(
"OPFS sync access handles are unavailable here; they exist only inside a Web Worker: {0}"
)]
SyncHandlesUnavailable(String),
#[error(
"the stored database is {resident} bytes, over the {limit} byte mirror-mode limit; \
raise OpfsOptions::max_resident_bytes or open it in a worker, where OpfsMode::Sah \
streams to storage instead"
)]
ResidencyExceeded {
resident: usize,
limit: usize,
},
#[error("OPFS call failed: {0}")]
Js(String),
}
impl From<OpfsError> for io::Error {
fn from(error: OpfsError) -> Self {
match error {
OpfsError::Unavailable(_) | OpfsError::SyncHandlesUnavailable(_) => {
io::Error::new(io::ErrorKind::Unsupported, error.to_string())
}
_ => io::Error::other(error.to_string()),
}
}
}
impl From<OpfsError> for crate::Error {
fn from(error: OpfsError) -> Self {
crate::Error::Io(error.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpfsMode {
Sah,
Mirror,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpfsOptions {
pub initial_slots: usize,
pub max_resident_bytes: usize,
pub force_mode: Option<OpfsMode>,
}
impl Default for OpfsOptions {
fn default() -> Self {
Self {
initial_slots: 64,
max_resident_bytes: 32 * 1024 * 1024,
force_mode: None,
}
}
}
enum Backend {
Sah(PoolStore),
Mirror(MirrorStore),
}
struct Inner {
db_path: PathBuf,
mode: OpfsMode,
backend: Backend,
open_dirs: Arc<super::db_lock::DirectoryRegistry>,
}
#[derive(Clone)]
pub struct OpfsEnv {
inner: Arc<Inner>,
}
impl std::fmt::Debug for OpfsEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpfsEnv")
.field("db_path", &self.inner.db_path)
.field("mode", &self.inner.mode)
.field("backend", &self.store())
.finish()
}
}
impl OpfsEnv {
pub async fn mount(db_name: &str, options: OpfsOptions) -> Result<Self, OpfsError> {
let root = js::root_directory()
.await
.map_err(|e| OpfsError::Unavailable(describe(&e)))?;
let directory = js::directory_handle(&root, db_name, true)
.await
.map_err(|e| OpfsError::Js(describe(&e)))?;
let existing = js::list_files(&directory)
.await
.map_err(|e| OpfsError::Js(describe(&e)))?;
let db_path = PathBuf::from(db_name);
if options.force_mode != Some(OpfsMode::Mirror) {
let slots = options
.initial_slots
.max(1)
.max(sah::existing_slot_count(&existing));
match sah::open_slots(&directory, &existing, 0..slots).await {
Ok(opened) => {
let (handles, headers): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
let mount = sah::register_mount(directory, handles);
let pool = Arc::new(SahPool::new(mount, headers));
return Ok(Self {
inner: Arc::new(Inner {
db_path,
mode: OpfsMode::Sah,
backend: Backend::Sah(PoolStore(pool)),
open_dirs: Arc::default(),
}),
});
}
Err(e) if options.force_mode == Some(OpfsMode::Sah) => {
return Err(OpfsError::SyncHandlesUnavailable(describe(&e)));
}
Err(e) => {
tracing::warn!(
reason = %describe(&e),
"OPFS sync access handles unavailable; falling back to mirror mode, \
which is durable only across OpfsEnv::persist"
);
}
}
}
let loaded = MirrorFs::load(&directory)
.await
.map_err(|e| OpfsError::Js(describe(&e)))?;
let resident: usize = loaded.iter().map(|(_, data)| data.len()).sum();
if resident > options.max_resident_bytes {
return Err(OpfsError::ResidencyExceeded {
resident,
limit: options.max_resident_bytes,
});
}
let mount = sah::register_mount(directory, Vec::new());
let fs = Arc::new(MirrorFs::new(mount, loaded, options.max_resident_bytes));
Ok(Self {
inner: Arc::new(Inner {
db_path,
mode: OpfsMode::Mirror,
backend: Backend::Mirror(MirrorStore(fs)),
open_dirs: Arc::default(),
}),
})
}
pub fn mode(&self) -> OpfsMode {
self.inner.mode
}
pub fn db_path(&self) -> &Path {
&self.inner.db_path
}
pub fn as_env(&self) -> Arc<dyn Env> {
Arc::new(self.clone())
}
pub async fn persist(&self) -> Result<(), OpfsError> {
match &self.inner.backend {
Backend::Sah(_) => Ok(()),
Backend::Mirror(store) => store
.0
.persist()
.await
.map_err(|e| OpfsError::Js(describe(&e))),
}
}
pub fn pending_bytes(&self) -> usize {
match &self.inner.backend {
Backend::Sah(_) => 0,
Backend::Mirror(store) => store.0.pending_bytes(),
}
}
pub fn resident_bytes(&self) -> usize {
match &self.inner.backend {
Backend::Sah(_) => 0,
Backend::Mirror(store) => store.0.resident_bytes(),
}
}
pub fn free_slots(&self) -> usize {
match &self.inner.backend {
Backend::Sah(store) => store.0.free_slots(),
Backend::Mirror(_) => 0,
}
}
pub async fn grow_pool(&self, additional: usize) -> Result<(), OpfsError> {
let Backend::Sah(store) = &self.inner.backend else {
return Ok(());
};
let mount = store.0.mount_id();
let directory = sah::mount_directory(mount).map_err(|e| OpfsError::Js(e.to_string()))?;
let first = store.0.slot_count();
let opened = sah::open_slots(&directory, &[], first..first + additional)
.await
.map_err(|e| OpfsError::Js(describe(&e)))?;
let (handles, headers): (Vec<_>, Vec<_>) = opened.into_iter().unzip();
sah::extend_mount(mount, handles).map_err(|e| OpfsError::Js(e.to_string()))?;
store.0.adopt_slots(headers);
Ok(())
}
fn store(&self) -> &dyn OpfsStore {
match &self.inner.backend {
Backend::Sah(store) => store,
Backend::Mirror(store) => store,
}
}
}
impl Env for OpfsEnv {
fn create_dir_all(&self, path: &Path) -> io::Result<()> {
self.store().create_dir_all(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
self.store().read_dir(path)
}
fn open_read(&self, path: &Path) -> io::Result<Box<dyn ReadFile>> {
self.store().open_read(path)
}
fn open_write(&self, path: &Path, mode: WriteMode) -> io::Result<Box<dyn WriteFile>> {
self.store().open_write(path, mode)
}
fn metadata(&self, path: &Path) -> io::Result<FileMeta> {
self.store().metadata(path)
}
fn remove_file(&self, path: &Path) -> io::Result<()> {
self.store().remove_file(path)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.store().rename(from, to)
}
fn exists(&self, path: &Path) -> bool {
self.store().exists(path)
}
fn sync_dir(&self, path: &Path) -> io::Result<()> {
self.store().sync_dir(path)
}
fn hard_link(&self, _src: &Path, _dst: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"OPFS has no hard links; Capabilities::hard_link is false, so regolith copies instead",
))
}
fn lock_file(&self, path: &Path, exclusive: bool) -> io::Result<Box<dyn FileLock>> {
self.inner.open_dirs.acquire(path, exclusive)
}
fn capabilities(&self) -> Capabilities {
self.store().capabilities()
}
fn now_micros(&self) -> Option<u64> {
js::monotonic_ms().map(|ms| (ms.max(0.0) * 1000.0) as u64)
}
fn unix_secs(&self) -> Option<u64> {
let ms = js::wall_clock_ms();
if ms.is_finite() && ms >= 0.0 {
Some((ms / 1000.0) as u64)
} else {
None
}
}
fn spawn(
&self,
_name: &str,
_body: Box<dyn FnOnce() + Send + 'static>,
) -> io::Result<Box<dyn JoinHandle>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"this wasm target has no threads; set Options::max_background_compactions = 0 \
to run compaction on the calling thread",
))
}
fn sleep(&self, _duration: Duration) {
}
}
pub(super) fn register_ancestors(dirs: &mut BTreeSet<PathBuf>, path: &Path) {
let mut cursor = path.parent();
while let Some(dir) = cursor {
if dir.as_os_str().is_empty() || !dirs.insert(dir.to_path_buf()) {
break;
}
cursor = dir.parent();
}
}
pub(super) fn children<'a>(
dir: &Path,
files: impl Iterator<Item = &'a PathBuf>,
dirs: impl Iterator<Item = &'a PathBuf>,
) -> Vec<(PathBuf, bool)> {
let mut out: Vec<(PathBuf, bool)> = files
.filter(|path| path.parent() == Some(dir))
.map(|path| (path.clone(), false))
.chain(
dirs.filter(|path| path.parent() == Some(dir))
.map(|path| (path.clone(), true)),
)
.collect();
out.sort();
out
}
pub(super) fn not_found(path: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::NotFound,
format!("no such file or directory: {}", path.display()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure};
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn ancestors_register_the_whole_chain() {
let mut dirs = BTreeSet::new();
register_ancestors(&mut dirs, Path::new("db/sst/000001.sst"));
assert!(dirs.contains(Path::new("db")));
assert!(dirs.contains(Path::new("db/sst")));
assert!(!dirs.contains(Path::new("db/sst/000001.sst")));
}
#[wasm_bindgen_test]
fn children_are_direct_entries_only() {
let files = [
PathBuf::from("db/MANIFEST"),
PathBuf::from("db/sst/000001.sst"),
];
let dirs = [PathBuf::from("db"), PathBuf::from("db/sst")];
let listed = children(Path::new("db"), files.iter(), dirs.iter());
assert_eq!(
listed,
vec![
(PathBuf::from("db/MANIFEST"), false),
(PathBuf::from("db/sst"), true),
]
);
}
#[wasm_bindgen_test]
fn default_options_bound_residency() {
let options = OpfsOptions::default();
assert_eq!(options.initial_slots, 64);
assert_eq!(options.max_resident_bytes, 32 * 1024 * 1024);
assert_eq!(options.force_mode, None);
}
}