dynoxide-rs 3.0.0

An embeddable DynamoDB emulator backed by SQLite. Starts in under a millisecond, and is verified against real AWS by a public conformance suite.
Documentation
# Installation

## npm

```sh
npm install --save-dev dynoxide
```

Or run directly without installing:

```sh
npx dynoxide --port 8000
```

## Homebrew (macOS and Linux)

```sh
brew install nubo-db/tap/dynoxide
```

Homebrew 6.0.0 added [tap trust](https://docs.brew.sh/Tap-Trust). The fully-qualified command above still installs fine (Homebrew trusts a named formula at install time), but the tap stays untrusted after that, so `brew upgrade` will skip Dynoxide and `brew doctor` will flag the tap. Trust it once:

```sh
# Trust just Dynoxide - lets brew upgrade see it again
brew trust --formula nubo-db/tap/dynoxide

# Or trust the whole tap - also clears the brew doctor warning
brew trust nubo-db/tap
```

## Pre-built binaries

Download from [GitHub Releases](https://github.com/nubo-db/dynoxide/releases) for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows.

```sh
# Example: Linux x86_64
curl -fsSL https://github.com/nubo-db/dynoxide/releases/latest/download/dynoxide-x86_64-unknown-linux-musl.tar.gz | tar xz
sudo mv dynoxide /usr/local/bin/
```

## Cargo

```sh
cargo install dynoxide-rs

# With encryption support (SQLCipher + vendored OpenSSL)
cargo install dynoxide-rs --no-default-features --features encrypted-full
```

## As a library (Rust)

```toml
[dependencies]
# Minimal - just the embedded database, no server or CLI dependencies
dynoxide-rs = { version = "3.0", default-features = false, features = ["native-sqlite"] }

# Or with encryption:
# dynoxide-rs = { version = "3.0", default-features = false, features = ["encryption"] }
```

## Upgrading to 1.3.0

**Database files are upgraded when 1.3.0 first opens them**, to schema version
11, and older versions can't undo that. It only changes what an older Dynoxide
reads in two cases: a global secondary index with a multi-attribute key, whose
entries are rebuilt, and a table with a vector index, whose stored item sizes
are recomputed. If a file has either and you might go back to 1.2.x, copy it
first: 1.2.x doesn't check a file's schema version, so it would open the file
and read those indexes or sizes wrongly. From 1.3.0 on, a file written by a
newer Dynoxide is refused rather than opened.

**Behaviour moved towards DynamoDB's in a lot of places**: validation messages
and the order checks run in, PartiQL limits and NextToken rules, legacy filter
handling, and what transactions report when they're cancelled. Requests
DynamoDB refuses that 1.2.1 accepted are now refused too, so a test that relied
on the old leniency may need updating. The changelog's Behaviour changes and
Fixed sections list each one.

**Depending on the `dynoxide-rs` crate?** It's now 3.0.0. The `expressions` and
`partiql` modules are no longer public. `ParameterizedStatement`,
`ExecuteStatementRequest` and `BatchStatementError` gained fields, so struct
literals of them need the new field or `..Default::default()`. The minimum
supported Rust version stays 1.88. The changelog's Breaking (Rust API) section
lists each change.

## Upgrading to 1.0.0

The largest behaviour change the project has shipped. Unlike the 0.12.x and
0.9.x notes below, this one affects anyone running the binary, not just library
consumers. [CHANGELOG.md](../CHANGELOG.md) groups the full list under Behaviour
changes; the shapes to expect are:

**Running the binary or the container?** Requests that used to be accepted are
now rejected, matching DynamoDB: item-size ceilings on `UpdateItem` and the
PartiQL write surfaces, `ReturnConsumedCapacity` validation, and several
index-qualified PartiQL `SELECT` forms. Consumed capacity figures move, so a
test asserting exact values needs updating. A PartiQL `UPDATE` or `DELETE` can
now match rows it previously skipped, because its `WHERE` clause runs through
the same matcher as `SELECT`.

**Depending on the `dynoxide-rs` crate?** The MSRV is now 1.88. `WASM_PREVIEW`
is renamed `WASM_BACKEND`. `storage_backend::StorageBackend` is sealed, so it
can still be named and used as a bound but no longer implemented outside the
crate. Several public types gained fields and became `#[non_exhaustive]`, and a
few signatures changed; the changelog's Breaking (Rust API) section lists each
one.

**Pinning.** Behaviour moves inside 1.x, because conformance fixes ship as
minors. [versioning.md](versioning.md) explains the trade and how to pin
narrower if you would rather it held still.

## Upgrading to 0.12.x

Source-breaking for library consumers only. The DynamoDB wire API and the
CLI, server and MCP surfaces are unchanged, so anyone running the binary can
upgrade without reading further.

**Depending on the `dynoxide-rs` crate?** Two public types gained fields and
became `#[non_exhaustive]`:

- **`partiql::parser::Statement`** - the `Update` and `Delete` variants carry a
  `returning` field. Code that constructs or exhaustively matches them needs `..`.
- **`actions::batch_execute_statement::BatchStatementResponse`** - gained a
  `table_name` field, same treatment.

## Upgrading from 0.9.x

0.10.0 is a breaking release, but most of the breaks are library-only. The [CHANGELOG](../CHANGELOG.md) has the full list.

**Running the binary** (Homebrew, npm, the release archives, or the Docker image)? One change affects you:

- **MCP over HTTP now requires a bearer token.** Existing HTTP-transport clients break until they send an `Authorization: Bearer <token>` header. A loopback bind generates and persists a token on first run; a non-loopback bind will not start without one (`--mcp-token` or `DYNOXIDE_MCP_AUTH_TOKEN`). The stdio transport is unaffected, and plain `dynoxide serve` (DynamoDB only, no MCP) is unchanged. See [MCP Server]#mcp-server.

**Depending on the `dynoxide-rs` crate?** Also note:

- **`DynoxideError` is now `#[non_exhaustive]`.** Code that matches it exhaustively needs a `_ =>` arm.
- **`Database` is now generic, `Database<S>`.** The parameter defaults to the native backend, so code that names `Database` keeps compiling; a new `NativeDatabase` alias names that default explicitly.
- **Embedding the MCP HTTP server:** `dynoxide::mcp::serve_http` and `serve_http_with_shutdown` take an `HttpOptions` struct (bind host, auth mode, allowed hosts) in place of a bare port.

## GitHub Actions

```yaml
- uses: nubo-db/dynoxide/action@v1.3.0
  with:
    snapshot-url: https://example.com/test-data.db.zst  # optional
    port: 8000
```

See [action/action.yml](../action/action.yml) for all inputs and outputs.

## Docker

A 5 MB drop-in for `amazon/dynamodb-local` in containerised test suites. Same DynamoDB-compatible API, faster startup, smaller image. Note that this is a packaging convenience for test fixtures, not a containerised database product; production-database-on-Kubernetes patterns are out of scope.

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

With persistent storage:

```sh
docker run --rm -p 8000:8000 \
  -v "$(pwd)/data:/data" \
  ghcr.io/nubo-db/dynoxide \
  serve --host 0.0.0.0 --port 8000 --db-path /data/dynoxide.sqlite
```

Image tags follow the version: `dynoxide:1.3.0` is exact, `dynoxide:1.3` floats
across patches, and `dynoxide:1` floats across the whole 1.x line. `:1` receives
conformance fixes, which change behaviour within a major; `:1.2` does not. See
`docs/versioning.md`.

The image runs as root by default, matching `amazon/dynamodb-local`, so bind mounts on Linux Just Work without `--user`. The canonical image lives at `ghcr.io/nubo-db/dynoxide`. Mirrors are pushed to `docker.io/nubodb/dynoxide` and `public.ecr.aws/nubo-db/dynoxide` on a best-effort basis. SLSA provenance and SBOM attestations are published to GHCR only; if you want to verify provenance, pull from the GHCR canonical.

If you override `CMD` to bind to a different port, set the healthcheck target with environment variables so the container's `HEALTHCHECK` follows:

```sh
docker run -e DYNOXIDE_HEALTHCHECK_PORT=9000 ghcr.io/nubo-db/dynoxide serve --port 9000
```

`DYNOXIDE_HEALTHCHECK_HOST` and `DYNOXIDE_HEALTHCHECK_PORT` are documented public surface and will not be renamed in a patch or minor release.

The container probes itself every 250ms until it first reports healthy, then
every 30 seconds. Dynoxide boots in milliseconds, so that first probe is
normally the only quick one and waiting on the healthcheck rather than polling
the port resolves in well under a second. The ten second start period is
headroom for a loaded host rather than a window a healthy container uses.
Needs Docker Engine 25.0 or newer. See [Testcontainers](testcontainers.md) for
how to wait on it from a test suite.

### Running as nonroot

For security-conscious operators, opt into a nonroot uid:

```sh
docker run --rm -p 8000:8000 --user 65532:65532 ghcr.io/nubo-db/dynoxide
```

Persistent mode under nonroot needs a host-owned bind mount, since the in-image `/data` is owned by root:

```sh
docker run --rm -p 8000:8000 \
  --user "$(id -u):$(id -g)" \
  -v "$(pwd)/data:/data" \
  ghcr.io/nubo-db/dynoxide \
  serve --host 0.0.0.0 --port 8000 --db-path /data/dynoxide.sqlite
```

The default in-memory mode needs no flags whether root or nonroot. The uid 65532 is the well-known nonroot uid used by Google's distroless images; pick any uid you prefer with `--user <uid>:<gid>`.

### MCP over HTTP in Docker

The default image serves DynamoDB only. To also expose the [MCP](#mcp-server) Streamable HTTP transport, override the command to start it on `0.0.0.0` and supply a bearer token. The token is **mandatory** for any non-loopback bind. Pass it via the `DYNOXIDE_MCP_AUTH_TOKEN` environment variable (which keeps it out of shell history and `ps`), not a `--mcp-token` flag:

```sh
TOKEN=$(openssl rand -base64 24)

docker run --rm -p 8000:8000 -p 19280:19280 \
  -e DYNOXIDE_MCP_AUTH_TOKEN="$TOKEN" \
  ghcr.io/nubo-db/dynoxide \
  serve --host 0.0.0.0 --port 8000 \
        --mcp --mcp-host 0.0.0.0 --mcp-port 19280
```

DynamoDB is then reachable on `http://localhost:8000` and MCP on `http://localhost:19280/mcp`. Point an HTTP-transport MCP client at the latter with an `Authorization: Bearer <token>` header. See [MCP Server](#mcp-server) for the client config shape.

A few things to know:

- **The token is not optional.** Omit it and the container exits immediately with `a non-loopback MCP bind requires an explicit token`. The default `docker run ghcr.io/nubo-db/dynoxide` stays DynamoDB-only precisely because a token-less `0.0.0.0` MCP bind cannot boot.
- **Reaching MCP from another container** by service name (rather than `localhost`) needs that name added to the Host allowlist: `--mcp-allowed-host <name>` (e.g. `--mcp-allowed-host dynoxide`). The `-p`-mapped `localhost` access above needs nothing extra.
- **`--network host`** (Linux only) is an alternative to `-p`, but it bypasses Docker network isolation and binds MCP directly on the host's network interface, reachable from the LAN, not just the host. Prefer `-p` unless you specifically need host networking.