@joaoh82/sqlrite-wasm
v0.9.1
Published
WebAssembly build of the SQLRite embedded database engine. Runs entirely in the browser via wasm-pack / wasm-bindgen.
Downloads
3,570
Readme
@joaoh82/sqlrite-wasm
WebAssembly build of the SQLRite embedded database engine. Runs entirely in a browser tab — no server, no backend — via wasm-pack / wasm-bindgen.
Why the scoped name? Same reason as
@joaoh82/sqlrite(the Node SDK): the unscoped form risks tripping npm's similarity check againstsqlite-wasm/sqlite. Scoping under@joaoh82is the standard Node ecosystem pattern (@napi-rs/*,@swc/*, etc.).
Install
npm install @joaoh82/sqlrite-wasmThe npm package ships the bundler target (webpack / vite / rollup / parcel-friendly). For other build targets, build locally from a repo clone:
# From a repo checkout:
cd sdk/wasm
wasm-pack build --target web --release # → pkg/, ES modules for direct browser use
# …or for a bundler (Webpack / Vite):
wasm-pack build --target bundler --release
# …or for Node.js:
wasm-pack build --target nodejs --releaseQuick tour
import init, { Database } from '@joaoh82/sqlrite-wasm';
// Async init — fetches the .wasm file and wires up memory.
// Nothing else in the module works until this resolves.
await init();
const db = new Database(); // always in-memory in the WASM build
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)");
db.exec("INSERT INTO users (name, age) VALUES ('alice', 30)");
const rows = db.query("SELECT id, name, age FROM users");
// → [{ id: 1, name: 'alice', age: 30 }]See ../../examples/wasm/ for a runnable HTML demo — a tiny SQL console that make build && make serve spins up on http://localhost:8080.
Transactions
db.exec("BEGIN");
db.exec("INSERT INTO users (name) VALUES ('carol')");
if (looksGood) {
db.exec("COMMIT");
} else {
db.exec("ROLLBACK"); // restores pre-BEGIN state
}
console.log(db.inTransaction); // false once COMMIT / ROLLBACK runsVector columns + KNN (Phase 7a–7d)
VECTOR(N) storage class plus vec_distance_l2 / vec_distance_cosine / vec_distance_dot distance functions. Vector literals are bracket arrays.
db.exec("CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(384))");
db.exec("INSERT INTO docs (id, embedding) VALUES (1, [0.1, 0.2, ..., 0.0])");
const top10 = db.query(`
SELECT id FROM docs
ORDER BY vec_distance_l2(embedding, [0.1, 0.2, ..., 0.0])
LIMIT 10
`);HNSW indexes work in the WASM build too — CPU-only, no SIMD on wasm32, but algorithmically identical:
db.exec("CREATE INDEX idx_docs_emb ON docs USING hnsw (embedding)");JSON columns (Phase 7e)
JSON / JSONB columns are validated at INSERT time. Use json_extract / json_type / json_array_length / json_object_keys. Path subset: $, .key, [N], chained.
db.exec("CREATE TABLE events (id INTEGER PRIMARY KEY, payload JSON)");
db.exec(`INSERT INTO events (payload) VALUES ('{"user": {"name": "alice"}, "score": 42}')`);
const rows = db.query(`SELECT json_extract(payload, '$.user.name') AS name FROM events`);
// → [{ name: 'alice' }]Natural-language → SQL (Phase 7g.7)
Per Phase 7 plan Q9, the WASM SDK has a different ask() shape than the other SDKs. The WASM module does the schema-aware prompt construction in-page, but does NOT make the HTTP request itself. The caller's JS code does the call, typically routed through their own backend.
Why this design
Two reasons direct browser-to-LLM calls don't work:
- CORS. Browsers block direct cross-origin POSTs from a WASM module to
api.anthropic.com/api.openai.comunless the LLM provider serves CORS headers. They don't, by design — they don't want users embedding API keys in client-side JS. - API key exposure. Even if CORS were OK, putting the API key into a WASM-loaded page exposes it to anyone who opens devtools.
Both problems disappear server-side. Node, Python, Go, Desktop (Tauri runs the call in the Rust backend, not the webview) all do the HTTP from a trusted process. WASM solves it with a split: the browser tab does the prompt building (it has the schema and the rules), then hands the request to the user's backend which holds the API key.
Two-step API
import init, { Database } from '@joaoh82/sqlrite-wasm';
await init();
const db = new Database();
db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)`);
// Step 1 — build the LLM-API request body in the browser.
const payload = db.askPrompt('How many users are over 30?');
// payload looks like:
// {
// model: 'claude-sonnet-4-6',
// max_tokens: 1024,
// system: [
// { type: 'text', text: '<rules block>' },
// { type: 'text', text: '<schema>...</schema>',
// cache_control: { type: 'ephemeral' } }
// ],
// messages: [{ role: 'user', content: 'How many users are over 30?' }]
// }
// Step 2 — your backend forwards this to Anthropic with your API key.
const apiResponse = await fetch('/api/llm/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.text()); // raw response body string
// Step 3 — hand the raw API response back to WASM.
const result = db.askParse(apiResponse);
// → { sql: 'SELECT id, name FROM users WHERE age > 30',
// explanation: 'Counts users older than thirty.',
// usage: { inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens } }
// Now run the SQL however you want.
const rows = db.query(result.sql);Backend proxy (the bit you write)
The WASM SDK is provider-agnostic at the JS boundary, but askPrompt's default output shape is Anthropic's /v1/messages body, so you can forward it as-is to Anthropic. Your backend just needs to:
- Receive the JSON payload from the browser.
- Add the
x-api-keyheader from your secure storage. - POST to
https://api.anthropic.com/v1/messages. - Pipe the response body back to the browser.
A minimal Node/Express version:
import express from 'express';
const app = express();
app.use(express.json());
app.post('/api/llm/complete', async (req, res) => {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(req.body),
});
res.status(upstream.status);
res.set('content-type', 'application/json');
res.send(await upstream.text());
});
app.listen(3000);A minimal Cloudflare Worker / Vercel Edge function is essentially identical — read JSON in, forward with the key header, pass the response body through. The API key stays on the server; the browser never sees it.
Ready-to-deploy templates for Cloudflare Workers, Vercel Edge Functions, Deno Deploy, Firebase Functions, AWS Lambda, and Express live in docs/ask-backend-examples.md. For the broader Ask-feature reference (every SDK, env vars, defaults, prompt caching, security model), see docs/ask.md. A runnable end-to-end demo (browser + zero-dep Node proxy) lives at examples/wasm/ — make build && make ask-demo.
askPrompt options
import { AskPromptOptions } from '@joaoh82/sqlrite-wasm';
const opts = new AskPromptOptions();
opts.model = 'claude-haiku-4-5'; // default: 'claude-sonnet-4-6'
opts.max_tokens = 512; // default: 1024
opts.cache_ttl = '1h'; // default: '5m', also '1h' or 'off'
const payload = db.askPrompt('How many users?', opts);Using a non-Anthropic provider on your backend
askPrompt produces an Anthropic-shaped body. If your backend talks to OpenAI / Ollama / etc., translate the body server-side before forwarding (the system blocks → system message, messages array stays the same shape). The token-usage fields in askParse's output read from Anthropic's usage shape too — for non-Anthropic responses, your backend can normalize the response into that shape before returning it to the browser.
OpenAI / Ollama / Together / ... bindings are tracked in the broader Phase 7 roadmap (docs/phase-7-plan.md Q4); the WASM SDK's split design means provider variety lives entirely on your backend, with no SDK changes needed.
Verifying prompt-cache hits
The result.usage.cacheReadInputTokens field in askParse's output reports tokens served from Anthropic's prompt cache. After the first askPrompt call against a given schema, repeat calls within 5 minutes (1 hour with cacheTtl: '1h') should show non-zero cacheReadInputTokens. If it stays zero, something in the prefix is invalidating the cache — most likely a timestamp / UUID / non-deterministic field bleeding into the system blocks. The WASM SDK builds the same schema dump the other SDKs do (alphabetically sorted, byte-stable), so as long as your DB schema doesn't change between calls, caching should work.
API surface
| JS | Purpose |
|---------------------------------|------------------------------------------------------|
| new Database() | In-memory DB (only mode in the WASM build) |
| db.exec(sql) | DDL / DML / BEGIN / COMMIT / ROLLBACK — no return |
| db.query(sql) | SELECT — returns Array<Object>, one entry per row |
| db.columns(sql) | Column names a SELECT would produce |
| db.inTransaction (getter) | true inside BEGIN/…/COMMIT |
| db.readonly (getter) | Always false (no RO path in WASM) |
| db.free() | Releases the underlying state before GC |
Rows come back as plain objects keyed by column name, matching the Node.js SDK's shape. Projection order is preserved (Object.keys(row) matches the SELECT list).
Scope of the MVP
- In-memory only.
Connection::open(path)doesn't have a reasonable browser semantic — the OS file locks and-walsidecar that file-backed mode needs don't exist in a tab's sandbox. We only exposeConnection::open_in_memory(). Persistence via the browser's OPFS (Origin Private File System) is a natural follow-up but out of scope here. - No prepared-statement object. Unlike the Python / Node / Go / Rust SDKs, the WASM build collapses
prepare → step → finalizeinto the one-shotdb.query(sql). The engine still does the work internally; JS just sees a single call. The added objects + lifetimes don't earn their keep in the in-memory MVP. - Parameter binding follows the same "not yet, 5a.2 will add it" story as every other SDK.
Build sizes
A release build (wasm-pack build --target web --release) produces roughly:
| File | Size (before gzip) |
|--------------------------|-------------------|
| sqlrite_wasm_bg.wasm | ~1.8 MB |
| sqlrite_wasm.js (glue) | ~14 KB |
The wasm gzips to ~500 KB; browsers serve it compressed.
How this is wired
- Depends on
sqlritewithdefault-features = false, so theclifeature (rustyline + clap + env_logger — not wasm-safe) andfile-locksfeature (fs2 — no POSIX flock in wasm32) are both off. The engine's file-locking code is#[cfg]-gated behindfile-locksand compiles to a no-op when the feature is absent. console_error_panic_hook(default-on feature) turns Rust panics into readableconsole.errorstack traces in devtools.- Release profile uses
opt-level = "z"+ LTO +codegen-units = 1+ symbol stripping — wasm binary size is the main cost center on the wire. - Rows are marshalled to JS via
serde_wasm_bindgenwithserialize_maps_as_objects(true)(so each row is a plain JSObject, not aMap) andserde_json'spreserve_orderfeature (so column keys come across in projection order, not alphabetical).
Sibling products
The WASM SDK is browser-only. For LLM-agent-driven server-side access to SQLRite, install the sqlrite-mcp Model Context Protocol server (cargo install sqlrite-mcp) and wire it into Claude Code / Cursor / mcp-inspector / any MCP-aware client. Same engine underneath.
Status
Phase 5g MVP: ✅ — in-memory CRUD, transactions, columns(), panic hook, serialization behavior matches the Node.js SDK. OPFS-backed persistence, prepared-statement objects, and parameter binding are natural follow-ups.
