Skip to content
potluckdb

Browser

macOS · Linux · Windows · Containers · iOS and iPadOS · Android · ChromeOS · Browser · Node (WebAssembly)

potluckdb ships one browser target: its runtime compiled to WebAssembly. The package contains its hosting worker and the Node package’s application API. Databases live in the origin private file system. A remote is a directory there or a bucket reached from the page. See Concepts for the database model.

The browser needs the Origin Private File System and synchronous access handles in workers. The page must be cross-origin isolated. Serve it with both headers:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

The release test suite runs the quickstart in Chromium. The shipped module’s Runtime Interface conformance also runs in Firefox and Safari.

LanguagePackageAnalytics package
JavaScript and TypeScript@potluckdb/potluckdb-browser@potluckdb/potluckdb-browser-analytics
TargetArtifact families
browser-wasm32browser-wasm, npm-browser
Terminal window
npm install @potluckdb/potluckdb-browser

The package is an ES module containing the WebAssembly runtime and its hosting worker. A bundler only needs to copy its .wasm and worker files unchanged. In development and production, the server sends both headers above with the page.

Remote storage. Provider adapters reach buckets from the page through fetch:

Storage serviceAuthentication
S3-compatibleSignature Version 4
Google Cloud StorageBearer token
Azure Blob StorageShared access signature

The application supplies a credentials function. The adapter calls it before the first request and again before the expiry it returned. The backend can therefore issue short-lived credentials scoped to the namespace. No long-lived credential key reaches the page. The bucket’s CORS configuration must admit the origin for the adapter’s methods.

import { open, storage } from "@potluckdb/potluckdb-browser";
const credentials = async () => {
const response = await fetch("/api/storage-credentials");
return response.json(); // { accessKeyId, secretAccessKey, sessionToken, expiresAt }
};
const remote = await storage.fromS3({ bucket: "my-bucket", namespace: "notes", region: "eu-west-1", credentials });
const db = await open("app", remote);

Supply the bucket’s region explicitly. For AWS S3, omit endpoint to use https://s3.{region}.amazonaws.com. Unlike other SDKs, the browser has no provider configuration chain to discover the region. A missing or malformed region fails with invalid_argument before acquiring credentials or making requests.

For Cloudflare R2, use region: "auto" and endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com". For another S3-compatible storage service, supply its endpoint and documented signing region.

storage.fromGcs takes a function returning { token, expiresAt }. storage.fromAzure takes one returning { sas, expiresAt }. expiresAt is seconds since the Unix epoch.

Licensing. The trial and license key apply. A page has no environment or configuration directory. Pass the license key as the licenseKey open option. The trial anchor resides beside the databases in the origin private file system.

In this release. The hosting worker caches origin private file system entries it has read. While any database, snapshot or remote of the page is open, it does not see removals by another page or its own page’s code. None of the limitations below loses data.

A page can remove a database directory itself. It then cannot open a database there again while another database, snapshot or remote is open on that page. That open fails with temporarily_unavailable. Close all of them first, or use a new path.

When two pages share a directory remote and one page collects it, the other page can fail every sync() with temporarily_unavailable. The failures continue until the failing page closes every database, snapshot and remote, or reloads. Bucket remotes are unaffected.

A commit exhausting the origin’s quota fails with temporarily_unavailable rather than local_storage_full. The database refuses further writes until you close and reopen it. The reopened database holds every successful write.

Opening a database or snapshot with a bucket remote holds an origin-wide lock until that open completes. Every page needs this lock to open a database, snapshot or directory remote. A credentials function that never settles therefore stalls those calls on every page.

The self-contained program represents a laptop and phone with two databases on one page, sharing a remote. They modify the same row before either synchronizes, then reach the same state. The remote is an origin private file system directory that the page removes on exit.

  1. In an empty directory, install the package with npm install @potluckdb/potluckdb-browser.
  2. Save the two files in that directory, beside node_modules.
  3. Serve that directory with the two headers above.
  4. Open index.html.

The import map tells the browser where the package files are, and a bundler does this step for you.

The page prints Use query for this statement. and then quickstart: ok, both on the page and in the console.

