Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ jobs:
- uses: ./.github/actions/setup-workspace
with:
task: test
command: pnpm test:coverage && pnpm test:integration && pnpm test:smoke
# The SQLite wasm store's integration tests run in Chromium through Vitest's browser mode; Playwright installs the browser build its pinned version expects.
command: pnpm --filter @exadev/process-graph-sqlite-wasm exec playwright install --with-deps chromium && pnpm test:coverage && pnpm test:integration && pnpm test:smoke

knip:
name: Knip
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ reports
!CLAUDE.md
!**/AGENTS.md
!**/CLAUDE.md
.vitest
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config
| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; with an in-memory fake and a conformance kit. |
| [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. |
| [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. |
| [`graph-sqlite-wasm`](packages/adapters/graph/sqlite-wasm/README.md) | The SQLite store in the browser: SQLite wasm in a dedicated worker on OPFS `opfs-sahpool`, shared between tabs under a Web Lock with no special headers, served to the page over oRPC. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |

## Conventions
Expand Down
1 change: 1 addition & 0 deletions packages/adapters/graph/sqlite-wasm/AGENTS.md
1 change: 1 addition & 0 deletions packages/adapters/graph/sqlite-wasm/CLAUDE.md
43 changes: 43 additions & 0 deletions packages/adapters/graph/sqlite-wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @exadev/process-graph-sqlite-wasm

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-sqlite-wasm) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

The SQLite graph store from `@exadev/process-graph-store-sqlite` in the browser. SQLite compiled to WebAssembly (`@sqlite.org/sqlite-wasm`) runs in a dedicated worker on the origin private file system, Drizzle reaches it through `drizzle-orm/sqlite-proxy`, and the page talks to the store over oRPC's message-port transport. Any number of tabs can use one database, and the page needs no `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy` headers.

```ts
// store.worker.ts
import { serveGraphStoreInWorker } from "@exadev/process-graph-sqlite-wasm/worker";

serveGraphStoreInWorker({ name: "graph" });
```

```ts
// in the page
import { connectGraphStore } from "@exadev/process-graph-sqlite-wasm";

const store = connectGraphStore(
new Worker(new URL("./store.worker.ts", import.meta.url), { type: "module" }),
);
await store.v1.query.ready({ modelId: "release" });
```

With Vite, exclude `@sqlite.org/sqlite-wasm` from dependency pre-bundling (`optimizeDeps.exclude`), since it loads its `.wasm` file relative to itself.

## How tabs share one database

The database lives on SQLite's `opfs-sahpool` VFS. A pool holds its OPFS sync access handles exclusively, so only one worker in the origin can have it open at a time. Every store operation therefore takes an exclusive Web Lock named after the database, unpauses the pool, opens the file, runs, then closes the file and pauses the pool before releasing the lock: tabs take turns rather than failing. The lock covers the whole store operation, not each SQL statement, because a Drizzle transaction is several statements through the proxy. Changes committed in one tab reach every tab's `changes` subscribers over a `BroadcastChannel` named after the database. The schema is migrated on the first operation with the migrations embedded in `graph-store-sqlite`, the same statements the Node driver applies.

## Why this combination

The choice came from a spike recorded in [#22](https://github.com/ExaDev/process-graph/issues/22): two tabs on one database under Playwright, against a Vite dev server with no cross-origin isolation headers, and again with them as a control.

- SQLite's `opfs` and `opfs-wl` VFSes need `SharedArrayBuffer`, which a page has only when cross-origin isolated. Without the headers, `opfs-wl` never installs.
- SQLocal only opens the `opfs` VFS. Without the headers it falls back to an in-memory database with a console warning, so each tab silently writes to its own database and loses it on reload.
- `opfs-sahpool` needs neither, but a second tab cannot open a pool another tab holds (`NoModificationAllowedError`). Pausing the pool between operations under a Web Lock removes that, and is the only combination tried that kept two tabs reading and writing one database without the headers.
- Drizzle's `sqlite-proxy` migrator cannot run in a browser, because `drizzle-orm/migrator` imports `node:fs`; the store applies its migrations itself through the proxy.

Reacquiring the pool's handles on every operation is the cost of this choice. Electing one long-lived owner tab is the alternative if that cost ever matters, and it changes nothing above the worker.

## Tests

The contract's conformance kit runs in Chromium through Vitest's browser mode (`pnpm test:integration`), with a fresh database per test and no cross-origin isolation. Further browser tests run two workers on one database, as two tabs would, and reopen a database after its writer has gone. CI installs Chromium for them with Playwright.
16 changes: 16 additions & 0 deletions packages/adapters/graph/sqlite-wasm/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
projects: [
"./tsconfig.json",
"./tsconfig.node.json",
"./tsconfig.browser.json",
],
isomorphic: true,
// It starts and talks to workers, BroadcastChannels and Web Locks.
browserGlobals: true,
}),
);
83 changes: 83 additions & 0 deletions packages/adapters/graph/sqlite-wasm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"name": "@exadev/process-graph-sqlite-wasm",
"version": "0.0.0",
"description": "The SQLite graph store in the browser: SQLite compiled to WebAssembly in a dedicated worker on the OPFS sahpool VFS, shared safely between tabs with no special headers, served to the page over oRPC.",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/adapters/graph/sqlite-wasm"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/adapters/graph/sqlite-wasm#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./worker": {
"types": "./dist/worker.d.ts",
"import": "./dist/worker.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"registry": "https://registry.npmjs.org/"
},
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.browser.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only",
"test:integration": "turbo run _test:integration",
"_test:integration": "vitest run --project browser"
},
"keywords": [
"process-model",
"store",
"sqlite",
"wasm",
"opfs",
"worker"
],
"dependencies": {
"@exadev/process-graph-store-contract": "1.0.1",
"@exadev/process-graph-store-sqlite": "1.0.1",
"@orpc/client": "2.0.0-beta.41",
"@orpc/server": "2.0.0-beta.41",
"@sqlite.org/sqlite-wasm": "3.53.4-build2",
"drizzle-orm": "1.0.0-rc.4"
},
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@types/node": "26.6.4",
"@vitest/browser-playwright": "5.0.3",
"@vitest/coverage-v8": "5.0.3",
"eslint": "10.12.0",
"playwright": "1.63.0",
"publint": "0.3.25",
"typescript": "6.0.3",
"vitest": "5.0.3"
}
}
11 changes: 11 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { GraphStoreClient } from "@exadev/process-graph-store-contract";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/message-port";

