# @remelondb/driver-web

The browser `SqliteDriver`: **SQLite-WASM running in a dedicated Worker**,
persistent via the OPFS SyncAccessHandle pool VFS, reached over a
postMessage RPC. This is the architecture the whole seam was designed
around: OPFS sync-access handles exist only in workers, the main thread
can only reach a worker asynchronously, hence the Promise-shaped driver
contract everywhere.

## Status: verified on OPFS in Chromium, Firefox, WebKit, and Safari

The worker-side server is transport-abstracted (`Endpoint`), so the test
suite runs the **exact same server code against real SQLite-WASM
in-process under Node**: driver → RPC → wasm SQLite, end to end,
including the full stack (Database, models, observation, sync) on top.
The `storage: 'opfs'` path additionally runs as a real-browser
conformance suite on all three engines, plus real Safari via
safaridriver (checklist below).

## Usage

The recommended app bootstrap is the database manager — one shared
open, retryable failure, takeover handled — with the React hook for
lifecycle state:

```ts
import { createDatabaseManager, Database } from '@remelondb/core';
import { useDatabaseState } from '@remelondb/core/react';
import { WebSqliteDriver } from '@remelondb/driver-web';

const manager = createDatabaseManager({
  open: (onTakenOver) =>
    Database.open({
      driver: new WebSqliteDriver({
        shared: true,
        takeover: true,
        onTakenOver,
      }),
      schema,
      migrations,
      modelClasses: [Task],
      name: 'app.db',
    }),
});

// in a component: const { status, error } = useDatabaseState(manager)
// when status is 'ready': manager.database
```

`Database.open` is the primitive underneath — fine to call directly in
tests and scripts; apps should let the manager own the lifecycle. The
todo-sync example's `frontend/src/db.ts` is the working reference.

- **Persistence is never silently downgraded.** The default `storage: 'opfs'`
  fails loudly if OPFS is unavailable. `open()` rejects with a
  typed **`OpfsUnavailableError`** (`code: 'OPFS_UNAVAILABLE'`), refused
  up front — before any worker/broker spawns — when OPFS is blocked: an
  old browser, a sandboxed iframe, Node, or **Firefox private browsing /
  "never remember history" / blocked site data**, where
  `navigator.storage.getDirectory()` throws `SecurityError` even over
  https. Catch it to degrade gracefully (show a message, or reopen with
  `storage: 'memory'`). Pass `storage: 'memory'` directly when
  non-persistence is intended (previews, tests).
- A transient OPFS pool lock is retried with bounded backoff. If the browser
  still has not released it, `open()` rejects with a typed
  **`OpfsPoolHeldError`** (`code: 'OPFS_POOL_HELD'`) whose `diagnostic`
  preserves the browser's final `NoModificationAllowedError`. Firefox may
  require a full browser restart after a page worker dies holding the pool.
- The OPFS SAH-pool VFS needs **no COOP/COEP headers** (unlike the
  SharedArrayBuffer-based VFS).
- The worker is spawned via
  `new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })`
  — Vite and comparable bundlers resolve this pattern and bundle the
  worker (including the wasm asset from `@sqlite.org/sqlite-wasm`).

## Multi-tab usage

The OPFS SAH-pool VFS acquires **exclusive** sync access handles: one
connection per database, full stop. A second tab that tries to open the
same database fails loudly (consistent with the no-silent-downgrade
rule) — it does not corrupt anything, does not silently fall back to
memory, and the error says what is going on: the database is open in
another tab.

**Single-owner with takeover** is built into the driver. Tabs
coordinate through a Web Lock (`remelondb:<name>`) held for the
connection's lifetime:

```ts
const driver = new WebSqliteDriver({
  takeover: true, // take the database from another tab
  onTakenOver: () => {
    // ...and learn when one takes it from us
    showBanner('This app was opened in another tab.');
  },
});
```

- Default (`takeover` unset): opening a database another tab holds
  rejects with `'<name>' is open in another tab or window`.
