Skip to content

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 29 May 19:47
· 361 commits to main since this release
987fa8b

Added

  • A StorageBackend trait in the new dynoxide::storage_backend module, decoupling the data layer from a specific SQLite binding. The native rusqlite-backed Storage implements the trait, and the action handlers and Database now consume it (see Changed). The trait surface also carries a clock() accessor for the stream and TTL paths and batch-shaped put_base_items / insert_gsi_items methods that replaced the last two raw Storage::conn() escape hatches in the handlers.
  • A BackendError enum returned by the trait surface, with an explicit rusqlite::Error -> BackendError mapping for the common failure modes (NotADatabase, locked / busy, constraint violations, I/O failures), plus an Unsupported { capability } variant for a capability a backend cannot serve (the wasm preview uses it for TTL). It is #[non_exhaustive] so future backends can add failure modes without a breaking change.
  • A Clock capability on Storage so the trait surface does not assume std::time. Stream and TTL paths route their created_at and sweep timestamps through the clock; SystemClock is the default and ManualClock ships as a deterministic test helper. Other std::time call sites (idempotency cache, action-handler timestamps, snapshots) remain native-only and are unchanged.
  • A wasm-sqlite cargo feature and a working WebAssembly backend. dynoxide compiles to wasm32-unknown-unknown and runs in the browser against wa-sqlite (a WASM build of SQLite) over a wasm-bindgen bridge, persisting to OPFS. WasmBridgeBackend implements StorageBackend, and WasmDatabase (Database<WasmBridgeBackend>) exposes the handlers as async fn with no block_on. It covers create-table, put, get, delete, query, and scan over base tables and both index types (GSI and LSI), with index fan-out atomic with the base write. TTL returns BackendError::Unsupported; streams return a preview "not yet implemented" error pending a delivery design; TransactWriteItems, tags, table-setting updates, stats, and bulk import are preview placeholders. The native and wasm backends share one set of SQL builders (storage_backend::sql_builders), so both issue identical SQL.
  • A self-contained browser build: npm run build:wasm (wasm-pack + esbuild) emits a dist/ of three files - a bundled Web Worker plus the two .wasm assets (dynoxide ~550 KB, wa-sqlite ~545 KB; ~1.2 MB total). The engine runs in a Web Worker because wa-sqlite's OPFS persistence uses synchronous access handles, which browsers expose only in a Worker; pairing wa-sqlite's synchronous VFS (AccessHandlePoolVFS) with its non-async build needs no SharedArrayBuffer, and so no cross-origin isolation (COOP/COEP) - it drops onto ordinary static hosting. A build-visible WASM_PREVIEW constant (true under wasm-sqlite) marks the preview. The harness under harness/ loads the same bundled Worker that ships, so a green harness means the shipping artefact works; it exercises CRUD, GSI query/scan, and error-envelope fidelity on OPFS. CI builds the wasm32-unknown-unknown target for both the wasm-sqlite and wasm-harness features on every PR, so the harness's use of WasmDatabase and the action types is type-checked too.
  • Official Docker image. docker run -p 8000:8000 ghcr.io/nubo-db/dynoxide is a ~5 MB drop-in for amazon/dynamodb-local in containerised test suites: multi-arch (linux/amd64 and linux/arm64), FROM scratch, published to GHCR on each release with Docker Hub and ECR Public mirrors pushed best-effort. The image ships a HEALTHCHECK backed by a new dynoxide healthcheck subcommand, so docker ps and Compose health gates report status without extra tooling (#3).
  • SECURITY.md, documenting the MCP HTTP transport's threat model: the bearer-token authentication it now requires, plus the Host and Origin allowlists that back it (#27).
  • MCP HTTP transport options: --mcp-host/--host to bind beyond loopback, --mcp-allowed-host/--allowed-host to accept additional Host headers by name, and --mcp-no-auth/--no-auth to disable authentication on loopback binds only. With a token set, these make the transport reachable from outside a container, unblocking the Docker MCP path (#24).

Changed

  • Database is now generic over its storage backend: Database<S>, monomorphised, no dyn. The parameter defaults to the native rusqlite backend, so existing code that names Database is unaffected, and a new NativeDatabase alias names that default explicitly. The action handlers are now async and route through the StorageBackend trait. NativeDatabase keeps the historical synchronous public API: each method drives the handler future to completion with block_on (via pollster), and because the native backend's futures never suspend, that block_on never parks the thread, so it stays safe inside the tokio-based HTTP and MCP servers.
  • DynoxideError is now #[non_exhaustive]. Match arms in downstream code must include a wildcard. Done now, while 0.10.0 is already a breaking release, so later variant additions stay non-breaking.
  • Breaking: the MCP HTTP transport (dynoxide mcp --http, dynoxide serve --mcp) now requires bearer-token authentication on every request. On a loopback bind, dynoxide generates a token on first run, persists it to a per-user config file, and prints a client-config snippet; later runs reuse it silently. Existing clients break until updated: add "headers": { "Authorization": "Bearer <token>" } to your MCP client config. A non-loopback bind requires an explicit token via --mcp-token/--token or DYNOXIDE_MCP_AUTH_TOKEN and will not start without one. The stdio transport is unaffected (#27).
  • Breaking (library API): dynoxide::mcp::serve_http and serve_http_with_shutdown now take an HttpOptions struct (bind host, AuthMode, extra allowed hosts) in place of a bare port: u16. Embedders constructing the MCP HTTP server must build HttpOptions and choose an AuthMode.
  • rusqlite is now an optional dependency behind the native-sqlite feature (on by default, so native builds are unchanged). The crate type-checks with rusqlite absent, which is the precondition for the wasm build. Cross-platform wall-clock paths (the idempotency cache, created_at stamps, and SystemClock) moved to web-time - std::time on native, the browser clock on wasm. The native binary now builds behind a cli marker feature (pulled in by http-server, mcp-server, and import), so it is skipped in backend-neutral builds such as --features wasm-sqlite. The DynoxideError::SqliteError variant is consequently native-sqlite-gated and absent on backend-neutral builds, which matters only for code that matches it by name on a wasm target.

Fixed

  • PartiQL DELETE and UPDATE now evaluate the non-key predicates in a WHERE clause instead of acting on the key alone. Before, the executor pulled the primary key out of the WHERE and ignored the rest, so DELETE FROM "t" WHERE pk = 'a' AND NOT begins_with(name, 'x') deleted the row even when name began with x, mutating a row the filter should have excluded (a data-correctness bug predating v0.9.5). The write paths now run the full condition against the fetched item, the same matches_where pass SELECT already uses: a present item whose non-key predicate is false raises ConditionalCheckFailedException, matching how AWS treats a PartiQL write whose condition fails, and a missing item stays a silent no-op (#54).
  • DescribeTable now returns a stable TableId instead of a freshly generated UUID on every call. The id is a random UUID assigned once at create time and persisted (a new table_id column, added to existing databases through the versioned schema migration and backfilled), so it stays the same across calls, CreateTable returns the same value, and a dropped-and-recreated table gets a new one, matching AWS (#55).
  • UpdateItem evaluates an UpdateExpression against the pre-update item image and accepts parenthesised arithmetic. SET a = :v, b = a now gives b the old value of a rather than the value assigned earlier in the same call, and SET c = (c - :v) parses and applies on the BigDecimal path instead of being rejected with Expected operand in SET, got ( (#35).
  • UpdateItem ReturnValues: UPDATED_NEW matches AWS granularity. A nested SET parent.child = :v returns only the changed fragment {parent: {M: {child}}} instead of the whole parent map, and a REMOVE-only update omits Attributes entirely rather than returning an empty map (#36).
  • Paginating a Query over a GSI no longer drops items when several entries share the same index key and the base table has only a partition key. On a hash-only base table the continuation cursor lost its base-key component and stalled after the first page, the same defect #38 fixed for Scan; the Query path now carries the base partition key, so every tied item is returned across the paged walk (#52).
  • TransactWriteItems, TransactGetItems and PartiQL ExecuteStatement now report ConsumedCapacity the way AWS does. A transactional write charges 2 WCU per item and a transactional read 2 RCU per item including a missing one (each item rounded up before the 2x factor); the TransactGetItems INDEXES breakdown carries Table.ReadCapacityUnits; and ExecuteStatement returns the ConsumedCapacity block whenever ReturnConsumedCapacity is requested instead of omitting it (#37).
  • PartiQL ExecuteStatement accepts the bracket IN [...] list form, not just IN (...), and evaluates NOT begins_with(...) as a negated predicate. IS NOT MISSING already evaluated; the gaps were the bracket list and the NOT function arm, which the same statement bundles together (#40).
  • DescribeTable now round-trips OnDemandThroughput and reports the full SSEDescription shape. A table created with OnDemandThroughput reports its MaxReadRequestUnits and MaxWriteRequestUnits back; the value lives in a new on_demand_throughput column added through the versioned schema migration, so existing on-disk databases pick it up on open. Server-side encryption enabled with the AWS-managed key now reports SSEType: KMS and a KMSMasterKeyArn alongside Status: ENABLED, where before it returned the status alone (#44).
  • UpdateTable now accepts a lone TableClass or OnDemandThroughput change instead of rejecting it with At least one of ProvisionedThroughput, BillingMode, ... is required. Both fields are validated (an unknown TableClass is a ValidationException) and persisted, so the change shows up on the next DescribeTable (#45).
  • DeleteTable on a table with deletion protection enabled now returns the exact AWS message, Resource cannot be deleted as it is currently protected against deletion. Disable deletion protection first., in place of the ARN-prefixed wording dynoxide used before (#46).
  • TransactGetItems now omits Item from a response entry when a ProjectionExpression matches no attribute on an otherwise-present item, matching AWS. The projection always re-injects the table key, so the entry previously came back as a key-only object instead of being omitted (#39).
  • BatchWriteItem now rejects a PutRequest whose item is missing the table key with a 400 ValidationException rather than a 500 InternalServerError. The duplicate-key detection pass extracted keys before validating them; it now validates first, the same ordering the single-item write paths already use (#39).
  • Paginating a Scan over a GSI no longer drops items when several entries share the same index key and the base table has only a partition key. On a hash-only base table the continuation cursor lost its base-key component and stalled after the first page; it now carries the base partition key, so every tied item is returned across the paged walk (#38).
  • A single-item write (PutItem, DeleteItem, UpdateItem) and its GSI/LSI index fan-out now run in a single transaction. A failure partway through the fan-out rolls the whole write back rather than leaving a base row with a half-applied (torn) index. The same per-item atomicity now also covers BatchWriteItem (each write request) and the TTL sweep (each expired-item delete). This matches DynamoDB, where a single-item write does not half-apply to its indexes.
  • Write paths now roll back on a failed COMMIT and surface a failed ROLLBACK rather than leaving the connection stuck mid-transaction, which would make the next write fail. Every write path shares one transaction helper for this.
  • A client-facing ValidationException raised inside a backend method (the 50-tag limit in set_tags) keeps its 400 status across the StorageBackend boundary instead of collapsing to a 500.
  • Tighter expression and scan validation, to match what real DynamoDB rejects
    (surfaced by the conformance suite). Dynoxide now turns away redundant
    parentheses like ((a = :b)) in condition, filter, and key-condition
    expressions; contains(x, x) with the same operand on both sides; and
    begins_with handed a number instead of a string or binary. These are
    rejected up front, before any items are scanned
    (#31).
  • size() now measures strings in UTF-16 code units rather than bytes, so
    values with emoji or accented characters report the length DynamoDB returns.
  • A negative Segment on a parallel scan is now rejected rather than accepted.

Notes

  • Existing native code that names Database keeps working unchanged: the new generic parameter defaults to the rusqlite backend and the synchronous method signatures are identical. The one deliberate behaviour change is index fan-out atomicity (see Fixed); it is more DynamoDB-correct, and the conformance suite still passes. Tests, conformance, and benchmarks pass against the same observable surface as before.
  • Building dynoxide for wasm32-unknown-unknown is now supported via the wasm-sqlite feature (see Added). The wasm backend is a preview: it is not run against the conformance suite that covers the native build, so its correctness rests on its own CRUD/query/scan/GSI/LSI tests for now. The engine runs in a Web Worker (OPFS's synchronous file handles are Worker-only) and needs no cross-origin isolation, so it works on ordinary static hosting.