/**
* A client for the graph store a worker serves with `serveGraphStoreInWorker`. Every procedure of the contract is available, with its abort signal and typed errors, as if the store were in the page.
* @param worker - The dedicated worker running the store.
* @returns The store's oRPC client.
*/
export const connectGraphStore = (worker: Readonly<Worker>): GraphStoreClient =>
createORPCClient(new RPCLink({ port: worker }));
82 changes: 82 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type {
Database,
SAHPoolUtil,
Sqlite3Static,
} from "@sqlite.org/sqlite-wasm";
import type { Exclusive } from "@exadev/process-graph-store-sqlite";
import { drizzle, type SqliteRemoteDatabase } from "drizzle-orm/sqlite-proxy";

/** Where the database lives: an OPFS `opfs-sahpool` pool and a file in it. */
export interface DatabaseLocation {
/** The pool's name, which is also its OPFS directory. */
readonly pool: string;
/** The database file's path within the pool. */
readonly file: string;
}

/** The database a worker serves, and the exclusivity that makes it safe to share. */
export interface WorkerDatabase {
/** A Drizzle database whose statements run on the file this worker currently holds open. */
readonly db: SqliteRemoteDatabase;
/** Holds the database across every tab for one store operation. */
readonly exclusive: Exclusive;
}

/**
* Opens a database on the `opfs-sahpool` VFS so that any number of tabs, each with its own worker, can use it with no cross-origin isolation headers.
*
* A pool holds its OPFS sync access handles exclusively, so only one worker in the origin can have it open at a time. Each store operation therefore takes an exclusive Web Lock named after the pool, unpauses the pool (reacquiring its handles), opens the file, runs, then closes the file and pauses the pool (releasing them) before the lock is released. A Drizzle transaction is several statements, so the lock covers the whole store operation rather than each statement. The choice and the evidence for it are recorded in https://github.com/ExaDev/process-graph/issues/22.
* @param sqlite3 - The SQLite module, initialising; the first operation waits for it.
* @param location - The pool and file.
* @returns The database and its exclusivity.
*/
export const openWorkerDatabase = (
sqlite3: Readonly<Promise<Sqlite3Static>>,
location: DatabaseLocation,
): WorkerDatabase => {
// The pool, once its installation has started. It is installed inside the first operation, under the lock, because installing takes the handles another tab may hold.
let pool: Promise<SAHPoolUtil> | undefined;
let open: Database | undefined;

// The store reads through `values`, `all` and query builders, never `.get()`, so `get` is refused rather than answered: the proxy's declared result type has no way to say "no row", which Drizzle's `get` signals with a missing `rows`.
const db = drizzle(async (statement, params, method) => {
if (method === "get") {
throw new Error("The graph store's worker database does not serve get");
}
if (open === undefined) {
throw new Error(
"The database was used outside a store operation; only the store's exclusive section may run statements",
);
}
const rows = open.exec(statement, {
bind: params,
rowMode: "array",
returnValue: "resultRows",
});

return Promise.resolve({ rows });
});

const exclusive: Exclusive = async (operation) =>
navigator.locks.request(location.pool, { mode: "exclusive" }, async () => {
// Installing the pool takes its handles, so only an already installed pool needs unpausing.
const installing = pool === undefined;
pool ??= sqlite3.then(async (module) =>
module.installOpfsSAHPoolVfs({ name: location.pool }),
);
const held = await pool;
if (!installing) {
await held.unpauseVfs();
}
open = new held.OpfsSAHPoolDb(location.file);
try {
return await operation();
} finally {
open.close();
open = undefined;
held.pauseVfs();
}
});

return { db, exclusive };
};
40 changes: 40 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
ChangeFeed,
storeChangeSchema,
type StoreChange,
} from "@exadev/process-graph-store-contract";

