Illustration of the Dynoxide logo with DynamoDB table icons on a dark background

Dynoxide 0.10.0: it runs in the browser now

A DynamoDB-compatible database, running in a browser tab. No server, no network. The data lives in the origin private file system, and the query engine is the same Rust that backs the native build, compiled to WebAssembly.

That's the thing I'm most pleased about in dynoxide 0.10.0. It's a big release, and the first one that isn't a clean upgrade. So here's what shipped, why, and what bites you on the way up.

A database in the browser

Dynoxide started as an engine I wanted to embed in a native app. "Embed it anywhere" was always the pitch. 0.10.0 is the release where the browser counts as anywhere.

The native build backs onto SQLite through rusqlite. To run in a browser you need a SQLite that runs in WebAssembly, and that's wa-sqlite. Dynoxide talks to it over a wasm-bindgen bridge and persists to OPFS. Build it with --features wasm-sqlite and you get WasmDatabase, the same handlers exposed as async fn with no block_on in sight.

What works today: create table, put, get, delete, query, and scan, over base tables and both secondary index types (GSI and LSI). Index maintenance is atomic with the base write, same as native. It's enough to back a real client-side app.

What doesn't, yet: TTL returns a typed Unsupported error (I haven't implemented it yet). Streams are planned but not wired - the delivery design is the open question. And TransactWriteItems, tags, table-setting updates, stats, and bulk import all return a preview "not yet implemented" error. It's a preview, and I'd rather say so plainly than have you find out mid-build.

The detail I like most: no cross-origin isolation. SQLite in the browser usually needs COOP/COEP headers, because the common trick makes an async storage API look synchronous through a SharedArrayBuffer. Dynoxide sidesteps that by running wa-sqlite's synchronous OPFS VFS inside a Web Worker, where synchronous file handles are available directly.

So it drops onto ordinary static hosting. No special headers, no cross-origin isolation, no faff.

npm run build:wasm gives you a self-contained dist/ of three files, about 1.2 MB total. The harness under harness/ loads the exact same bundled worker a consumer would, so a green harness means the shipping artefact works, not a parallel build that happens to pass.

The trait that made it possible

You can't point one engine at both rusqlite and wa-sqlite if the data layer is welded to rusqlite. So the real work in 0.10.0 is underneath. There's a StorageBackend trait now, and Database is generic over it - Database<S>. The native rusqlite backend implements the trait, the browser one is WasmBridgeBackend, and both go through the same set of SQL builders.

A query fixed on one is fixed on both. That shared SQL is what lets me trust the browser build without a separate conformance run.

The handlers went async, because the browser backend is async by nature. Native keeps its old synchronous API. NativeDatabase drives each handler future to completion with block_on (via pollster), and because the native futures never actually suspend, that block_on never parks a thread.

It stays safe inside the tokio-based HTTP and MCP servers, and existing native code that names Database keeps compiling untouched.

The Docker image

Last time I said a Docker image was coming next. Here it is.

docker run -p 8000:8000 ghcr.io/nubo-db/dynoxide

FROM scratch, around 5 MB, multi-arch (amd64 and arm64). No shell, no OS, just the static binary. Against DynamoDB Local's ~225 MB Java image, it's a clean drop-in for the containerised test suites people actually run, and the one someone asked for.

It ships a HEALTHCHECK backed by a new dynoxide healthcheck subcommand, so docker ps and Compose health gates report status without you bolting on curl or wget (there's no shell in the image to run them with anyway). GHCR is the canonical home; Docker Hub and ECR Public get best-effort mirrors on each release.

What breaks

0.10.0 is the first breaking release, so this part matters.

If you run the binary, there's one change to know about: MCP over HTTP now requires a bearer token on every request. A loopback bind generates and persists a token on first run and prints a client-config snippet; a non-loopback bind won't start without one. Existing HTTP-transport MCP clients break until they send an Authorization: Bearer <token> header. The stdio transport is unchanged, and plain dynoxide serve (DynamoDB only, no MCP) is unchanged.

The reason is the obvious one. An MCP server reachable off-loopback with no auth is an open door to whatever it can touch. There's a SECURITY.md now that lays out the threat model, the token, and the Host and Origin allowlists behind it.

If you depend on the Rust crate, there are a few more. DynoxideError is now #[non_exhaustive], so an exhaustive match needs a _ => arm. Database being generic is source-compatible if you just name Database (it defaults to the native backend, and there's a NativeDatabase alias if you want to be explicit). And mcp::serve_http takes an HttpOptions struct now instead of a bare port.

The less glamorous half

Under the two headline features sits a stack of correctness fixes, most of them surfaced by the conformance suite I run alongside dynoxide. A few worth naming:

  • PartiQL DELETE and UPDATE now evaluate the whole WHERE clause, not just the key. The old behaviour could delete a row a filter should have excluded - a genuine data-correctness bug.
  • DescribeTable returns a stable TableId instead of minting a fresh UUID on every call.
  • Query and Scan over a GSI stop dropping items when several entries tie on the index key and the base table has only a partition key.
  • ConsumedCapacity now matches AWS on the transactional and PartiQL paths.

Where dynoxide actually stands against real DynamoDB and the other emulators lives at dynamodb-conformance.org. It moves as the suite grows and each engine changes, so I'm not going to pin a number here that's wrong by next week. Go and look at the live one.

What's next

Streams in the browser, once I've settled how delivery should work without a server to push from. And an npm package for the wasm build, so you can pull it in without wiring up the worker yourself - that path already works, it just isn't packaged.

The browser build is the proof I wanted. The engine isn't tied to one host any more. Native binary, Docker image, or a browser tab: same engine, same SQL, same behaviour.