---
title: "Dynoxide 0.11.0: wasm on npm, plus a stack of fixes - Martin Hicks"
description: "Dynoxide 0.11.0 ships the browser engine as an npm package, @dynoxide/wasm-engine, and lands a stack of correctness fixes: secondary-index validation on write, sparse-index behaviour, and error messages brought back in line with real DynamoDB."
canonical: https://martinhicks.dev/articles/dynoxide-0110-wasm-on-npm
last-updated: 2026-07-01
---

![The Dynoxide hexagonal logo centred on a near-black background](https://martinhicks.dev/images/articles/dynoxide-0110-wasm-on-npm.png)25th June 2026

# Dynoxide 0.11.0: wasm on npm, plus a stack of fixes

Dynoxide 0.11.0 packages the browser engine as an npm module and fixes a stack of correctness bugs. [Last release](https://martinhicks.dev/articles/dynoxide-0100-browser-and-docker) got the engine running in the browser, but you had to wire up the Web Worker yourself to use it.

Now there's a [package that does that for you](https://www.npmjs.com/package/@dynoxide/wasm-engine). The fixes are mostly small things the conformance suite turned up - nothing dramatic, but the sort of drift you want closed if you're going to claim the thing behaves like DynamoDB.

## npm install @dynoxide/wasm-engine

In 0.10.0 the browser build was a `dist/` of a worker and two `.wasm` files, and the `postMessage` plumbing between your page and the engine was your problem. The package handles that:

```bash
npm install @dynoxide/wasm-engine
```

It bundles the engine `.wasm`, the SQLite build, the Web Worker, and an `EngineClient` that drives them. You import the client and run operations against a store that persists to the browser's [origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system):

```js
import { EngineClient } from "@dynoxide/wasm-engine";

const client = new EngineClient();
await client.ready();

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 an operation name and a DynamoDB-JSON request, resolves with the response, and rejects with a typed `EngineError`. It runs in a Web Worker because OPFS's synchronous file handles are Worker-only, and the client handles the round trip, so you work in objects rather than message envelopes. It needs no server and no cross-origin isolation headers, so it'll sit on plain static hosting. Types are included.

The client also checks a contract version against the engine when it boots, and fails loudly if they don't match, so a pinned consumer gets a clear error rather than quietly mis-reading a newer engine.

The SQLite underneath is now the official [`@sqlite.org/sqlite-wasm`](https://github.com/sqlite/sqlite-wasm) build. The `open`/`exec`/`query`/`close` contract didn't change, so nothing using the package needs touching.

It's a preview, and the version says so (`0.11.0-preview`). The reason for the label is that the browser build doesn't run against the conformance suite the native build does, so I trust it less than the native one, and you should too.

## The fixes

Most of these came out of the [conformance suite](https://martinhicks.dev/articles/dynoxide-conformance-suite), which runs dynoxide against real DynamoDB and flags where they differ. Two were actual bugs; the rest is error-message fidelity.

The first bug: a write whose secondary-index key was the wrong type, a non-scalar, or an empty string was accepted when it shouldn't have been. The base row went in, but the bad value was kept out of the index, so you'd be left with a row the table had and its index didn't, and nothing told you. It's now rejected on every write path - put, update, batch, transactional, PartiQL, import - with the message DynamoDB uses.

The second is related. A `Scan` or `Query` on a composite GSI was returning items that were missing the index sort key. Index membership was keyed on the partition key alone, so an item with the partition key but no sort key got written into the index at an empty sort-key position. DynamoDB leaves those out; that's how sparse indexes are meant to work. It's now one membership rule shared by global and local indexes, applied on every write.

The rest you'd only notice if you assert on the exact error string. Empty-string and empty-binary key values now come back as a top-level `ValidationException` everywhere, including inside `TransactWriteItems`, where a few paths were burying them in a cancellation reason instead. I checked the wording against four regions to be sure it matched, since [AWS had just reworded a chunk of these](https://martinhicks.dev/articles/dynamodb-changed-its-validation-errors). `BatchWriteItem` returns DynamoDB's generic `The provided key element does not match the schema` for a wrong-type key now, rather than `PutItem`'s more specific message, and `Query` and `Scan` reject a couple of `Select`/`ProjectionExpression` combinations that DynamoDB rejects before it reads anything.

One change went the other way and accepted something it used to reject: `{ NULL: false }`, now read back as `{ NULL: true }`. That's the change I [saw AWS make](https://martinhicks.dev/articles/dynamodb-changed-its-validation-errors) a couple of weeks ago, when it dropped the old true-only rule. The field was a plain boolean in the model anyway, so `false` was always valid input.

## UpdateTable in the browser

The browser engine also got `UpdateTable`: add or drop a global secondary index (a new one backfills the existing rows), or change the simple settings like billing mode, table class, throughput and deletion protection. One difference from native: a new GSI comes back `ACTIVE` straight away instead of going through `CREATING`, because there's no background index build happening in a single tab. A stream-spec change is still rejected - streams aren't done in the browser yet.

## What's next

This one took longer than I'd hoped - a month between releases, most of it found in odd hours, and some weeks I couldn't find any.

The next job is to run the wasm build against the conformance suite, so the browser engine is held to the same standard as the native one and I can drop the preview label. Streams in the browser come after that, once I've worked out how delivery should work with no server to push from.

The package is [on npm](https://www.npmjs.com/package/@dynoxide/wasm-engine) and the source's [on GitHub](https://github.com/nubo-db/dynoxide). If you try it and something doesn't match DynamoDB, that's worth a bug report - it's the sort of thing the suite is there to catch.

## Further reading

-    [Dynoxide 0.10.0: it runs in the browser now
    
    Dynoxide 0.10.0 compiles to WebAssembly and runs in the browser on OPFS, ships an official ~5 MB Docker image, and adds a storage-backend trait. Plus what breaks.](https://martinhicks.dev/articles/dynoxide-0100-browser-and-docker)
-    [I watched DynamoDB change under my conformance suite
    
    My DynamoDB conformance suite went red and the test that failed was real AWS. It had reworded its validation errors, and the change hasn't reached every region yet.](https://martinhicks.dev/articles/dynamodb-changed-its-validation-errors)
-    [Building a DynamoDB conformance suite
    
    How I built a 526-test DynamoDB conformance suite, and what it found in DynamoDB Local, LocalStack, Dynalite, and Dynoxide.](https://martinhicks.dev/articles/dynoxide-conformance-suite)

---

Source: https://martinhicks.dev/articles/dynoxide-0110-wasm-on-npm
