v0.13.0
Added
- PartiQL now works in the browser. The wasm engine serves
ExecuteStatement,BatchExecuteStatementandExecuteTransaction, where it previously answered all three with a 501. Behaviour matches the native build statement for statement, including theRETURNINGprojections, the per-statement error codesBatchExecuteStatementreports, andClientRequestTokenidempotency onExecuteTransaction. The engine's advertised capability list grows from 14 operations to 17;CONTRACT_VERSIONis unchanged at 1, since adding an operation is additive, so a pinned client keeps working. The engine bundle grows by about 143 KB raw and 53 KB gzipped, which is the parser and executor now being reachable from the wasm entry points.
Changed
- Breaking (Rust API):
wasm_api::dispatchandwasm_api::dispatch_httptake aDispatchContext, which borrows the idempotency caches aClientRequestTokenneeds. Both are behind thewasm-sqlitefeature, so a native library consumer is unaffected, and the#[wasm_bindgen]surface the npm package calls (open,execute,dispatchHttp,capabilities,contract_version) is unchanged. Callers ofdispatchbuild a context from the new publicTokenCaches. This is separate fromCONTRACT_VERSION, which does not move. - The wasm build's documentation no longer describes it as unverified. The preview label stays, and now rests on what remains unimplemented -
TransactWriteItems, streams, tags and TTL - rather than on an absence of test data.
Fixed
- PartiQL
SELECTonExecuteStatementnow paginates:NextTokenis honoured and returned, andLimitbounds the rows evaluated rather than the rows matched, matching DynamoDB and the existing Query and Scan semantics. A filteredSELECTwith aLimitcan therefore return fewer rows than before for the identical request, and a page can come back short or empty while still carrying aNextToken, so callers should follow the token rather than treat a short page as the end of the result. ASELECTbound to a partition key now reads in ascending sort-key order, and its sort-key conditions are pushed into the read, soLimitpaces against rows in the key-condition range rather than the whole partition; a condition a key condition cannot express falls back to the partition-wide read and filters as before. ANextTokenis now rejected when replayed against a different statement or differentParameters, not just a different table, where it previously resumed the walk and silently skipped rows; the mismatch carries DynamoDB'sNextToken does not match requestmessage (captured eu-west-2) while an undecodable token keepsInvalid NextToken.Limit: 0is rejected with the message DynamoDB's ExecuteStatement returns (the Scan-shaped wording, also captured); it previously read nothing and returned no token, making a paginated walk look complete. - PartiQL
SELECT COUNT(*)is removed. It was a dynoxide-only extension: real DynamoDB rejects the projection outright, so a statement that worked locally broke in production. ACOUNT(...)projection now returns DynamoDB's exactValidationException, the bareUnexpected path componentmessage with the 1-based position of theCOUNTtoken (captured eu-west-2), fired before the table-existence check, onExecuteStatement,BatchExecuteStatementandExecuteTransactionalike. Note this removes a previously shipped dynoxide feature rather than fixing it in place; there is no replacement, matching DynamoDB, where counting means paging the rows yourself or using Query/Scan withSelect: COUNT. - The wasm engine refuses a
CreateTablecarrying an enabledStreamSpecificationorTags, and anUpdateTablecarrying aStreamSpecification, before creating or changing anything, with the same typedUnsupportedOperationenvelope and 501 status the unimplemented operations use. Previously the table was created first and the stream or tag step then failed with a 500, so an AWS SDK retried the error and surfacedResourceInUseExceptionfor the half-created table; the conformance suite's streams probe scored that as a failure where it now correctly records a skip. Every backend capability refusal (BackendError::Unsupported) now maps to this envelope rather than to a 500InternalServerError, so a client - and an SDK retry policy - can tell a scope gap from a server fault.
v0.12.0
Changed
- Breaking (Rust API): the public
partiql::parser::Statementenum gained areturningfield on itsUpdateandDeletevariants, and both variants are now#[non_exhaustive]; the publicactions::batch_execute_statement::BatchStatementResponsestruct gained atable_namefield and is now#[non_exhaustive]too. Library consumers that construct or exhaustively match these types must add... This is a source-breaking change for the crate's public API, so the next release is a minor bump (0.12.0). The DynamoDB wire API and the CLI/server/MCP surfaces are unaffected.
Added
- PartiQL now honours the
RETURNINGclause onExecuteStatement, where dynoxide previously parsed the statement but silently dropped the clause.DELETE ... RETURNING ALL OLD *returns the deleted item inItems(a present but emptyItemsarray on a missing target, matching DynamoDB rather than the classicDeleteItempath), andUPDATE ... RETURNING <ALL|MODIFIED> <OLD|NEW> *returns the matching projection of the item; theMODIFIEDvariants return only the changed paths (a nestedSET a.breturns just the changed leaf, not the wholeaattribute), exclude the primary key, and return an emptyItemsarray when nothing was projected.BatchExecuteStatementhonours a member'sRETURNINGclause;ExecuteTransactionrejects one with a top-levelValidationException. TheRETURNINGvariants DynamoDB does not allow onDELETE(MODIFIED OLD *,ALL NEW *,MODIFIED NEW *) are rejected with its exact validation message instead of being ignored (#137). - A test-only HTTP server for the wasm engine,
npm run wasm:serve. It exists so the conformance suite can reach the browser build over a socket. It is not a way to run dynoxide and is deliberately not distributed: not in the release binary, not on npm. To run dynoxide, use the native build. It drives the shippingdist/bundle in a headless Chromium it installs itself and serves DynamoDB JSON-1.0 on port 8003, with one browser page's worth of concurrency and no TLS. Each start is a fresh in-memory database. The engine gained adispatchHttpworker op so the wire envelope is decided there instead of in the transport, and an operation the preview does not implement returns HTTP 501. See docs/wasm.md. dynoxide serveanddynoxide(no-subcommand) now accept a--schemaflag, taking the same DynamoDB DescribeTable JSON format asimport --schema. On startup, dynoxide creates each table defined in the file and skips any that already exist. This lets you pre-populate an empty database (in-memory or persistent) with the correct table structure without running an import first.
Fixed
OnDemandThroughputnow follows real DynamoDB's semantics on every surface, captured against eu-west-2: CreateTable and UpdateTable both reject it when the effective billing mode is PROVISIONED (each operation with its own captured wording, naming the first present member, read checked first), members must be at least 1 (-1is valid only on UpdateTable, where it removes that ceiling), a partial UpdateTable object merges member-wise over the stored ceilings instead of replacing them, the UpdateTable response echoes the merge with-1kept verbatim while DescribeTable reports the post-removal state, and switching billing mode to PROVISIONED clears the stored ceilings. The billing gate fires before the range check on both operations, and anOnDemandThroughputobject with no members is treated as absent: real DynamoDB accepts one at creation and returnsInternalFailurefor one on UpdateTable, which dynoxide deliberately replaces with its deterministic no-change validation error rather than emulating a 500. dynoxide previously stored whatever it was given, on any billing mode, and replaced wholesale on update (#159).- The MCP
describe_tabledefault view now includesbilling_mode,table_classand the capacity settings for the mode the table is in (provisioned_throughputoron_demand_throughput), where it previously showed none of the table configuration fields and only theraw: trueview carried them. - The MCP
create_tableandupdate_tabletools now accepton_demand_throughput, so the on-demand ceilings that HTTP and wasm could already set and round-trip are reachable over MCP too (#157). - The MCP
update_tabletool now acceptsbilling_mode,provisioned_throughputandtable_class, so a table can be switched between PROVISIONED and PAY_PER_REQUEST or moved to STANDARD_INFREQUENT_ACCESS over MCP, as it already could over HTTP and wasm. The handler previously hardcoded all three to none, so the engine saw an empty update (#156). - The MCP
create_tabletool now acceptsbilling_modeandprovisioned_throughput; it previously had neither, so every table created over MCP was PROVISIONED with default throughput. An invalid billing mode is now rejected with DynamoDB's enum validation message on every surface, not just over HTTP (#154). import --schemaandserve --schemano longer dropBillingModeandTableClassfrom a DescribeTable response. DescribeTable wraps both in summary objects (BillingModeSummary,TableClassSummary) that the rebuiltCreateTableRequestnever read, so an on-demand table came backPROVISIONEDandSTANDARD_INFREQUENT_ACCESScame backSTANDARD. The schema path now unwraps both summaries and, when the billing mode came from the summary, drops the zeroedProvisionedThroughputblocks DescribeTable reports for an on-demand table and its GSIs, which CreateTable would otherwise reject as zero capacity units. A table's own DescribeTable output round-trips without degrading or failing, a provisioned table's capacity values survive intact, and a schema already in CreateTable shape passes through untouched, so an inconsistent one still fails validation exactly as it would on the CreateTable API (#140).- PutItem and UpdateItem validation errors now carry DynamoDB's
1 validation error detected:envelope on exactly the request-validation families real DynamoDB envelopes: empty and duplicate sets,{"NULL": false}in any position (item body, key or expression attribute values), expression syntax and oversize errors, redundant parentheses, the distinct-operand rule forcontains, expression parameter misuse (ExpressionAttributeValueswithout an expression, mixingExpectedwithConditionExpression), and invalidReturnValues. Data-plane, structural and limit families stay bare, matching DynamoDB: key and index-key type mismatches, cannot-update-key, invalid document paths, references to missing attributes, empty-string key values, empty or multi-typedAttributeValueobjects, and oversized items. dynoxide previously enveloped only its constraint-collection path and left the rest bare. The split is classified per family at the raising site, never by matching message text, so an attribute value that echoes a bare-family phrase cannot shed its envelope, and the message is identical on every surface: HTTP, wasm, MCP and the in-process Rust API, whose errors gain the prefix for these families. Deserialisation failures on the wasm and MCP surfaces now classify the same way the HTTP server does, instead of leaking an internal marker inside a mis-typedSerializationException. Read operations are unchanged and their expression errors stay bare. Confirmed against real DynamoDB in eu-west-2. - PartiQL
UPDATEnow performs real list-index writes.SET tags[0] = :vupdates the list element (appending when the index is at or beyond the end) andREMOVE tags[0]deletes it and shifts the rest, where dynoxide previously treatedtags[0]as a literal map key so both the stored item and aRETURNING MODIFIEDprojection over it diverged from DynamoDB. ARETURNING MODIFIEDprojection over list-index paths now packs the changed elements into a dense list in ascending index order (SET a[0], a[2]yields{a: [v0, v2]}), matching DynamoDB. BatchExecuteStatementnow echoesTableNameon each successful member response, and a member that fails to parse now carries the short-formValidationErrorcode (matching a per-statement execution error) instead of the long-formValidationException. Both match DynamoDB.- PartiQL
UPDATEon a non-existent key now fails withConditionalCheckFailedException(The conditional request failed) and creates nothing, where dynoxide upserted the item.UPDATEis not an upsert: the target must already exist, matching DynamoDB. - PartiQL parse errors now use DynamoDB's message wording:
Statement wasn't well formed, can't be processed: <detail>(previously... got error: ...), and a statement that does not begin with a DML keyword reportsExpected data manipulation. Applies toExecuteStatement,BatchExecuteStatement, andExecuteTransaction. - A
{ "NULL": false }attribute value is now rejected with theValidationExceptionreal DynamoDB returns (One or more parameter values were invalid: Null attribute value types must have the value of true), where dynoxide normalised it to{ "NULL": true }and accepted it. TheNULLmember must be exactlytrue; this specific input flipped behaviour across AWS regions and has since settled on rejection everywhere. The fix covers the item body and theDeleteItemraw expression-value path, where the rejection had surfaced as a mis-typedSerializationExceptionleaking an internal prefix rather than the plainValidationException. Confirmed against real DynamoDB in eu-west-2 (#145). UpdateTableadding a global secondary index now validates ...
v0.11.4
Fixed
- Passing a top-level argument together with a subcommand is now a hard parse error, where the argument was silently ignored. The top-level
--host,--port,--db-pathand--encryption-key-fileexist for the bare pre-subcommand form (dynoxide --port 8000) and only fed the no-subcommand path, sodynoxide --db-path data.db servestarted an in-memory server, never created the file, and the data was gone on exit with nothing said about it;dynoxide --port 8893 servelistened on 8000. Combining one withserve,mcp,importorhealthchecknow fails up front with clap's conflict error naming the offending option, and the same option after the subcommand keeps working as before (#141).
v0.11.3
Security
- On Windows, the HTTP and MCP listeners now bind with
SO_EXCLUSIVEADDRUSE, closing a hole where another process running as the same user could take over either port withSO_REUSEADDRwhile dynoxide was serving. Restarting immediately after a clean shutdown still works; a regression test covers the rebind, and CI now runs the unit tests on Windows (#23).
v0.11.2
Fixed
- A
CreateTablerequest whoseStreamSpecificationsetsStreamEnabled: falsebut also supplies aStreamViewTypeis now rejected with theValidationExceptionreal DynamoDB returns (One or more parameter values were invalid: Table is being created with a stream disabled, UpdateViewType should not be specified), where dynoxide accepted it. A view type only has meaning when the stream is enabled, so the two cannot be combined at table creation (#115). - A
CreateTableglobal or local secondary index usingProjectionType: INCLUDEwithout aNonKeyAttributeslist is now rejected with theValidationExceptionreal DynamoDB returns (One or more parameter values were invalid: ProjectionType is INCLUDE, but NonKeyAttributes is not specified), where dynoxide accepted it and created the table.INCLUDEprojects the index key attributes plus an explicit list, so the list is mandatory; the shared projection validator now requires it, closing the gap for both index types (#116). QueryandScannow return DynamoDB's exact message whenSelect: SPECIFIC_ATTRIBUTESis given with noProjectionExpressionorAttributesToGet, where dynoxide rejected the request correctly but with its own wording. Both carried the same non-AWS string; the corrected phrase isMust specify the AttributesToGet or ProjectionExpression when choosing to get SPECIFIC_ATTRIBUTES, whichQuerywraps in the1 validation error detected:envelope andScanreturns bare, matching real DynamoDB (#121).TransactWriteItemsnow reports top-levelReadCapacityUnitsandWriteCapacityUnitsin itsConsumedCapacity, where only the nestedTablebreakdown carried them. A transactional write reports write capacity (a standaloneConditionCheckcosts 2 write units on its own table line underINDEXES), and a same-token idempotent replay now reports a recomputed transactional read cost, rounded at 4KB read granularity, rather than re-reporting the first call's write units relabelled as read. The two magnitudes diverge above 1KB (writes round at 1KB, reads at 4KB); for a ~1.5KB item the first call reports 4 write units and the replay 2 read units. The replay honours its ownReturnConsumedCapacitymode. Single-item operations are unchanged. Confirmed against real DynamoDB by the conformance suite.- A
TransactWriteItemscall with aClientRequestTokennow holds the idempotency lock across the whole first call, closing a window where two concurrent same-token calls could both execute the transaction. The lock was previously released between the cache check and execution, so racing same-token calls each ran the transaction; the second now waits and replays the first's result. Transactions without a token are unaffected. - PartiQL
ExecuteTransactionnow honoursClientRequestTokenidempotency, where it ignored the token and re-applied the statements on every call. A same-token, same-statements call within the 600-second window replays the stored result without re-executing (a same-token call with different statements returnsIdempotentParameterMismatchException), using the same hold-the-lock-across-execute guard asTransactWriteItemsso concurrent same-token calls serialise rather than double-apply. The cache is separate from theTransactWriteItemsone, since idempotency is scoped per API operation.ExecuteTransactionalso now reports transactionalConsumedCapacitysplit by statement kind (write capacity for a write set, read capacity for an all-SELECTread set, and read on a replay) at 2 units per statement, replacing a flat 1-unit-per-statement estimate with no read/write split. Confirmed against real DynamoDB by the conformance suite. GetItem,Query,Scan,BatchGetItem, andTransactGetItemsnow reject an invalidProjectionExpressionbefore any item is read, where dynoxide validated it lazily per row. Overlapping paths (aanda.b), duplicate paths (aanda), and undefined expression-attribute names are rejected with DynamoDB'sInvalid ProjectionExpression:messages, so a lookup that matches nothing still rejects rather than returning an empty result. Confirmed against real DynamoDB in eu-west-2.- A
ProjectionExpressionselecting several indices of one list now returns them compacted and in ascending index order, where dynoxide returned them in request order:#l[2], #l[0]on[l0, l1, l2]now yields[l0, l2]. Confirmed against real DynamoDB in eu-west-2. - A
ProjectionExpressionnaming two or more sub-attributes of the same list index now returns them merged into a single list element, where dynoxide split each path into its own element:l[0].a, l[0].bon{ l: [ { a, b } ] }returned[ { a }, { b } ]and now returns[ { a, b } ]. The merge holds at depth (nested maps and nested lists under one index), distinct indices still stay separate and compact to ascending order, and the fix reaches every projecting read through the shared reconstruction path (GetItem,Query,Scan,BatchGetItem,TransactGetItems). Confirmed against real DynamoDB in eu-west-2 (#126). Querynow accepts aKeyConditionExpressionsort-key comparison with the value on the left (:lo <= #sk), treating it as the attribute-on-left form (#sk >= :lo) for each of<,<=,>,>=, where dynoxide rejected it. A nested or indexed path on a key attribute is now rejected with DynamoDB's message (Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes), replacing dynoxide's own wording. Confirmed against real DynamoDB in eu-west-2.BatchGetItemnow rejects a request that uses an expressionProjectionExpressionon one table's block and a non-expressionAttributesToGeton another, where dynoxide accepted it. Real DynamoDB rejects the whole request even when each block is internally consistent. Confirmed against real DynamoDB in eu-west-2.PutItemandUpdateItemvalidation ordering now matches real DynamoDB: an empty or invalidTableNameis reported on its own, before theReturn*enum checks, where dynoxide aggregated them into one envelope.UpdateItemadditionally stops at the first invalid enum (reportingReturnValues), wherePutItemcontinues to aggregate every invalid enum, matching each operation's own behaviour. Confirmed against real DynamoDB in eu-west-2.UpdateTablenow merges the request'sAttributeDefinitionsinto the table's existing set, where each call replaced the stored list with only the attributes it carried. DynamoDB treats these as a delta: adding a global secondary index only requires the new index's key attributes, so the table keys and prior indexes' attributes need not be re-declared. Adding two GSIs with delta-only attributes therefore dropped the table keys and the first index's attributes fromDescribeTable, and a laterPutItemfailed index-key validation withIndex key attribute GSI1PK missing from AttributeDefinitions. The definitions are now unioned by attribute name, preserving those declared earlier; a redeclared attribute keeps its existing type, matching real DynamoDB, which ignores a conflicting type in the delta rather than overwriting or rejecting it.UpdateTablenow also keepsAttributeDefinitionsequal to exactly the attributes used by the table key schema and the current index key schemas: deleting a GSI prunes its now-orphaned key attributes, and an entry supplied in the delta that is used by no key schema is dropped rather than stored (neither is an error). All verified against AWS in eu-west-2 (#129).
v0.11.1
Fixed
- A
ConditionExpressioncomparing a Map (M) or List (L) attribute for equality now works, where=always reported not-equal and<>always equal regardless of the values.compare_valueshad no arm for document types, so every map or list comparison fell through to the not-equal default; it now compares them deeply - maps order-independently, lists element-wise in order - with nested numbers normalised as elsewhere. The same path backsIN,BETWEEN, andcontainsover document operands, so those are fixed too (#103). ExpressionAttributeValuesnested beyond DynamoDB's 32-level document limit are now rejected up front with the sameValidationExceptionAWS returns, where before they were accepted and evaluated. The check runs on every path that takes expression values - PutItem, UpdateItem, DeleteItem, Query, Scan, and TransactWriteItems. The stored-item nesting check was also one level too lenient (it accepted a value AWS rejects) and carried a non-AWS message; both now match DynamoDB's limit and wording, confirmed against real AWS in eu-west-2 (#110).- Number-set equality in a condition or filter expression now compares at full precision, where it parsed each member to
f64and so reported two sets differing only beyond ~15 significant digits as equal. It now uses the canonical numeric form, matching DynamoDB and the way number-set duplicates are already detected on write; the fix also covers number sets nested inside a map or list (#111). - A
Numberwith a leading+on the mantissa (+5,+1.5,+1e2) is now accepted and stored normalised (+5reads back as5), matching real DynamoDB, where dynoxide rejected it with aValidationException. The validator was reworked to accept exactly DynamoDB's numeric grammar, which also closes two pre-existing gaps in the same direction: malformed forms such as1+2,1.2.3,+e2, and a digitless exponent are now rejected, as is any surrounding or internal whitespace (" 5"was previously trimmed and accepted). The accept and reject boundary was verified against real DynamoDB (#109).
v0.11.0
Added
UpdateTableon the wasm preview engine: add or delete a global secondary index, with existing rows backfilled into a newly added index, and change the simple table settings (provisioned throughput, billing mode, table class, on-demand throughput, deletion protection). A stream-specification change throughUpdateTablestays unsupported, since streams remain a preview gap, and a newly added GSI is reported immediatelyACTIVErather than transitioning throughCREATING.- The wasm engine gained an operation-level
executeAPI, and a new npm package,@dynoxide/wasm-engine, that ships it. The Worker answers a small versioned RPC -open,execute,capabilities,contractVersion- with{id, op, payload}in and{id, ok, result|error}out, and a bundledEngineClientowns the round trip so you deal in objects instead of hand-building postMessage envelopes.npm run build:wasmassembles the package: the Worker, the two.wasm, theEngineClient, and amanifest.jsonstamped with the engine and contract versions. Depend on that built package, not this repo's source. The client checks itsCONTRACT_VERSIONagainst the engine on boot and fails loudly if they differ, so a stale embed can't quietly mis-read a newer one. The package ships TypeScript types for the client. Still a preview: the wasm path isn't run against the conformance suite.
Changed
- On the wasm backend, the per-write and per-delete secondary-index fan-out now crosses the JS bridge once per index type rather than once per index operation. Keeping a table's GSIs and LSIs in step with a write is a delete and a re-insert per index, each previously its own bridge crossing; a new
exec_scriptprimitive carries the whole ordered batch over in a single crossing, so an indexedPutItemorDeleteItemon a table with K GSIs and L LSIs drops from order K+L crossings to a constant two. Index contents and native behaviour are unchanged (#85). - The browser backend moved from
wa-sqliteto the official@sqlite.org/sqlite-wasmengine, maintained by the SQLite team and versioned to track SQLite releases. The bridge now runs through thesqlite3.oo1API over the OPFS SAHPool VFS, which keeps the no-COOP/COEP guarantee that motivated the original VFS choice (it needs noSharedArrayBuffer). Theopen/exec/query/closecontract is unchanged, so consumers of@dynoxide/wasm-engineneed no code change. A busy database now recovers once the holder releases it rather than staying busy until reload, and the full 64-bit integer round-trip and thefnv1a_hashscalar are re-proven on the new engine (#61). The shipped SQLite.wasmis larger than before (~845 KB against wa-sqlite's ~545 KB).
Fixed
- An empty-binary key value now surfaces as a top-level
ValidationExceptionon every path, matching DynamoDB. Previously the lookup path (GetItem/DeleteItem/UpdateItem, batch, and a transactUpdate/Delete/ConditionCheckKey) returned the older...were invalid:...wording and, inside a transaction, aValidationErrorcancellation reason rather than hoisting; the same cancellation-instead-of-hoist gap also affected an empty-binary table item key and an empty-binary secondary-index key in a transaction. This is the binary counterpart to the empty-string key fix #98; real DynamoDB returns the same top-level...are not valid. ... empty binary value...messages (table keys, and the put and update forms for secondary-index keys), confirmed identical across four regions. - Inside a
TransactWriteItems, an empty-string value in the lookupKeyof anUpdate,Delete, orConditionCheckwas wrapped in aTransactionCanceledException; it now surfaces as a top-levelValidationException, matching DynamoDB and completing the empty-string key fix #95 made for thePutitem key. Wrong-type and non-scalar lookup keys still cancel with aValidationErrorreason, and the corrected empty-string message now also matches DynamoDB on the single-actionGetItem/DeleteItem/UpdateItemand batch lookup paths (#98). BatchWriteItemnow reports a wrong-type or non-scalar table key in a put request with DynamoDB's genericThe provided key element does not match the schema, rather than borrowingPutItem'sType mismatch for key ...wording. Real DynamoDB collapses both cases to the schema error inside a batch. The empty-string table-key message and the secondary-index key messages already matched and are unchanged, andPutItemand the other put-shaped paths keep the specific type-mismatch message (#97).QueryandScannow reject twoSelect/ProjectionExpressioncombinations that real DynamoDB rejects before reading any item, where dynoxide previously returned results: aProjectionExpressionwith anySelectother thanSPECIFIC_ATTRIBUTES(such asALL_ATTRIBUTES), andSelect: ALL_PROJECTED_ATTRIBUTESwithout anIndexName. Both now return aValidationExceptionwith DynamoDB's message (#96).- Inside a
TransactWriteItems, a key (table or secondary index) carrying an empty string was wrapped in aTransactionCanceledException; it now surfaces as a top-levelValidationException, matching DynamoDB. Wrong-type and non-scalar key values still cancel with aValidationErrorreason, so only the empty-string case changes. A non-scalar table key no longer fails as an internal error before the transaction runs, and an update that sets a secondary-index key to an empty string now returns DynamoDB's distinct update-path message rather than the put-shaped one (#95). - A write whose secondary-index (GSI or LSI) key attribute is the wrong type, a non-scalar, or an empty string is now rejected with a
ValidationExceptionmatching DynamoDB's exact message, where before it was silently accepted (kept out of the index but still written to the base table). Validation runs on every write path - put, update, batch, transactional, PartiQL, and import. An update only re-checks an index key it actually changes, so an unrelated update to a row holding a pre-existing bad value still succeeds (#92). - A
ScanorQueryon a composite global secondary index no longer returns items that are missing the index sort key; they are now excluded from the index (sparse-index behaviour), matching DynamoDB. Index membership was gated on the partition key alone, so an item carrying the partition key but no sort key was written into the index at an empty sort-key position. Membership is now a single shared rule across both global and local secondary indexes, applied on every write path - put, update, batch, transactional, PartiQL, import, and GSI backfill - and it also excludes an item whose index key attribute is present but not a scalar. In-memory databases start fresh each run and are unaffected; only a file-backed database, or a snapshot taken from one, written by an older build carries stray index rows. They clear as each affected item is next written, and a persisted store can rebuild an index by dropping and re-adding it (#91). PutItemand the other write paths now accept a{"NULL": false}attribute value and read it back as{"NULL": true}, where before they rejected it withOne or more parameter values were invalid: Null attribute value types must have the value of true. The NULL member is typed as a plain boolean in the model, sofalsewas valid input all along; AWS has dropped the server-side true-only rule and normalisesfalsetotrueon read, and dynoxide now matches. A non-boolean NULL such as{"NULL": "no"}is still rejected as a type error (#62).- Hardened the wasm engine preview ahead of a stable
@dynoxide/wasm-enginepublish. A body-less operation such asListTablesnow round-trips instead of failing as aSerializationException(#65). OPFS open tells a busy database (another tab holding its lock) apart from one that is genuinely unavailable: the busy case surfaces a stablecom.dynoxide.wasm#OpfsUnavailableerror rather than silently forking to a separate in-memory store, while a private window or quota error still degrades to an ephemeral session. Re-opening opens the new database before closing the old, so a failed re-open leaves the working session intact, and closing a database releases its OPFS handles so the name is free for another tab (#64). The bridge round-trips full 64-bit integers, and a cross-backend test pins thefnv1a_hashscalar the wasm and native backends share (#61). A headless-browser CI job exercises the shipped bundle against the real wasm engine and OPFS on every PR (#68).
v0.10.0
Added
- A
StorageBackendtrait in the newdynoxide::storage_backendmodule, decoupling the data layer from a specific SQLite binding. The native rusqlite-backedStorageimplements the trait, and the action handlers andDatabasenow consume it (see Changed). The trait surface also carries aclock()accessor for the stream and TTL paths and batch-shapedput_base_items/insert_gsi_itemsmethods that replaced the last two rawStorage::conn()escape hatches in the handlers. - A
BackendErrorenum returned by the trait surface, with an explicitrusqlite::Error -> BackendErrormapping for the common failure modes (NotADatabase, locked / busy, constraint violations, I/O failures), plus anUnsupported { 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
Clockcapability onStorageso the trait surface does not assumestd::time. Stream and TTL paths route theircreated_atand sweep timestamps through the clock;SystemClockis the default andManualClockships as a deterministic test helper. Otherstd::timecall sites (idempotency cache, action-handler timestamps, snapshots) remain native-only and are unchanged. - A
wasm-sqlitecargo feature and a working WebAssembly backend. dynoxide compiles towasm32-unknown-unknownand runs in the browser against wa-sqlite (a WASM build of SQLite) over a wasm-bindgen bridge, persisting to OPFS.WasmBridgeBackendimplementsStorageBackend, andWasmDatabase(Database<WasmBridgeBackend>) exposes the handlers asasync fnwith noblock_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 returnsBackendError::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 adist/of three files - a bundled Web Worker plus the two.wasmassets (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 noSharedArrayBuffer, and so no cross-origin isolation (COOP/COEP) - it drops onto ordinary static hosting. A build-visibleWASM_PREVIEWconstant (trueunderwasm-sqlite) marks the preview. The harness underharness/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 thewasm32-unknown-unknowntarget for both thewasm-sqliteandwasm-harnessfeatures on every PR, so the harness's use ofWasmDatabaseand the action types is type-checked too. - Official Docker image.
docker run -p 8000:8000 ghcr.io/nubo-db/dynoxideis a ~5 MB drop-in foramazon/dynamodb-localin containerised test suites: multi-arch (linux/amd64andlinux/arm64),FROM scratch, published to GHCR on each release with Docker Hub and ECR Public mirrors pushed best-effort. The image ships aHEALTHCHECKbacked by a newdynoxide healthchecksubcommand, sodocker psand 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/--hostto bind beyond loopback,--mcp-allowed-host/--allowed-hostto accept additionalHostheaders by name, and--mcp-no-auth/--no-authto 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
Databaseis now generic over its storage backend:Database<S>, monomorphised, nodyn. The parameter defaults to the native rusqlite backend, so existing code that namesDatabaseis unaffected, and a newNativeDatabasealias names that default explicitly. The action handlers are nowasyncand route through theStorageBackendtrait.NativeDatabasekeeps the historical synchronous public API: each method drives the handler future to completion withblock_on(viapollster), and because the native backend's futures never suspend, thatblock_onnever parks the thread, so it stays safe inside the tokio-based HTTP and MCP servers.DynoxideErroris 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/--tokenorDYNOXIDE_MCP_AUTH_TOKENand will not start without one. The stdio transport is unaffected (#27). - Breaking (library API):
dynoxide::mcp::serve_httpandserve_http_with_shutdownnow take anHttpOptionsstruct (bind host,AuthMode, extra allowed hosts) in place of a bareport: u16. Embedders constructing the MCP HTTP server must buildHttpOptionsand choose anAuthMode. rusqliteis now an optional dependency behind thenative-sqlitefeature (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_atstamps, andSystemClock) moved toweb-time-std::timeon native, the browser clock on wasm. The native binary now builds behind aclimarker feature (pulled in byhttp-server,mcp-server, andimport), so it is skipped in backend-neutral builds such as--features wasm-sqlite. TheDynoxideError::SqliteErrorvariant is consequentlynative-sqlite-gated and absent on backend-neutral builds, which matters only for code that matches it by name on a wasm target.
Fixed
- PartiQL
DELETEandUPDATEnow evaluate the non-key predicates in aWHEREclause instead of acting on the key alone. Before, the executor pulled the primary key out of theWHEREand ignored the rest, soDELETE FROM "t" WHERE pk = 'a' AND NOT begins_with(name, 'x')deleted the row even whennamebegan withx, 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 samematches_wherepassSELECTalready uses: a present item whose non-key predicate is false raisesConditionalCheckFailedException, matching how AWS treats a PartiQL write whose condition fails, and a missing item stays a silent no-op (#54). DescribeTablenow returns a stableTableIdinstead of a freshly generated UUID on every call. The id is a random UUID assigned once at create time and persisted (a newtable_idcolumn, added to existing databases through the versioned schema migration and backfilled), so it stays the same across calls,CreateTablereturns the same value, and a dropped-and-recreated table gets a new one, matching AWS (#55).UpdateItemevaluates anUpdateExpressionagainst the pre-update item image and accepts parenthesised arithmetic.SET a = :v, b = anow givesbthe old value ofarather than the value assigned earlier in the same call, andSET c = (c - :v)parses and applies on theBigDecimalpath instead of being rejected withExpected operand in SET, got ((#35).UpdateItemReturnValues: UPDATED_NEWmatches AWS granularity. A nestedSET parent.child = :vreturns only the changed fragment{parent: {M: {child}}}instead of the wholeparentmap, and a REMOVE-only update omitsAttributesentirely rather than returning an empty map (#36).- Paginating a
Queryover 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 forScan; theQuerypath now carries the base partition key, so every tied item is returned across the paged walk (#52). TransactWriteItems,TransactGetItemsand PartiQLExecuteStatementnow reportConsumedCapacitythe 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); theTransactGetItemsINDEXESbreakdown carriesTable.ReadCapacityUnits; andExecuteStatementreturns theConsumedCapacityblock wheneverReturnConsumedCapacity...
v0.9.13
Security
-
Close a DNS rebinding vulnerability in the MCP HTTP transport
(GHSA-fvh2-gm75-j4j7 /
CVE-2026-42559) by upgradingrmcp
from 1.1.1 to 1.6.0 in both lockfiles. A malicious page could make the
user's browser send requests to a loopback MCP server with a non-loopback
Hostheader, which the server would then process. Affects 0.9.3 to 0.9.12.
Users runningdynoxide mcp --httpordynoxide serve --mcpshould
upgrade; stdio transport is unaffected. -
Close a related cross-origin CSRF gap: a page could
fetchthe loopback
endpoint withmode: 'no-cors', and the Host check would pass while the
Origin header went unchecked. Affected write tools:put_item,
update_item,delete_item,create_table, andbatch_write_item.
Fixed by setting an explicit Host and Origin allowlist on
StreamableHttpServerConfig. Native MCP clients (Claude Code, Cursor,
the dynoxide CLI) don't send an Origin header and are unaffected.
v0.9.12
Fixed
-
Unix: port releases immediately after
dynoxide serveshuts down. The listener used to skipSO_REUSEADDR, leaving leftoverTIME_WAITsockets from connected clients to block restart for ~60s. Live-listener conflict detection is unaffected:SO_REUSEADDRonly bypassesTIME_WAIT, not active sockets.Windows: unchanged.
SO_REUSEADDRlets another process hijack an active bind there, so we leave it off.