Browser (WASM)
Dynoxide compiles to WebAssembly and runs in the browser. The same engine that backs the native build drives the official @sqlite.org/sqlite-wasm SQLite build, persists the database to OPFS (the browser's origin private file system), and runs the whole thing in a Web Worker. No server, no install, and nothing leaves the page.
It ships on npm as @dynoxide/wasm-engine. The package carries the engine .wasm, the SQLite .wasm, the bundled Worker, and an EngineClient that drives them, so you run real DynamoDB operations client-side with a few lines of JavaScript.
The browser build implements a subset of the native one. The core data plane, PartiQL, vector search and both index types are there, but DynamoDB Streams, TTL, resource tags and write transactions aren't wired yet. The engine reports its exact capability set at boot, so gate on that rather than assuming. The What works section below spells out the boundary.
Want to see it without writing any code? accesspatterns.dev runs this same engine in the browser - guided lessons, real access-pattern models, and a freeform playground for learning DynamoDB by operating it.
Install
npm install @dynoxide/wasm-engine
That pulls the current release, 1.1.0. Every Dynoxide artifact carries the same version number, so the package and the engine inside it move together. Use ~1.1.0 to hold behaviour still across minors, or pin the exact version.
Quick start
import { EngineClient } from "@dynoxide/wasm-engine";
const client = new EngineClient();
await client.ready();
await client.execute("CreateTable", {
TableName: "Music",
KeySchema: [
{ AttributeName: "artist", KeyType: "HASH" },
{ AttributeName: "song", KeyType: "RANGE" },
],
AttributeDefinitions: [
{ AttributeName: "artist", AttributeType: "S" },
{ AttributeName: "song", AttributeType: "S" },
],
BillingMode: "PAY_PER_REQUEST",
});
await client.execute("PutItem", {
TableName: "Music",
Item: { artist: { S: "Pixies" }, song: { S: "Debaser" } },
});
const { Items } = await client.execute("Query", {
TableName: "Music",
KeyConditionExpression: "artist = :a",
ExpressionAttributeValues: { ":a": { S: "Pixies" } },
});
execute takes the operation name and a plain DynamoDB-JSON request, resolves with the response, and rejects with a typed EngineError. The client owns the Worker round-trip, so you deal in objects rather than postMessage envelopes. Calls issued before ready() resolves queue behind boot rather than racing it, so you can put work in flight straight after construction.
The client
new EngineClient(opts?)- boots the engine eagerly. Options are all optional:name(the database name and per-instance OPFS pool, defaultdynoxide.db),ephemeral(force an in-memory session), and the asset-resolution options below.await client.ready()- resolves with the boot descriptor:{ contractVersion, capabilities, persistenceMode }.client.execute(op, request)- run one operation. In TypeScript the response defaults tounknown, so pass a type argument when you have one:client.execute<ScanOutput>("Scan", request).client.supports(op)- whether the engine implements an operation, for capability-gating your UI.client.persistent-truewhen the session persists across reloads (OPFS),falsein the in-memory fallback.client.terminate()- tear the Worker down and reject any in-flight calls.
It also exports EngineError (the engine's __type lands on .type, so you branch on it instead of string-matching messages) and CONTRACT_VERSION. TypeScript declarations ship in the package, so the client and its boot descriptor are typed out of the box.
What works
Create, delete, describe, update and list tables; put, get, delete and update items; query and scan; the batch and transactional reads (BatchGetItem, BatchWriteItem, TransactGetItems); all three PartiQL operations (ExecuteStatement, BatchExecuteStatement, ExecuteTransaction); and SearchVectors. Eighteen in all, over base tables, both secondary index types (GSI and LSI), and vector indexes. UpdateTable adds or drops a GSI and changes the simple table settings. Index maintenance is atomic with the base write, vector shadow tables included, same as the native build.
PartiQL reached the browser build in 0.13.0. The native engine has had it since 0.9.0, and the browser build now matches it statement for statement: the RETURNING projections, the per-statement error codes BatchExecuteStatement reports, and ClientRequestToken idempotency on ExecuteTransaction. The PartiQL guide covers the grammar. One corner is unreachable rather than supported: a PartiQL write records a stream event when the table has a stream enabled, and the browser build can't enable one.
Vector search arrived with 1.0.0, on this build and the native one together. It is the same code either side: exact brute-force KNN over COSINE, EUCLIDEAN or DOT_PRODUCT, the same f32 storage, the same scoring and the same tie break, so a query answers identically in the tab and on the server. A database persisted before the vector column existed is migrated when it is opened, so an OPFS database written by an older build keeps working and gains the surface.
The engine reports its exact capability set at boot, so don't guess - gate on it. ready() hands you capabilities, and client.supports("TransactWriteItems") answers for a single op. Anything outside the set rejects with a typed EngineError rather than misbehaving: TTL returns an Unsupported error, streams aren't wired yet, and a handful of operations (TransactWriteItems, tags, table stats, bulk import) return a typed "not yet implemented" error. Branch on EngineError.type to tell a genuine rejection from a not-yet-wired one.
Capabilities answer whether an operation is routed at all, not whether every request to it will succeed. A CreateTable carrying an enabled StreamSpecification or Tags, and an UpdateTable carrying a StreamSpecification, are refused with the same typed error before anything is created or changed, so a refusal never leaves you holding a half-made table.
Where the assets live
The Worker and both .wasm travel inside the package. new EngineClient() with no arguments resolves the Worker next to the client module, and the Worker resolves its .wasm next to itself, so a bundler that copies the package's files - or a plain static deploy of them - needs no configuration.
| File | Size | What |
|---|---|---|
dynoxide_bg.wasm |
~1.4 MB | the engine |
sqlite3.wasm |
~865 KB | the official SQLite wasm build |
dynoxide-worker.js |
~230 KB | the bundled Web Worker |
About 2.5 MB raw, but that isn't the number that reaches a browser. Both .wasm and the Worker JS compress well, so gzip takes it to around 990 KB over the wire and brotli lower again - turn one of them on at the host, as most CDNs do by default. The .wasm files are immutable, so they cache hard after the first load.
Serving from a CDN or a different origin? Two options on the constructor:
assetBase- the directory the assets sit in, e.g.new EngineClient({ assetBase: "https://cdn.example.com/dynoxide/" }).workerUrl- the exact Worker URL, if it doesn't sit beside its.wasmunder a shared base.
If you'd rather let a bundler construct the Worker, the package exposes it at the ./worker subpath. Build it and hand it back through createWorker:
new EngineClient({
createWorker: () =>
new Worker(new URL("@dynoxide/wasm-engine/worker", import.meta.url), { type: "module" }),
});
Two clients on one page are fine. Each gets its own storage pool keyed on name, so give them distinct names if both should persist.
Hosting
Put the assets on any origin that's a secure context - HTTPS in production, or localhost for development. OPFS needs a secure context, but no COOP/COEP headers and no cross-origin isolation. Serve the .wasm as application/wasm, and if you set a Content-Security-Policy it must allow 'wasm-unsafe-eval', or the engine won't instantiate.
That no-cross-origin-isolation part is the interesting bit. SQLite in the browser usually needs cross-origin isolation, because the common trick makes an async storage API look synchronous via SharedArrayBuffer. Dynoxide sidesteps that by running SQLite's synchronous OPFS VFS inside a Web Worker, where synchronous file handles are available directly. So it drops onto ordinary static hosting.
Persistence
State persists across reloads via OPFS. Where OPFS synchronous access handles aren't available - Firefox private windows, older Safari - the client falls back to an in-memory session and reports it through persistent / persistenceMode rather than failing. Check client.persistent if you need to tell the user their data won't survive a reload.
Versioning
CONTRACT_VERSION stamps the message-envelope shape, not the engine version. Adding an operation leaves it alone; changing a request, response or error envelope bumps it. The client validates it against the engine on boot and fails loudly on a mismatch, so a pinned consumer gets a clear error rather than mis-reading a newer engine. The shipped engine and contract versions sit in the package's manifest.json.
Building it yourself
You don't need to - the package above is the engine, prebuilt. If you want to build it from source, or you're after the harness that drives the shipping bundle as a worked example, the WebAssembly section of the README is the canonical reference.