/**
* A change feed shared by every worker serving the same database in this origin: each change committed here is published locally and posted on a `BroadcastChannel`, and each change another worker posts there is published locally. Every tab's subscribers therefore hear every tab's writes.
*/
export class BroadcastChangeFeed extends ChangeFeed {
readonly #channel: BroadcastChannel;

/**
* @param name - The channel name, one per database, so two databases in one origin do not hear each other.
*/
constructor(name: string) {
super();
this.#channel = new BroadcastChannel(name);
this.#channel.addEventListener("message", (event: MessageEvent) => {
const parsed = storeChangeSchema.safeParse(event.data);
if (parsed.success) {
super.publish(parsed.data);
}
});
}

/**
* Publishes a change committed in this worker, here and to every other worker on the channel.
* @param change - The committed change.
*/
override publish(change: StoreChange): void {
super.publish(change);
this.#channel.postMessage(change);
}

/** Stops hearing and posting changes. */
close(): void {
this.#channel.close();
}
}
35 changes: 35 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/feed.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { BroadcastChangeFeed } from "./feed.ts";

const change = { modelId: "m", kind: "saved", revision: 1 } as const;

/** Reads the next change a subscription delivers. */
const next = async (feed: BroadcastChangeFeed) =>
feed.subscribe("m")[Symbol.asyncIterator]().next();

describe("BroadcastChangeFeed", () => {
test("delivers a change published in one feed to the subscribers of another on the same channel", async () => {
const name = `feed-${crypto.randomUUID()}`;
const here = new BroadcastChangeFeed(name);
const there = new BroadcastChangeFeed(name);
const heard = next(there);
here.publish(change);

await expect(heard).resolves.toEqual({ done: false, value: change });
here.close();
there.close();
});

test("ignores a message on the channel that is not a change", async () => {
const name = `feed-${crypto.randomUUID()}`;
const feed = new BroadcastChangeFeed(name);
const heard = next(feed);
const stranger = new BroadcastChannel(name);
stranger.postMessage({ modelId: "m", kind: "renamed" });
new BroadcastChangeFeed(name).publish(change);

await expect(heard).resolves.toEqual({ done: false, value: change });
stranger.close();
feed.close();
});
});
4 changes: 4 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* The SQLite graph store in the browser. The worker module (`./worker`) serves the store from a dedicated worker over SQLite compiled to WebAssembly on the OPFS `opfs-sahpool` VFS; this entry connects a page to it.
*/
export { connectGraphStore } from "./client.ts";
38 changes: 38 additions & 0 deletions packages/adapters/graph/sqlite-wasm/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
import { createGraphStoreRouter } from "@exadev/process-graph-store-contract";
import { createSqliteGraphStore } from "@exadev/process-graph-store-sqlite";
import { RPCHandler } from "@orpc/server/message-port";
import { openWorkerDatabase } from "./database.ts";
import { BroadcastChangeFeed } from "./feed.ts";

/** Which database a worker serves. */
export interface GraphStoreWorkerOptions {
/**
* The database's name. It names the OPFS `opfs-sahpool` pool (and so its directory), the Web Lock that serialises tabs, and the channel changes are shared on, so every worker in the origin given the same name serves the same database.
*/
readonly name: string;
}

/**
* Serves the graph store from inside a dedicated worker, to the page that started it, over oRPC's message-port transport. Call it once at the top level of the worker module; connect from the page with `connectGraphStore`.
*
* The SQLite module starts initialising at once and the first store operation waits for it. The database is opened only inside each store operation, under a Web Lock shared by every tab, so tabs take turns rather than failing; see `openWorkerDatabase`. Changes committed in any tab reach every tab's `changes` subscribers through a `BroadcastChannel`.
* @param options - Which database to serve.
*/
export const serveGraphStoreInWorker = (
options: GraphStoreWorkerOptions,
): void => {
const { db, exclusive } = openWorkerDatabase(sqlite3InitModule(), {
pool: options.name,
file: "/graph.sqlite3",
});
new RPCHandler(
createGraphStoreRouter(
createSqliteGraphStore({
db,
exclusive,
feed: new BroadcastChangeFeed(options.name),
}),
),
).upgrade(self);
};
Loading
Loading