- `takeover: true`: `open()` steals the lock. The losing driver
  terminates its worker — which is what releases the pool's handles;
  in-flight statements there are abandoned, committed data is safe on
  disk — fires its `onTakenOver` callback, and every later call on it
  rejects with a clear error. The winning `open()` retries the handle
  acquisition for a bounded period, then reports `OpfsPoolHeldError` if
  the browser has not released it.
- Environments without the Web Locks API (Node tests, non-secure
  contexts) skip coordination entirely and behave as before.
- With shared mode active, `takeover` and `onTakenOver` are unused:
  every tab joins one owner, so there is nothing to take over. They
  apply where the driver falls back to single-tab mode.

Only one tab is active at a time, but the handoff is explicit and
nothing can race.

The same rule rules out background sync on the web. A service worker
woken by Web Push cannot open the database to sync: it either fails
because a tab holds it, or takes it over and terminates that tab's
worker. So Web Push can tell the user something changed; the data
lands when the tab is next opened or focused. The Background Sync API
is blocked for the same reason. Mobile has no such restriction — see
[when to sync](https://github.com/dustyway/remelonDB/blob/main/docs/sync-triggering.md).

For every tab live simultaneously, opt in to **shared mode**:

```ts
const driver = new WebSqliteDriver({ shared: true });
```

All tabs then route through one SharedWorker broker to a single
connection: same-name opens share it instead of contending (no lock,
no takeover), commits in any tab reach every other tab's record cache
and observers, and `db.write` blocks are serialized across tabs by the
broker so racing read-modify-write converges (see "The RPC protocol"
below and [docs/multi-tab.md](../../docs/multi-tab.md) for the design,
including the ordering invariant; [docs/multi-tab.qnt](../../docs/multi-tab.qnt)
model-checks it). Where `SharedWorker` is unavailable (Chrome for
Android), the option gracefully falls back to the single-owner
behavior above — same API, same errors, the takeover UI simply becomes
reachable again.

The broker releases its own OPFS pool before replacing a silent compute
worker. If `OpfsPoolHeldError` still reaches the app, offer Retry; the holder
may be another live context, and Firefox may require a full browser restart
after an orphaned worker handle. Retry cannot safely steal from a live holder.

Sync also coordinates itself: `synchronize` runs only in the tab
holding the broker's sync lease (renewed on each tick, inherited when
the holder closes), so a naive per-tab sync interval is correct. The
remaining sharp edges before shared mode becomes the default are the
open questions in docs/multi-tab.md.

## Bundlers (Vite)

When consuming this package from npm with Vite, exclude it from
dependency prebundling, or the worker URLs resolve into `.vite/deps`
where the worker files do not exist:

```ts
// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    // prebundling rewrites the driver's worker URLs into .vite/deps,
    // where the worker files do not exist
    exclude: [
      '@remelondb/driver-web',
      '@remelondb/core',
      '@sqlite.org/sqlite-wasm',
    ],
  },
});
```

Shared mode needs `@remelondb/driver-web` 0.1.2 or newer.

A complete minimal consumer (registry tarballs, Vite, single-tab and
shared mode) is scaffolded by `scripts/vite-smoke.mjs` — it runs in CI
against every change and doubles as the reference setup.

## The RPC protocol

Every request carries an `id`; the answering side replies with
`{id, ok, result}` or `{id, ok: false, error}`. Requests and responses
are structured-clonable plain data (`src/protocol.ts` is the source of
truth). Who answers depends on the op:

| Op                            | Answered by | Purpose                                                          |
| ----------------------------- | ----------- | ---------------------------------------------------------------- |
| `open`                        | worker      | open by name, report `user_version` (async: awaits pool install) |
| `close`                       | worker      | close the named connection                                       |
| `query` / `execute`           | worker      | one SELECT / one non-SELECT statement                            |
| `executeBatch`                | worker      | atomic multi-statement transaction                               |
| `setUserVersion`              | worker      | `PRAGMA user_version` after setup/migration                      |
| `destroy`                     | worker      | delete the database and sidecar files                            |
| `ping`                        | worker      | liveness probe (the broker checks its compute channel)           |
| `releasePool`                 | worker      | broker retirement: close databases and pause the OPFS VFS        |
| `acquireSlot` / `releaseSlot` | broker      | cross-tab write-block arbitration (never reach SQLite)           |
| `publishChanges`              | broker      | relay a commit's change set to the other tabs                    |
| `syncTurn`                    | broker      | sync-lease request: grant/renew for the holder, deny others      |

