Skip to content

Commit c333c9d

Browse files
committed
feat(vfs/opfs): add root-scoped VFS and extract lock/path modules
Introduce `OpfsVfs::with_root(worker_url, root)` so multiple independent databases can coexist in the same browser origin without colliding on the shared OPFS path namespace. `OpfsVfs::new` becomes a thin wrapper that passes an empty root, preserving the existing layout. Path resolution is centralised in `OpfsVfs::resolve`, which delegates to the new `path` module (`normalize_root` + `join_root`). Keeping the helpers in a target-independent file lets them be unit-tested on the host even though the rest of the OPFS backend is wasm-only. The lock manager (`LockMap` / `OpfsLockHandle`) is moved from `vfs_impl` into its own `lock` module so each file has a single responsibility. `OpfsLockHandle` is re-exported from `opfs::lock` rather than `opfs::vfs_impl`; the public path (`opfs::OpfsLockHandle`) is unchanged. Additional cleanup in the same pass: - Replace `std::io::Error::new(ErrorKind::Other, …)` with the `std::io::Error::other(…)` shorthand throughout the OPFS backend. - Use `std::sync::PoisonError::into_inner` as the closure directly instead of the `|e| e.into_inner()` lambda form.
1 parent db35f1d commit c333c9d

5 files changed

Lines changed: 250 additions & 116 deletions

File tree

src/vfs/opfs/handle.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,6 @@ pub(crate) fn map_err(reason: &str, kind: ErrKind) -> PagedbError {
172172
PagedbError::Io(std::io::Error::from(std::io::ErrorKind::AlreadyExists))
173173
}
174174
ErrKind::PermissionDenied => PagedbError::ReadOnly,
175-
ErrKind::Io | ErrKind::Other => PagedbError::Io(std::io::Error::new(
176-
std::io::ErrorKind::Other,
177-
reason.to_string(),
178-
)),
175+
ErrKind::Io | ErrKind::Other => PagedbError::Io(std::io::Error::other(reason.to_string())),
179176
}
180177
}

src/vfs/opfs/lock.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! In-process advisory file locking for the OPFS VFS.
2+
//!
3+
//! OPFS itself exposes no cross-handle locking primitive, so `OpfsVfs`
4+
//! arbitrates `lock_exclusive` / `lock_shared` within the single browser JS
5+
//! realm using the [`LockMap`] reference-counted table. [`OpfsLockHandle`] is
6+
//! the RAII guard handed back to callers; dropping it releases the lock.
7+
8+
#![cfg(all(target_arch = "wasm32", feature = "opfs"))]
9+
// The unsafe Send + Sync impls below are required to satisfy `Vfs: Send + Sync`
10+
// on wasm32. Safety justification is on the impl blocks.
11+
#![allow(unsafe_code)]
12+
13+
use std::collections::HashMap;
14+
use std::sync::{Arc, Mutex};
15+
16+
#[derive(Clone, Copy)]
17+
enum LockKind {
18+
Exclusive,
19+
Shared,
20+
}
21+
22+
/// Reference-counted advisory lock table keyed by resolved path.
23+
#[derive(Default)]
24+
pub(super) struct LockMap {
25+
entries: HashMap<String, (LockKind, u32)>,
26+
}
27+
28+
impl LockMap {
29+
pub(super) fn try_exclusive(&mut self, path: &str) -> bool {
30+
if self.entries.contains_key(path) {
31+
return false;
32+
}
33+
self.entries
34+
.insert(path.to_string(), (LockKind::Exclusive, 1));
35+
true
36+
}
37+
38+
pub(super) fn try_shared(&mut self, path: &str) -> bool {
39+
match self.entries.get_mut(path) {
40+
None => {
41+
self.entries.insert(path.to_string(), (LockKind::Shared, 1));
42+
true
43+
}
44+
Some((LockKind::Shared, n)) => {
45+
*n += 1;
46+
true
47+
}
48+
Some((LockKind::Exclusive, _)) => false,
49+
}
50+
}
51+
52+
pub(super) fn release(&mut self, path: &str) {
53+
let remove = match self.entries.get_mut(path) {
54+
Some((_, n)) if *n <= 1 => true,
55+
Some((_, n)) => {
56+
*n -= 1;
57+
false
58+
}
59+
None => false,
60+
};
61+
if remove {
62+
self.entries.remove(path);
63+
}
64+
}
65+
}
66+
67+
/// RAII advisory lock handle returned by `lock_exclusive` / `lock_shared`.
68+
pub struct OpfsLockHandle {
69+
pub(super) path: String,
70+
pub(super) locks: Arc<Mutex<LockMap>>,
71+
}
72+
73+
// SAFETY: wasm32 is single-threaded by browser spec. All access to
74+
// OpfsLockHandle happens on the spawning thread. The Arc<Mutex<LockMap>>
75+
// field contains no JS types and is naturally Send+Sync.
76+
unsafe impl Send for OpfsLockHandle {}
77+
unsafe impl Sync for OpfsLockHandle {}
78+
79+
impl Drop for OpfsLockHandle {
80+
fn drop(&mut self) {
81+
if let Ok(mut map) = self.locks.lock() {
82+
map.release(&self.path);
83+
}
84+
}
85+
}