<!doctype html>
<meta charset="utf-8">
<title>potluckdb quickstart</title>
<pre id="output"></pre>
<script type="importmap">{"imports": {"@potluckdb/potluckdb-browser": "./node_modules/@potluckdb/potluckdb-browser/index.js"}}</script>
<script type="module" src="./quickstart.js"></script>
// Two devices, one remote, one conflict, and its resolution.
import { open, OpenOptions, Remote, SyncPolicy } from "@potluckdb/potluckdb-browser";
const output = document.getElementById("output");
function print(line) {
output.textContent += `${line}\n`;
console.log(line);
}
function expect(condition, message) {
if (!condition) throw new Error(message);
}
// Every path is a directory in the origin's private file system. The page removes
// the whole scratch directory on exit, so every run starts from nothing.
const scratch = "quickstart";
const remote = await Remote.fromDirectory(`${scratch}/remote`); // a bucket replaces this in production; see the installation section
// The laptop synchronizes on its own: automatic is the default policy.
// onSync delivers every outcome to the program; this program dispatches each as an
// event named by its kind and registers a waiter before the write it waits for.
const laptopEvents = new EventTarget();
const once = (kind) => new Promise((resolve) => laptopEvents.addEventListener(kind, resolve, { once: true }));
const laptop = await open(`${scratch}/laptop`, remote, new OpenOptions({
onSync: (outcome) => laptopEvents.dispatchEvent(new CustomEvent(outcome.kind, { detail: outcome })),
}));
laptopEvents.addEventListener("failed", (event) => { throw event.detail.error; });
let pushed = once("pushed");
await laptop.transaction(async (setup) => { // one transaction: both statements travel in one push
await setup.execute("CREATE TABLE note(id INTEGER PRIMARY KEY, body TEXT NOT NULL)");
await setup.execute("INSERT INTO note(body) VALUES(?)", ["buy milk"]);
});
await pushed;
// The phone synchronizes only when instructed: a manual policy, and sync()
// calls that return the outcome as a value. It starts from the revision the
// laptop published.
const phone = await open(`${scratch}/phone`, remote, new OpenOptions({ syncPolicy: SyncPolicy.manual() }));
const { columns, rows } = await phone.query("SELECT id, body FROM note ORDER BY id");
expect(columns.join() === "id,body", "columns are reported with the rows");
expect(rows[0].id === 1 && rows[0].body === "buy milk", "a row is read by column name");
// Both devices modify the same row before either has seen the other's write.
pushed = once("pushed");
await laptop.execute("UPDATE note SET body = ? WHERE id = ?", ["buy oat milk", 1]);
await phone.execute("UPDATE note SET body = ? WHERE id = ?", ["buy milk and eggs", 1]);
await pushed;
// The phone's push finds a remote head it has not seen:
// a conflict, returned rather than thrown.
expect((await phone.sync()).kind === "conflict", "the phone's push is refused with a conflict");
const resolution = await phone.resolveConflict(); // adopt the laptop's revision, replay the phone's statement on it
expect(resolution.quarantined.length === 0, "no statement was quarantined");
const pulled = once("pulled");
expect((await phone.sync()).kind === "pushed", "the phone's push succeeds after resolution");
// The laptop pulls the resolved revision on its own. Both devices hold the same row.
await pulled;
for (const db of [laptop, phone]) {
const current = await db.query("SELECT body FROM note WHERE id = ?", [1]);
expect(current.rows[0].body === "buy milk and eggs", "both devices hold the same row");
}
// query reads, execute writes. The wrong call is refused before execution.
const refusal = await laptop.execute("SELECT count(*) FROM note").then(() => null, (error) => error);
expect(refusal !== null && refusal.code === "read_in_execute", "execute refuses a read and names the code");
print(refusal.action); // Use query for this statement.
await laptop.close();
await phone.close();
await remote.close();
const root = await navigator.storage.getDirectory();
await root.removeEntry(scratch, { recursive: true });
print("quickstart: ok");

Browser analytical reads need the engine linked into the module at build time. No loader can find one later. Install and import @potluckdb/potluckdb-browser-analytics in place of the default package.

Each release candidate builds every target in the table and passes every required check of the release matrix before promotion. The packages that this page names are the packages that the release tested.

Each release builds the default and analytics browser modules. It runs Runtime Interface conformance in Chromium, Firefox and Safari, and this quickstart in Chromium. All three provider adapters also run conformance in Chromium.