In shared mode the broker additionally pushes three unsolicited control
messages to tabs: `{control: 'spawnWorker'}` asks a tab to host the compute
worker, `{control: 'discardWorker'}` terminates a late worker after another tab
won the spawn race, and `{control: 'externalChanges', name, changes}` carries
another tab's commit. A hosting tab transfers the worker channel back with
`{control: 'adoptWorkerPort'}`. In dedicated mode only the worker-answered ops
occur, over a direct Worker transport.

## Layout

| Piece                    | Role                                                                                                                                        |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/protocol.ts`        | RPC message types + the `Endpoint` transport abstraction                                                                                    |
| `src/server.ts`          | worker-side server: connections by name, statement cache, boolean→0/1 binding, atomic batches with rollback, OPFS/memory storage resolution |
| `src/worker.ts`          | browser Worker entry (sqlite-wasm init + server)                                                                                            |
| `src/WebSqliteDriver.ts` | main-thread driver: request/response correlation, seam contract                                                                             |

## Browser-verification checklist

- [x] Full driver contract against real sqlite-wasm (Node, in-process
      endpoint): round-trip, batch rollback, boolean binding,
      user_version, error surfaces
- [x] Full stack on the driver: Database + models + observation + sync
- [x] Loud failure when OPFS is unavailable (no silent downgrade)
- [x] `storage: 'opfs'` in real Chromium, Firefox, and WebKit (vitest
      browser mode + Playwright): the FULL conformance suite on OPFS,
      persistence, `destroy()` unlinking pool + journal files, and
      durability across worker termination (page-reload equivalent). Run:
      `pnpm --filter @remelondb/driver-web test:browser` (`BROWSER=firefox`
      / `BROWSER=webkit` for the others; WebKit needs the persistent
      context wired up in `vitest.webkit-provider.ts` — ephemeral WebKit
      contexts have no OPFS backing store. WebKit OPFS is macOS-only:
      the Linux GTK port doesn't ship the sync-access-handle APIs, so
      CI runs WebKit on a macOS runner)
- [x] Real Safari via `BROWSER=safari` (webdriverio + safaridriver,
      macOS only): the full conformance suite, 51/51. One-time setup:
      enable "Allow remote automation" in Safari's Developer settings
      (`sudo safaridriver --enable`). Not headless — Safari can't. Only
      one automation session may exist; kill stray `safaridriver`
      processes if the session refuses to start.
- [x] Vite production smoke: `pnpm --filter @remelondb/driver-web smoke:vite`.
      It covers worker and wasm loading in browser mode, packs the tarballs,
      scaffolds the reference Vite app, runs `vite build` and `vite preview`,
      and drives headless Chromium against the output. OPFS opens from the
      production bundle and data persists across a page reload.
- [x] Multi-tab behavior: single-connection by design, documented below
      (see "Multi-tab usage")

### Why browser tests run serially (`fileParallelism: false`)

The OPFS SAH pool is a single exclusive resource per origin — one worker
owns it at a time (see "Multi-tab usage"). Vitest runs test files in
parallel by default, so several `*.browser.test.ts` files then contend
for that one pool. Two failures come out of that contention, both most
visible on WebKit:

- a recycled pool slot returns stale state, so a just-created table
  reads back missing (`SQLITE_ERROR: no such table`) or a fresh database
  reads as corrupt (`SQLITE_NOTADB`);
- the single-owner takeover in `sharedFallback.browser.test.ts` never
  gets the pool and times out.

Running the files serially (each owns the pool uncontended) fixes both:
the full suite is green across repeated WebKit runs with no retries. It
is not a product bug — an application opens one long-lived database and
never has two contexts race a cold pool this way. Ruled out as causes
along the way: the request dispatcher (serialized, covered by
`server.concurrency.test.ts`), pool capacity (fails at 512 too), `unlink`
accumulation (pool stays at one file), and stale sqlite-wasm (on the
latest, 3.53.0-build1). To see the raw flake, set `fileParallelism` back
to `true`.