src/vfs/opfs/mod.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,25 @@
2020
//! target (or when the `opfs` feature is absent) a thin shim is compiled
2121
//! instead; every method returns [`crate::errors::PagedbError::Unsupported`].
2222
23+
// Pure path-rooting helpers — target-independent so they are unit-testable on
24+
// the host even though the OPFS backend itself is wasm-only.
25+
mod path;
26+
2327
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
2428
mod handle;
2529
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
30+
mod lock;
31+
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
2632
mod protocol;
2733
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
2834
mod vfs_impl;
2935

3036
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
3137
pub use handle::OpfsFile;
3238
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
33-
pub use vfs_impl::{OpfsLockHandle, OpfsVfs};
39+
pub use lock::OpfsLockHandle;
40+
#[cfg(all(target_arch = "wasm32", feature = "opfs"))]
41+
pub use vfs_impl::OpfsVfs;
3442

3543
/// Embedded source of the pure-JS OPFS Web Worker.
3644
///
@@ -62,6 +70,11 @@ mod shim {
6270
pub fn new(_worker_url: &str) -> Result<Self> {
6371
Err(PagedbError::Unsupported)
6472
}
73+
74+
/// Always returns `Err(PagedbError::Unsupported)`.
75+
pub fn with_root(_worker_url: &str, _root: &str) -> Result<Self> {
76+
Err(PagedbError::Unsupported)
77+
}
6578
}
6679

6780
pub struct OpfsFileShim;

src/vfs/opfs/path.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! Pure path helpers for the OPFS VFS root-prefixing scheme.
2+
//!
3+
//! The OPFS backend only compiles for `wasm32 + opfs`, but the rooting logic is
4+
//! plain string manipulation. Keeping it here as target-independent free
5+
//! functions lets it be unit-tested on the host even though the rest of the
6+
//! backend cannot be. `OpfsVfs` (wasm) is the sole production caller, so on
7+
//! every other target these are legitimately dead — hence the `allow`.
8+
9+
/// Normalise a caller-supplied root directory into a single leading-slash
10+
/// prefix, or the empty string when no rooting is requested.
11+
///
12+
/// Surrounding slashes are trimmed: `"db"`, `"/db"`, and `"/db/"` all map to
13+
/// `"/db"`; `""`, `"/"`, and `"///"` map to `""` (legacy origin-root layout).
14+
#[cfg_attr(not(all(target_arch = "wasm32", feature = "opfs")), allow(dead_code))]
15+
pub(super) fn normalize_root(root: &str) -> String {
16+
let trimmed = root.trim_matches('/');
17+
if trimmed.is_empty() {
18+
String::new()
19+
} else {
20+
format!("/{trimmed}")
21+
}
22+
}
23+
24+
/// Join a virtual VFS path under a normalised root.
25+
///
26+
/// pagedb hands the VFS origin-relative *virtual* paths (e.g. `/main.db`); the
27+
/// native `TokioVfs` joins them onto its on-disk root the same way. The leading
28+
/// `/` is stripped and the remainder joined under `root` with exactly one
29+
/// separator, so the result is correct whether `path` arrives absolute
30+
/// (`/main.db`) or relative (`main.db`) — it never glues into `/dbmain.db`. An
31+
/// empty `root` returns `path` unchanged, preserving the legacy layout exactly.
32+
#[cfg_attr(not(all(target_arch = "wasm32", feature = "opfs")), allow(dead_code))]
33+
pub(super) fn join_root(root: &str, path: &str) -> String {
34+
if root.is_empty() {
35+
path.to_string()
36+
} else {
37+
format!("{}/{}", root, path.trim_start_matches('/'))
38+
}
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use super::*;
44+
45+
#[test]
46+
fn normalize_trims_surrounding_slashes() {
47+
assert_eq!(normalize_root("db"), "/db");
48+
assert_eq!(normalize_root("/db"), "/db");
49+
assert_eq!(normalize_root("/db/"), "/db");
50+
assert_eq!(normalize_root("a/b"), "/a/b");
51+
}
52+
53+
#[test]
54+
fn normalize_empty_or_slash_only_means_no_root() {
55+
assert_eq!(normalize_root(""), "");
56+
assert_eq!(normalize_root("/"), "");
57+
assert_eq!(normalize_root("///"), "");
58+
}
59+
60+
#[test]
61+
fn join_uses_single_separator_regardless_of_leading_slash() {
62+
// Absolute virtual path — how pagedb actually calls the VFS.
63+
assert_eq!(join_root("/db", "/main.db"), "/db/main.db");
64+
// Relative virtual path — must NOT glue into "/dbmain.db".
65+
assert_eq!(join_root("/db", "main.db"), "/db/main.db");
66+
// Nested segments are preserved.
67+
assert_eq!(join_root("/db", "/segments/seg-1"), "/db/segments/seg-1");
68+
}
69+
70+
#[test]
71+
fn empty_root_passes_path_through_unchanged() {
72+
assert_eq!(join_root("", "/main.db"), "/main.db");
73+
assert_eq!(join_root("", "main.db"), "main.db");
74+
}
75+
76+
#[test]
77+
fn distinct_roots_never_collide_on_the_fixed_main_db_path() {
78+
let a = join_root(&normalize_root("alpha"), "/main.db");
79+
let b = join_root(&normalize_root("beta"), "/main.db");
80+
assert_eq!(a, "/alpha/main.db");
81+
assert_eq!(b, "/beta/main.db");
82+
assert_ne!(a, b);
83+
}
84+
}

0 commit comments

Comments
 (0)