8000 BrowserDb: OPFS-backed persistent mode (sync access handle, no in-memory buffer) · Issue #441 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
  • BrowserDb: OPFS-backed persistent mode (sync access handle, no in-memory buffer) #441

    Open

    Description

    @adityamukho

    Motivation

    The persistent BrowserDb keeps a full in-memory copy of the database. StorageBackend is synchronous and IndexedDB is async, so BrowserDb.open() loads every page into BrowserBufferBackend and every transact writes the dirty pages through to IndexedDB. Heap use and open time are O(database size), which is where the ~20K-fact guideline comes from. #440 bounds growth, but the database still has to fit in memory.

    The Origin Private File System (OPFS) offers FileSystemSyncAccessHandle, a synchronous file API (Baseline since March 2023: Chrome 102+, Firefox 111+, Safari 15.2+). It lets the browser use a real .graph file through the normal sync StorageBackend, with no buffer.

    Proposal

    Add an OPFS-backed persistent mode to BrowserDb, for use inside a dedicated Web Worker. Keep the IndexedDB + buffer mode as the main-thread fallback.

    Backend mapping

    StorageBackend FileSystemSyncAccessHandle
    read_page(id) read(buf, {at: id * PAGE_SIZE})
    write_page(id, data) write(buf, {at})
    sync() flush()
    page_count() getSize() / PAGE_SIZE
    close() close() (releases the lock)
    • Only open is async (navigator.storage.getDirectory() → getFileHandle({create}) → createSyncAccessHandle()). Every operation after that is synchronous.
    • No buffer. The regular PageCache applies, so memory is O(cache) and open does not load every page.
    • The OPFS file is a byte-for-byte .graph file. exportGraph() / importGraph() read and write through the handle, because getFile() is blocked while a sync handle is open.
    • web-sys: FileSystemSyncAccessHandle, FileSystemFileHandle::create_sync_access_handle, FileSystemGetFileOptions, StorageManager and WorkerNavigator are stable. Do not use create_sync_access_handle_with_options (mode): it is behind web_sys_unstable_apis and supported only in Chrome 121+. No new crates.

    Locking

    The default readwrite mode takes an exclusive lock. A second tab or worker that opens the same database gets NoModificationAllowedError. Map it to the existing file-lock error code, which matches native kernel-lock semantics (single writer, fail fast). Multi-tab shared access is out of scope, because the shared modes are Chrome-only.

    Durability

    The WHATWG spec gives no explicit durability guarantee for flush() and says nothing about torn writes on sync handles. The OPFS backend must therefore:

    No WAL

    Keep checkpoint-per-transaction like BrowserDb today. With v8 O(change) checkpoints (#434) that is cheap, and it keeps the WAL file-backend only and the database a single file.

    Send + Sync

    StorageBackend: Send + Sync, but the handle is a JsValue. Wrap it in a newtype with unsafe impl Send/Sync, gated on single-threaded wasm (cfg(not(target_feature = "atomics"))) with a safety comment, or an equivalent wrapper.

    API surface

    • BrowserDb.openOpfs(name) (name TBD), or a mode option on open. It fails with a clear error when called outside a dedicated worker or when OPFS is unavailable.
    • Consider shipping a thin worker wrapper with a promise-based main-thread API (as SQLite-WASM's Worker1/Promiser does), so apps don't hand-roll postMessage.
    • Related fix: IndexedDbBackend::open uses web_sys::window(), so the existing IndexedDB mode fails inside workers. Use the global scope (js_sys::global()) instead, so both modes work in a worker.

    Open questions

    • What OPFS does in private browsing on each engine (availability and quota).
    • Eviction: OPFS shares the best-effort storage bucket with IndexedDB. Document navigator.storage.persist().
    • Whether execute can become synchronous in OPFS mode, or should stay Promise-returning for API parity.

    Acceptance criteria

    • Worker-based OPFS BrowserDb passes the existing browser test suite.
    • Heap use stays roughly flat as the database grows (bounded by the page cache), and open time does not scale with database size.
    • A second open of a locked database fails with the file-lock error code.
    • A file exported from OPFS opens natively with Minigraf::open(), and a native .graph file imports and opens.
    • Docs: wiki Use-Cases.md / Architecture.md, README browser section, docs/ERROR_REFERENCE.md if a new code is needed.

    Dependencies

    Needs v8 (#374, #434, #388), which provides copy-on-write, page CRCs and O(change) checkpoints. Related: #440.

    Philosophy

    Aligned: embedded, single .graph file, zero configuration, no new crates. Cost: a second browser persistence path to test, and a worker-only API.

    Activity

    1. added this to the v3.1.0 milestone on Oct 5, 2026
    2. adityamukho commented on Oct 7, 2026

      @adityamukho
      CollaboratorAuthor

      One more reason to move away from IndexedDB, found while working on #411/#440 (PR #474):

      If an IndexedDB flush fails (quota exceeded, transaction aborted), BrowserDb has already drained the buffer's dirty set via take_dirty() before the async write_pages call, and it does not restore it on error. The in-memory state has already activated the new meta. So the next successful flush writes only its dirty pages, and IndexedDB can end up with a meta page that references pages that never reached the store. This affects apply_write, checkpoint() and importGraph() in src/browser/mod.rs.

      The cause is the split between a sync StorageBackend (the buffer) and async IndexedDB persistence: the commit point in memory and the commit point in storage are two separate steps. With an OPFS FileSystemSyncAccessHandle backend, save() writes and syncs through the backend directly, so the native copy-on-write commit protocol (§8.3 of the v8 spec) applies unchanged and there is no separate flush to fail.

      Until then, the IndexedDB fallback still needs a fix: on flush error, put the pages back into the dirty set (or roll back the in-memory meta) so a later flush can't skip them.

    3. modified the milestones: v3.1.0, v3.0.0 on Oct 9, 2026
    4. adityamukho commented on Oct 14, 2026

      @adityamukho
      CollaboratorAuthor

      Moved to v3.1.0: a new persistence path and worker-only API, done after the streaming engine (#473). The browser pins and quarantine from #543 are already on v3, and an OPFS backend picks them up through PageAllocator like the file backend. Expect a rebase against #550 (large transactions commit as a checkpoint) in src/browser/mod.rs.

    5. added a commit that references this issue on Oct 23, 2026
    6. modified the milestones: v3.next, v3.0.0 on Nov 6, 2026
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Metadata

    Metadata

    Assignees

    No one assigned

      Labels

      enhancementNew feature or request

      Type

      No type

      Projects

      No projects

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        0