Skip to content

Changelog

Release notes for Dynoxide. Pulled from the source CHANGELOG. Subscribe via Atom feed.

Coming next

Unreleased

On main, not yet in a tagged release.

Added

  • The execute_partiql MCP tool accepts ConsistentRead. Omitting it was harmless while the field only chose a rate; it now also decides whether a select qualified by a GSI is rejected, so an agent had no way to reach either behaviour.

  • BatchExecuteStatement accepts ReturnConsumedCapacity and reports capacity, which it previously had no way to do at all. Per-table entries with index arms, aggregated across the batch. A failed statement is still charged the write it attempted, sized on the larger of the row already stored and the item it carried; a batch in which nothing succeeds reports no capacity, and a table whose statements all failed is omitted entirely. Captured against eu-west-2.

Changed

  • Breaking (behaviour): numbers are sized the way DynamoDB sizes them, so consumed capacity moves. Captured byte-exact against eu-west-2 by bisecting the 400KB gate: a number costs one byte, plus ceil(integer significant digits / 2) and ceil(fraction significant digits / 2), over the value with leading and trailing zeros trimmed. The old measure was wrong in both directions. It charged for zeros DynamoDB trims, so 0.0000001 was sized at 5 bytes rather than 2; it rounded the halving down where DynamoDB rounds up, so every number with an odd count of significant digits was a byte light; and it never charged for a minus sign, which costs a byte, so every negative number was light too. Both fed consumed capacity on every write, table_stats, the query and scan byte budgets, and index capacity, so those figures change. An item sitting on the 400KB boundary can flip either way with it.

  • Breaking (behaviour): UpdateItem measures the limit the way DynamoDB does, which is not the way a put is measured. It applied the same ceiling to the finished item as PutItem, where DynamoDB leaves the key attributes out of the figure and charges a fixed amount per action: three bytes for the update, nineteen for each action that writes a value, two for each that clears one, and nothing for the value written, the attribute name, or the expression text. With a one-character key that puts an update's ceiling nineteen bytes below a put's, so an item can be reachable by PutItem and out of reach by UpdateItem. Updates in that band used to succeed and now fail. Two forms are charged less than DynamoDB charges them, so the engine still accepts a little more rather than less: assigning through a list index costs a byte more, and an arithmetic assignment such as SET n = n + :one costs fifty more. The legacy AttributeUpdates parameter is charged on the same shapes, inferred from the expression form rather than captured separately.

    The finished item is held to the 400KB limit as well, so the rule is max(item, item - key attributes + action cost). Taking the key out never buys back more than the actions cost: once the key attributes reach that cost the item's own size binds, and the ceiling sits flat at 400KB however long the key gets. With a 20-byte key the two meet exactly. Measured across key lengths from 1 to 1,024 bytes, DynamoDB never accepts a finished item above 409,600.

  • Breaking (behaviour): TransactWriteItems reports an oversized item as DynamoDB does, where both actions used to come back as a cancellation carrying the put wording. A put's size is knowable from the request, so it is now rejected up front with a top-level ValidationException before the transaction opens; an update's depends on the stored item, so it stays a ValidationError cancellation reason, now reading Item size to update has exceeded the maximum allowed size. Code matching on the error type for an oversized put in a transaction sees a ValidationException where it saw a TransactionCanceledException. A transacted update is measured flat against the resulting item, without the key exclusion and per-action cost the standalone UpdateItem carries, so an item UpdateItem refuses can still be written inside a transaction. Captured against eu-west-2.

  • Breaking (behaviour): the PartiQL write surfaces hold the 400KB item-size limit, where they did not check it at all. INSERT and UPDATE computed an item's size, handed it to storage and never compared it to anything, so ExecuteStatement, BatchExecuteStatement and ExecuteTransaction would store an over-limit row and report its size in table statistics. Both are measured flat against the item, captured against eu-west-2 at a ceiling of 409,600, and report in DynamoDB's words: Item size has exceeded the maximum allowed size for an insert, Item size to update has exceeded the maximum allowed size for an update. A PartiQL update is not charged the key exclusion and per-action cost that the standalone UpdateItem carries, so the two surfaces genuinely differ on the same finished item. Statements that used to succeed now fail.

  • Breaking (behaviour): the three PartiQL surfaces reject a ReturnConsumedCapacity outside INDEXES, TOTAL and NONE, as GetItem, PutItem, UpdateItem, DeleteItem, Query and Scan already did. They derived their requests rather than validating them, so a typo was read as NONE and reported nothing. It now also decides whether a write sizes its indexes, so the same typo quietly skipped that too. A request carrying an unrecognised value used to succeed and now fails.

  • Breaking (behaviour): BatchExecuteStatement and ExecuteTransaction now reject two request shapes they used to accept, matching DynamoDB. A batch or transaction may not mix reads and writes, and may not name the same item twice, reads included. Both are rejected up front with a top-level ValidationException before any statement runs; a request that previously succeeded in either shape now fails. An unparseable member is unaffected and still reports against itself while the rest of the batch runs. Captured against eu-west-2.

    The four checks cost about 7% on a 25-statement BatchExecuteStatement, measured against 958e340.

  • Breaking (Rust API): the PartiQL parser's public types moved with the index-qualifier work. Every partiql::parser::Statement variant is now #[non_exhaustive], and Select, Update and Delete carry an index_name, so a downstream match needs a .. and construction goes through Default or deserialisation. partiql::parser::parse returns ParseError rather than String, because which envelope a rejection takes is part of the observable contract and the caller cannot tell from the text. partiql::parser::CompOp is a re-export of the condition engine's operator rather than a second enum of the same shape. partiql::executor::execute_page takes the statement's ConsistentRead, which now decides both the rate a read is charged at and whether a GSI-qualified select is rejected at all. Source-breaking for the crate's public API, so the next release is a minor bump. The DynamoDB wire API and the CLI, server and MCP surfaces are unaffected.

  • Breaking (Rust API): actions::batch_execute_statement::BatchStatementRequest is now #[non_exhaustive]. It is still short of DynamoDB's shape, lacking ConsistentRead and ReturnValuesOnConditionCheckFailure, so it will gain fields again and the attribute is there to stop that breaking anyone twice. Library consumers that construct it must build from Default and assign, or deserialise it. BatchExecuteStatementRequest is deliberately not marked: with ReturnConsumedCapacity on it, that type now matches DynamoDB exactly. This is a source-breaking change for the crate's public API, so the next release is a minor bump. The DynamoDB wire API and the CLI/server/MCP surfaces are unaffected.

  • Breaking (Rust API): partiql::parser::WhereClause is now #[non_exhaustive] and carries wrote_or, saying whether the clause as written joined anything with OR. The flattened group count no longer answers that, because a NOT over a conjunction becomes a disjunction under De Morgan, so UPDATE ... WHERE pk='x' AND NOT (a=1 AND b=2) used to be refused with a message about an OR the statement did not contain. from_conditions and from_groups are gone with it: both set wrote_or by guessing at it, from_groups from the group count and from_conditions from there being one group, and neither guess survives a NOT. Construct one through from_groups_written, which takes the answer rather than inferring it.

  • Breaking (Rust API): partiql::executor::statement_target is removed. It resolved a statement's table only to read one item key off it and throw the resolution away, and both batch surfaces now resolve the table once for the whole batch and call statement_target_in per statement instead. A caller that still wants the old one-shot behaviour can get it from ResolvedTable::load followed by statement_target_in. ResolvedTable is the new public type holding that resolution: a table's metadata, its parsed key schema, and the name it was resolved from, so a resolution handed to a call against another table can be spotted rather than obeyed. It is #[non_exhaustive], so build one through load. Source-breaking for the crate's public API; the DynamoDB wire API and the CLI, server and MCP surfaces are unaffected.

  • Breaking (Rust API): partiql::parser::WhereCondition is now #[non_exhaustive] and carries a NotComparison variant, holding a NOT in front of a comparison rather than flipping the operator. The flip is wrong on a row with no such attribute: a = 'x' is false there, so NOT a = 'x' is true and the row belongs in the result, where a <> 'x' compares a value that is not there and drops it. A downstream match on the enum needs a wildcard arm. The attribute is there because the parser gains a variant every time it learns a predicate, and each one it gained used to break every caller at once.

  • Breaking (Rust API): partiql::executor::execute_page takes two further arguments beyond the ConsistentRead above: the request's ReturnConsumedCapacity mode, so a write can skip sizing its indexes when nobody asked what it cost, and an optional ResolvedTable, so a batch resolves its table once rather than once per statement. StatementPage gains read_index, naming the index a SELECT was served from, and base_read_units, what an LSI reach-back cost on the base table in read units. The struct is #[non_exhaustive], so the two fields are additive; the argument list is not.

Fixed

  • The write paths no longer disagree about the 400KB item-size limit (#187). A number was sized by counting the digit characters of whatever string it held, and storage expands scientific notation, so the same item measured one size as it arrived and another once stored. PutItem checked the limit before that expansion and the other three after it, which is how an item of 8000 attributes holding N "1E125" passed PutItem, was refused by BatchWriteItem, and was then recorded at 552,004 bytes against a 409,600 limit. The recorded size feeds table statistics and the ItemCollectionMetrics estimate. Sizing a number by its significant digits makes the figure survive storage, so where each path checks no longer matters. The surfaces that measure differently on purpose, and the two that were not checking at all, are covered in the behaviour changes above.

  • ConsumedCapacity under INDEXES charges index writes on the change to what an index stores, not on the item the write leaves behind (#176). Each index used to be charged a unit whenever the finished item belonged to it, which is right for a plain insert and wrong for most else:

    • LSI writes are charged. The LocalSecondaryIndexes arm never appeared before, and its units never reached the total.
    • An identical overwrite is free, and a write outside a GSI's projection no longer charges that GSI.
    • Moving an index key costs two writes; removing one costs a single delete.
    • DeleteItem charges an index only when the deleted item was in it.

    Units are sized on the projected index entry rather than the base item: an insert or delete costs its own image, an in-place update the larger of the two, and a key move both halves rounded separately. BatchWriteItem inherits all of it. Two of the old figures over-reported rather than under-reported, so any downstream correction for the old numbers needs removing. Captured against eu-west-2.

  • PutItem, UpdateItem and BatchWriteItem size the table arm on the larger of the item's before and after images, matching DynamoDB. They used to size it on the finished item, so shrinking a 3KB item to 100B reported one unit against DynamoDB's three. Items under 1KB are unaffected, and DeleteItem already sized on the old image.

  • PutItem sizes the item after normalising it rather than as it arrived. Normalising expands scientific notation, so an item carrying 1E100 grows on the way to storage; the table arm was measuring the request and the stored size was recording it, while the same item read back measured larger. The same write reported one figure as an insert and another as an overwrite, and disagreed with BatchWriteItem, which already sized after normalising.

  • An overwrite that reorders a set's members is free, as it is on DynamoDB. Sets are unordered, so re-listing the same members leaves an index's stored view alone, but they are held in order internally and the comparison read that as a change, charging every index projecting the set.

  • Enabling wasm-sqlite alongside the default features now fails with a message that says so. Cargo adds the default features to whatever a manifest lists, so dynoxide-rs = { version = "0.13", features = ["wasm-sqlite"] } also enabled the native backend and the CLI, and the build stopped on three type errors naming neither the feature nor the conflict. The combination was never valid and still is not; what was missing was the diagnosis. Enable the wasm backend with default-features = false.

  • TransactWriteItems, ExecuteStatement, ExecuteTransaction and BatchExecuteStatement report per-index ConsumedCapacity under INDEXES, where they used to report a Table arm and nothing else (#178). The transactional 2x factor applies to the base table arm alone: an index arm inside a transaction costs what the same write costs outside one. Index units fold into the total, so totals grow on an indexed table under TOTAL as well.

  • The transactional table arm is sized on the larger of the item's before and after images rather than on the request payload. Delete and ConditionCheck carry a key and no item, so both used to be sized on the key: deleting a 3KB item reported 2 units against DynamoDB's 6. Invisible below 1KB. A ConditionCheck writes nothing and is still charged on the image it read.

  • A same-token TransactWriteItems or ExecuteTransaction replay is charged against the images the first call was sized on, at 4KB read granularity. It used to recompute from the request, which carries no item for a Delete or a ConditionCheck, so a replayed delete of a 9KB item reported 2 against DynamoDB's 6.

  • A PartiQL SELECT is served from the index its FROM clause names (#179). SELECT * FROM "table"."index" used to discard the index and scan the base table, so it returned items the index does not contain and a name matching no index quietly succeeded. The qualifier tokenises as three tokens and the table name parser took one, which left the rest for the next clause to parse against, so the WHERE went with it. That single mis-parse cost UPDATE its SET clause and made DELETE report requiring a WHERE clause it plainly had.

    The read now follows the index: sparse membership excludes items the index does not hold, and a KEYS_ONLY or INCLUDE projection returns what it projects and no more. Capacity lands on the index arm with the table arm at zero, matching Query and Scan. Continuation tokens carry the base table key, so rows sharing an index key are no longer skipped, and a token is bound to the index that minted it.

    Five rejections come with it, each with DynamoDB's own wording: an unknown index name, a FROM path of more than two components, an empty path component, a strongly consistent read of a GSI, and a qualifier on a write statement. A GSI rejects a projection naming an attribute it does not carry; either kind rejects a filter on one, but only when the read is keyed on the index partition key, because an unkeyed read is a scan and a scan matches nothing. Captured against eu-west-2.

  • A PartiQL predicate on a set, list, map, binary or null attribute compares by value instead of never matching (#186). PartiQL carried its own comparison covering strings, numbers and booleans, and answered every other type from a catch-all returning false for = and true for <>. The same code gates UPDATE and DELETE, so a write conditioned on any of those types could never fire. Sets compare without regard to order, lists in order, maps on their key set. PartiQL now shares the condition-expression engine's comparison rather than carrying a second copy of it. Captured against eu-west-2: the two surfaces agree on every type.

  • An unterminated quoted name is rejected instead of panicking the parser. SELECT * FROM " sliced a one-character string from index 1, which panics, and the release profile aborts on panic, so a single malformed statement took the process down. Predates the index qualifier, which added a second way to reach it.

  • BatchStatementRequest carries ConsistentRead (#183) and ReturnValuesOnConditionCheckFailure (#184). Both were absent, so a member setting either was parsed as though it had not.

    ConsistentRead is per member and sets the rate that member's read is charged at: a keyed batch SELECT costs 0.5 without it and 1 with it, and a batch mixing the two sums both rates. It does not change which rows come back.

    ReturnValuesOnConditionCheckFailure is accepted and inert, which is what DynamoDB does with it. A member whose condition fails returns the same response whether it is ALL_OLD, NONE or absent, and never carries the item; the same option on a TransactWriteItems ConditionCheck does return it, which is what rules out a bad measurement. The field is deserialised so a client setting it meets a field dynoxide knows rather than one it drops. Captured against eu-west-2.

  • A SELECT inside BatchExecuteStatement must name the table's primary key, and may not name an index. Both shapes were accepted and served; DynamoDB rejects each against that member while the rest of the batch runs, with the same message. An index-qualified batch read is therefore unreachable even when it does name the key.

  • A batch member whose statement ran and failed echoes its table, where one rejected before it ran does not. ConditionalCheckFailed and DuplicateItem carry TableName and a ValidationError does not, which is what an invalid RETURNING variant is.

  • Parenthesised grouping and NOT work in a WHERE clause. Both were documented as supported and neither was: the clause parser was a flat OR of ANDs, so even WHERE (a='1') was a parse error, and NOT was recognised only inside NOT EXISTS and NOT BEGINS_WITH. AND binds tighter than OR, and a NOT over a group is applied by De Morgan. A clause whose flattened form exceeds 256 alternatives is rejected as too complex rather than expanded.

  • An ordering comparison rejects an operand whose type has no ordering. DynamoDB orders S, N and B, and rejects <, <=, >, >= and BETWEEN against anything else before it resolves the table; dynoxide answered no rows. = and <> are unaffected, being defined for every type. Captured against eu-west-2.

  • An index-qualified SELECT inside ExecuteTransaction is rejected, as it is on DynamoDB, rather than served and charged to the base table arm.

  • A PartiQL read is charged on the rows it walked rather than the rows it handed back. DynamoDB sizes a read before the WHERE clause and before the projection, so a SELECT matching one row costs what one matching every row costs, and naming one attribute costs what naming all of them costs. Query and Scan already did this; the PartiQL executor summed the rows it was about to return, so any filtered or projected statement under-reported. Captured against eu-west-2.

  • A statement nested deeper than the parser can walk is rejected rather than taking the process with it. Around 250 nested parentheses in a WHERE clause, or a few thousand leading NOTs, overflowed the stack, and the release profile aborts on panic, so a statement of about a kilobyte was enough to stop the host. Nested list and map literals reached the same end by a second route. Both descents now carry a depth budget and reject past it, as an over-complex clause already was.

  • A negated comparison keeps the rows its attribute is missing from. NOT a='x' answered on rows holding an a and skipped the rest, because negation flipped the operator and <> is false on a missing path, while NOT CONTAINS wrapped instead and kept them. The two disagreed with each other on the same data. Negation now wraps in every form, so a row the comparison cannot answer for is a row the negation matches.

  • A SELECT inside BatchExecuteStatement naming a table that does not exist reports the missing table. It reported that the statement must specify a primary key in the where clause, which it had, because the check that reads a member's target cannot tell a missing key from a missing table and only the reads went through it. The same batch reported a missing table correctly for a write and misleadingly for a read.

  • An LSI read that reaches back to the base table is charged on the bytes each base read moved, not a flat half unit apiece. The flat figure came from one capture of three small rows and holds up to 4KB. Captured again across item sizes: the same three rows at 9KB each cost 4.5 rather than 1.5, and 9 under ConsistentRead. A SELECT * against an INCLUDE index does not reach back at all, and did not before.

  • Two pieces of repeated work on the write and batch paths are gone. Index writes were sized on every write, and ReturnConsumedCapacity defaults to NONE, where the response builders discard the figure, so a default write projected the old image of every index for nobody. An overwrite against a table with two indexes now builds two projected entries rather than four. Separately, a batch resolved each statement's table metadata and parsed its key schema twice, and a failing statement a third time; both are per-table facts, so a batch resolves each table once however many statements it carries. A 25-statement batch goes from 51 metadata loads and 51 key schema parses to one of each, which matters most on the wasm backend, where every load crosses the bridge to a JS worker and nothing caches the result.

  • An LSI serves a projection naming an attribute it does not carry by reading the base item, which is what DynamoDB does and a GSI cannot. dynoxide returned rows of empty objects. The base reads land on the table arm at read granularity apiece, so three rows served this way report total 2, table 1.5, lsi 0.5.

0.13.0

Added

  • PartiQL now works in the browser. The wasm engine serves ExecuteStatement, BatchExecuteStatement and ExecuteTransaction, where it previously answered all three with a 501. Behaviour matches the native build statement for statement, including the RETURNING projections, the per-statement error codes BatchExecuteStatement reports, and ClientRequestToken idempotency on ExecuteTransaction. The engine's advertised capability list grows from 14 operations to 17; CONTRACT_VERSION is 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::dispatch and wasm_api::dispatch_http take a DispatchContext, which borrows the idempotency caches a ClientRequestToken needs. Both are behind the wasm-sqlite feature, 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 of dispatch build a context from the new public TokenCaches. This is separate from CONTRACT_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 SELECT on ExecuteStatement now paginates: NextToken is honoured and returned, and Limit bounds the rows evaluated rather than the rows matched, matching DynamoDB and the existing Query and Scan semantics. A filtered SELECT with a Limit can therefore return fewer rows than before for the identical request, and a page can come back short or empty while still carrying a NextToken, so callers should follow the token rather than treat a short page as the end of the result. A SELECT bound to a partition key now reads in ascending sort-key order, and its sort-key conditions are pushed into the read, so Limit paces 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. A NextToken is now rejected when replayed against a different statement or different Parameters, not just a different table, where it previously resumed the walk and silently skipped rows; the mismatch carries DynamoDB's NextToken does not match request message (captured eu-west-2) while an undecodable token keeps Invalid NextToken. Limit: 0 is 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. A COUNT(...) projection now returns DynamoDB's exact ValidationException, the bare Unexpected path component message with the 1-based position of the COUNT token (captured eu-west-2), fired before the table-existence check, on ExecuteStatement, BatchExecuteStatement and ExecuteTransaction alike. 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 with Select: COUNT.
  • The wasm engine refuses a CreateTable carrying an enabled StreamSpecification or Tags, and an UpdateTable carrying a StreamSpecification, before creating or changing anything, with the same typed UnsupportedOperation envelope 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 surfaced ResourceInUseException for 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 500 InternalServerError, so a client - and an SDK retry policy - can tell a scope gap from a server fault.

0.12.0

Changed

  • Breaking (Rust API): the public partiql::parser::Statement enum gained a returning field on its Update and Delete variants, and both variants are now #[non_exhaustive]; the public actions::batch_execute_statement::BatchStatementResponse struct gained a table_name field 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 RETURNING clause on ExecuteStatement, where dynoxide previously parsed the statement but silently dropped the clause. DELETE ... RETURNING ALL OLD * returns the deleted item in Items (a present but empty Items array on a missing target, matching DynamoDB rather than the classic DeleteItem path), and UPDATE ... RETURNING <ALL|MODIFIED> <OLD|NEW> * returns the matching projection of the item; the MODIFIED variants return only the changed paths (a nested SET a.b returns just the changed leaf, not the whole a attribute), exclude the primary key, and return an empty Items array when nothing was projected. BatchExecuteStatement honours a member's RETURNING clause; ExecuteTransaction rejects one with a top-level ValidationException. The RETURNING variants DynamoDB does not allow on DELETE (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 shipping dist/ 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 a dispatchHttp worker 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 serve and dynoxide (no-subcommand) now accept a --schema flag, taking the same DynamoDB DescribeTable JSON format as import --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

  • OnDemandThroughput now 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 (-1 is 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 -1 kept 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 an OnDemandThroughput object with no members is treated as absent: real DynamoDB accepts one at creation and returns InternalFailure for 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_table default view now includes billing_mode, table_class and the capacity settings for the mode the table is in (provisioned_throughput or on_demand_throughput), where it previously showed none of the table configuration fields and only the raw: true view carried them.
  • The MCP create_table and update_table tools now accept on_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_table tool now accepts billing_mode, provisioned_throughput and table_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_table tool now accepts billing_mode and provisioned_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 --schema and serve --schema no longer drop BillingMode and TableClass from a DescribeTable response. DescribeTable wraps both in summary objects (BillingModeSummary, TableClassSummary) that the rebuilt CreateTableRequest never read, so an on-demand table came back PROVISIONED and STANDARD_INFREQUENT_ACCESS came back STANDARD. The schema path now unwraps both summaries and, when the billing mode came from the summary, drops the zeroed ProvisionedThroughput blocks 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 for contains, expression parameter misuse (ExpressionAttributeValues without an expression, mixing Expected with ConditionExpression), and invalid ReturnValues. 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-typed AttributeValue objects, 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-typed SerializationException. Read operations are unchanged and their expression errors stay bare. Confirmed against real DynamoDB in eu-west-2.
  • PartiQL UPDATE now performs real list-index writes. SET tags[0] = :v updates the list element (appending when the index is at or beyond the end) and REMOVE tags[0] deletes it and shifts the rest, where dynoxide previously treated tags[0] as a literal map key so both the stored item and a RETURNING MODIFIED projection over it diverged from DynamoDB. A RETURNING MODIFIED projection 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.
  • BatchExecuteStatement now echoes TableName on each successful member response, and a member that fails to parse now carries the short-form ValidationError code (matching a per-statement execution error) instead of the long-form ValidationException. Both match DynamoDB.
  • PartiQL UPDATE on a non-existent key now fails with ConditionalCheckFailedException (The conditional request failed) and creates nothing, where dynoxide upserted the item. UPDATE is 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 reports Expected data manipulation. Applies to ExecuteStatement, BatchExecuteStatement, and ExecuteTransaction.
  • A { "NULL": false } attribute value is now rejected with the ValidationException real 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. The NULL member must be exactly true; this specific input flipped behaviour across AWS regions and has since settled on rejection everywhere. The fix covers the item body and the DeleteItem raw expression-value path, where the rejection had surfaced as a mis-typed SerializationException leaking an internal prefix rather than the plain ValidationException. Confirmed against real DynamoDB in eu-west-2 (#145).
  • UpdateTable adding a global secondary index now validates the index's key attributes against the request's own AttributeDefinitions, where dynoxide resolved them from the merged stored set and so accepted a new index keyed on an existing table attribute the request did not re-declare. DynamoDB requires a new index's key attributes to appear in the request itself and rejects the omission with One or more parameter values were invalid: Some index key attributes are not defined in AttributeDefinitions. An unused definition supplied in the delta is still dropped, and a redeclared attribute still keeps its stored type. Confirmed against real DynamoDB in eu-west-2 (#144).
  • An expression parameter over 4096 bytes is now rejected with DynamoDB's Invalid <Type>Expression: Expression size has exceeded the maximum allowed size message, where dynoxide parsed it regardless of length. The length is measured on the raw string as sent, before name and value substitution. The guard covers every expression surface, each carrying the surface-specific Invalid <Type>Expression: prefix: UpdateExpression, ConditionExpression, FilterExpression (Query and Scan), ProjectionExpression, and KeyConditionExpression; on the key-condition surface the size check runs before parsing, so an oversized key condition is rejected for size even when otherwise malformed. Confirmed against real DynamoDB in eu-west-2 (#146).

0.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-path and --encryption-key-file exist for the bare pre-subcommand form (dynoxide --port 8000) and only fed the no-subcommand path, so dynoxide --db-path data.db serve started an in-memory server, never created the file, and the data was gone on exit with nothing said about it; dynoxide --port 8893 serve listened on 8000. Combining one with serve, mcp, import or healthcheck now fails up front with clap's conflict error naming the offending option, and the same option after the subcommand keeps working as before (#141).

0.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 with SO_REUSEADDR while 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).

0.11.2

Fixed

  • A CreateTable request whose StreamSpecification sets StreamEnabled: false but also supplies a StreamViewType is now rejected with the ValidationException real 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 CreateTable global or local secondary index using ProjectionType: INCLUDE without a NonKeyAttributes list is now rejected with the ValidationException real 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. INCLUDE projects 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).
  • Query and Scan now return DynamoDB's exact message when Select: SPECIFIC_ATTRIBUTES is given with no ProjectionExpression or AttributesToGet, where dynoxide rejected the request correctly but with its own wording. Both carried the same non-AWS string; the corrected phrase is Must specify the AttributesToGet or ProjectionExpression when choosing to get SPECIFIC_ATTRIBUTES, which Query wraps in the 1 validation error detected: envelope and Scan returns bare, matching real DynamoDB (#121).
  • TransactWriteItems now reports top-level ReadCapacityUnits and WriteCapacityUnits in its ConsumedCapacity, where only the nested Table breakdown carried them. A transactional write reports write capacity (a standalone ConditionCheck costs 2 write units on its own table line under INDEXES), 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 own ReturnConsumedCapacity mode. Single-item operations are unchanged. Confirmed against real DynamoDB by the conformance suite.
  • A TransactWriteItems call with a ClientRequestToken now 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 ExecuteTransaction now honours ClientRequestToken idempotency, 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 returns IdempotentParameterMismatchException), using the same hold-the-lock-across-execute guard as TransactWriteItems so concurrent same-token calls serialise rather than double-apply. The cache is separate from the TransactWriteItems one, since idempotency is scoped per API operation. ExecuteTransaction also now reports transactional ConsumedCapacity split by statement kind (write capacity for a write set, read capacity for an all-SELECT read 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, and TransactGetItems now reject an invalid ProjectionExpression before any item is read, where dynoxide validated it lazily per row. Overlapping paths (a and a.b), duplicate paths (a and a), and undefined expression-attribute names are rejected with DynamoDB's Invalid 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 ProjectionExpression selecting 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 ProjectionExpression naming 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].b on { 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).
  • Query now accepts a KeyConditionExpression sort-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.
  • BatchGetItem now rejects a request that uses an expression ProjectionExpression on one table's block and a non-expression AttributesToGet on 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.
  • PutItem and UpdateItem validation ordering now matches real DynamoDB: an empty or invalid TableName is reported on its own, before the Return* enum checks, where dynoxide aggregated them into one envelope. UpdateItem additionally stops at the first invalid enum (reporting ReturnValues), where PutItem continues to aggregate every invalid enum, matching each operation's own behaviour. Confirmed against real DynamoDB in eu-west-2.
  • UpdateTable now merges the request's AttributeDefinitions into 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 from DescribeTable, and a later PutItem failed index-key validation with Index 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. UpdateTable now also keeps AttributeDefinitions equal 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).

0.11.1

Fixed

  • A ConditionExpression comparing a Map (M) or List (L) attribute for equality now works, where = always reported not-equal and <> always equal regardless of the values. compare_values had 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 backs IN, BETWEEN, and contains over document operands, so those are fixed too (#103).
  • ExpressionAttributeValues nested beyond DynamoDB's 32-level document limit are now rejected up front with the same ValidationException AWS 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 f64 and 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 Number with a leading + on the mantissa (+5, +1.5, +1e2) is now accepted and stored normalised (+5 reads back as 5), matching real DynamoDB, where dynoxide rejected it with a ValidationException. 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 as 1+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).

0.11.0

Added

  • UpdateTable on 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 through UpdateTable stays unsupported, since streams remain a preview gap, and a newly added GSI is reported immediately ACTIVE rather than transitioning through CREATING.
  • The wasm engine gained an operation-level execute API, 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 bundled EngineClient owns the round trip so you deal in objects instead of hand-building postMessage envelopes. npm run build:wasm assembles the package: the Worker, the two .wasm, the EngineClient, and a manifest.json stamped with the engine and contract versions. Depend on that built package, not this repo's source. The client checks its CONTRACT_VERSION against 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_script primitive carries the whole ordered batch over in a single crossing, so an indexed PutItem or DeleteItem on 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-sqlite to the official @sqlite.org/sqlite-wasm engine, maintained by the SQLite team and versioned to track SQLite releases. The bridge now runs through the sqlite3.oo1 API over the OPFS SAHPool VFS, which keeps the no-COOP/COEP guarantee that motivated the original VFS choice (it needs no SharedArrayBuffer). The open/exec/query/close contract is unchanged, so consumers of @dynoxide/wasm-engine need 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 the fnv1a_hash scalar are re-proven on the new engine (#61). The shipped SQLite .wasm is larger than before (~845 KB against wa-sqlite's ~545 KB).

Fixed

  • An empty-binary key value now surfaces as a top-level ValidationException on every path, matching DynamoDB. Previously the lookup path (GetItem/DeleteItem/UpdateItem, batch, and a transact Update/Delete/ConditionCheck Key) returned the older ...were invalid:... wording and, inside a transaction, a ValidationError cancellation 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 lookup Key of an Update, Delete, or ConditionCheck was wrapped in a TransactionCanceledException; it now surfaces as a top-level ValidationException, matching DynamoDB and completing the empty-string key fix #95 made for the Put item key. Wrong-type and non-scalar lookup keys still cancel with a ValidationError reason, and the corrected empty-string message now also matches DynamoDB on the single-action GetItem/DeleteItem/UpdateItem and batch lookup paths (#98).
  • BatchWriteItem now reports a wrong-type or non-scalar table key in a put request with DynamoDB's generic The provided key element does not match the schema, rather than borrowing PutItem's Type 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, and PutItem and the other put-shaped paths keep the specific type-mismatch message (#97).
  • Query and Scan now reject two Select/ProjectionExpression combinations that real DynamoDB rejects before reading any item, where dynoxide previously returned results: a ProjectionExpression with any Select other than SPECIFIC_ATTRIBUTES (such as ALL_ATTRIBUTES), and Select: ALL_PROJECTED_ATTRIBUTES without an IndexName. Both now return a ValidationException with DynamoDB's message (#96).
  • Inside a TransactWriteItems, a key (table or secondary index) carrying an empty string was wrapped in a TransactionCanceledException; it now surfaces as a top-level ValidationException, matching DynamoDB. Wrong-type and non-scalar key values still cancel with a ValidationError reason, 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 ValidationException matching 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 Scan or Query on 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).
  • PutItem and the other write paths now accept a {"NULL": false} attribute value and read it back as {"NULL": true}, where before they rejected it with One 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, so false was valid input all along; AWS has dropped the server-side true-only rule and normalises false to true on 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-engine publish. A body-less operation such as ListTables now round-trips instead of failing as a SerializationException (#65). OPFS open tells a busy database (another tab holding its lock) apart from one that is genuinely unavailable: the busy case surfaces a stable com.dynoxide.wasm#OpfsUnavailable error 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 the fnv1a_hash scalar 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).

0.10.0

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.

0.9.13

Security

  • Close a DNS rebinding vulnerability in the MCP HTTP transport (GHSA-89vp-x53w-74fx / CVE-2026-42559) by upgrading rmcp 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 Host header, which the server would then process. Affects 0.9.3 to 0.9.12. Users running dynoxide mcp --http or dynoxide serve --mcp should upgrade; stdio transport is unaffected.

  • Close a related cross-origin CSRF gap: a page could fetch the loopback endpoint with mode: '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, and batch_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.

0.9.12

Fixed

  • Unix: port releases immediately after dynoxide serve shuts down. The listener used to skip SO_REUSEADDR, leaving leftover TIME_WAIT sockets from connected clients to block restart for ~60s. Live-listener conflict detection is unaffected: SO_REUSEADDR only bypasses TIME_WAIT, not active sockets.

    Windows: unchanged. SO_REUSEADDR lets another process hijack an active bind there, so we leave it off.

0.9.11

Fixed

  • dynoxide serve --mcp now exits cleanly on Ctrl+C when an MCP client (Claude Code, Cursor) is holding a connection open. The MCP server's graceful-shutdown drain used to wait for those connections forever, hanging the process until something SIGKILLed it (#22)

Security

  • Refresh Cargo.lock for the dependabot patches reachable within MSRV: aws-lc-sys 0.37.1 to 0.40.0 (5 high-severity AWS-LC issues), openssl 0.10.75 to 0.10.79 (5 buffer-overflow advisories), rand 0.8.5 to 0.8.6. Remaining rustls-webpki / time / aws-sdk-dynamodb alerts are dev-dependency only (test-suite AWS SDK chain, not the production binary) and stay pinned by MSRV 1.85 until v0.10.0

0.9.10

Fixed

  • 16 places where dynoxide's error strings drifted from real AWS DynamoDB. Mostly small things you only notice when you assert the message: tableName length validation is now per-operation (1 char on read/write, 3 stays on CreateTable), Select enum order matches AWS rather than alphabetical, Query vs Scan Limit=0 messages are different on purpose now, batch/transact empty and oversize requests use the standard validation envelope, and UpdateExpression/ProjectionExpression syntax errors include the AWS near: "..." window (#11, #12, #13, #15, #16, #17, #18)
  • TransactGetItems with a bad action key now comes back as a TransactionCanceledException with ValidationError rather than HTTP 500. The 500 was a real leak: the dedup loop called the server-fault helper before key validation (#19)

0.9.9

Fixed

  • KeyConditionExpression now accepts parenthesised sub-expressions, matching DynamoDB. Forms like (#pk = :pk) AND (#sk = :sk) previously returned ValidationException: Expected attribute name, got (. Both outer-wrap and per-condition parens are now handled (#4, #7)
  • UpdateItem and TransactWriteItems.Update now evaluate ConditionExpression against the existing item before populating key attributes for upsert. Previously attribute_exists(pk) on a non-existent key succeeded and created a ghost item (#5)
  • Paginated Scan on a GSI now returns all items when multiple items share the same GSI partition key. Previously the second page returned 0 items because the pagination cursor used only (gsi_pk, gsi_sk) instead of the full 4-tuple primary key (#6)
  • <> on missing attributes now returns true, matching DynamoDB. All other comparison operators continue to return false on missing operands. Previously <> also returned false, breaking PutItem conditional idioms like status <> "working" against fresh keys (#8)

0.9.8

Fixed

  • Dynoxide no longer orphans when backgrounded in npm scripts (dynoxide & sleep 1 && npm run seed && react-router dev) -- the port is released when the parent process exits (nubo-db/dynoxide#2)
  • The Rust server now handles SIGTERM for graceful shutdown, not just SIGINT (Ctrl+C) -- kill <pid> now works as expected
  • The npm wrapper switches from spawnSync to async spawn with explicit signal forwarding (SIGINT, SIGTERM, SIGHUP) and double-signal SIGKILL escalation
  • Parent-death detection via PPID polling catches the backgrounded case where no signal is delivered to the wrapper

0.9.7

Fixed

  • Benchmark sanity checks were blocking README updates during release - 10 stale values from the v0.9.6 pipeline now corrected
  • Binary download size in README was wrong (~5 MB, actually ~3 MB compressed / ~6 MB on disk)
  • Docker image sizes now show both download and on-disk measurements - the old "225 MB" was the compressed download, the actual on-disk size is 471 MB
  • MCP tool count in README was 33, should be 34 - execute_transaction_partiql was missing from the list
  • npm README had incorrect --input and --db-path flags for the import command (should be --source, --schema, --output)
  • Dropped the serve subcommand from npm examples (bare dynoxide --port 8000 is the preferred form)

Changed

  • Restructured release pipeline for token efficiency and reliability - dispatch verification, idempotent crate/npm publishing, template-based Homebrew formula updates
  • npm publishing uses OIDC provenance via a dedicated npm.yml workflow
  • Cross-compilation switched to cargo-zigbuild for aarch64-musl targets
  • Commit Cargo.lock for reproducible CI builds (was previously gitignored)
  • Updated npm package README to reflect current CLI usage and features

Security

  • Updated aws-lc-sys 0.37.1 to 0.39.1 (10 high-severity advisories - PKCS7 verification bypass, timing side-channel in AES-CCM, CRL/name constraint issues)
  • Updated rustls-webpki 0.103.9 to 0.103.10 (2 medium-severity CRL Distribution Point matching issues)

0.9.6

Fixed

  • Statically link the MSVC C runtime on Windows so the release binary no longer requires VCRUNTIME140.dll
  • Switch Linux aarch64 target to musl for fully static binaries (matching x86_64)

Changed

  • Drop the separate x86_64-unknown-linux-gnu release target (the musl build is already fully portable)

0.9.5

Added

  • DynamoDB conformance suite - 526 independently written tests across 3 tiers, validated against real DynamoDB ground truth. Dynoxide: 100%. DynamoDB Local: 92%. See dynamodb-conformance.
  • Dynalite external conformance - 817/1039 passing (87.1% DynamoDB parity) against Dynalite's test suite, where real DynamoDB itself only passes 51%
  • DynamoDB compatibility documentation - a public compatibility summary covering operation, expression, index, and PartiQL support, with a DynamoDB Local comparison column
  • Correctness fixes - 41 issues resolved across core operations and PartiQL
  • Reserved word validation - 573 DynamoDB reserved keywords rejected in ConditionExpression, UpdateExpression, FilterExpression, and ProjectionExpression with correct error messages
  • README benchmark automation - CI benchmark numbers auto-updated via template markers and Python script; PR-based review with sanity checking
  • IdempotentParameterMismatchException - TransactWriteItems detects same token with different payload
  • AccessDeniedException - returned for tag operations on non-existent ARNs (matches DynamoDB behaviour)

Changed

  • BigDecimal replaces f64 for all number comparisons and arithmetic - eliminates silent precision loss beyond 15 significant digits; f64 fast-path for ≤15 significant digits preserves performance
  • PartiQL INSERT now fails with DuplicateItemException if item already exists (previously silently overwrote)
  • PartiQL tokeniser - correct handling of negative numbers, escaped single quotes, unknown characters (error instead of silent skip)
  • Query/Scan COUNT now returns filtered count, not scanned count, when FilterExpression is present
  • begins_with sort key - SQL LIKE wildcards (%, _) properly escaped
  • Condition + write operations wrapped in SQLite transactions to prevent TOCTOU races
  • 1MB response limit now counts all scanned items, not just filtered results
  • GSI query/scan LastEvaluatedKey now includes base table key attributes
  • BatchWriteItem rejects duplicate keys within the same request
  • TransactWriteItems - 4MB size check uses accurate item size calculation; CancellationReasons returned as structured top-level JSON field; ReturnValuesOnConditionCheckFailure returns ALL_OLD item on condition failure
  • UpdateItem rejects empty update expressions; protects key attributes from REMOVE/ADD/DELETE
  • ReturnValues validated against allowed values per operation
  • UnprocessedKeys in BatchGetItem preserves per-table settings
  • SET on list index beyond bounds extends the list with NULL padding (previously returned error)
  • SET on empty list at index 0 now succeeds
  • Projection with list index correctly reconstructs list structure (previously created Map where List was needed)
  • Select validation - invalid Select values and SPECIFIC_ATTRIBUTES without ProjectionExpression rejected
  • ConsistentRead on GSI rejected with correct error message
  • Limit of 0 rejected with constraint error
  • Query/Scan validation ordering matches DynamoDB (input validation before table existence check)
  • Expression attribute usage validated syntactically (at parse time) not semantically (at runtime) - fixes false positives with if_not_exists short-circuiting
  • SerializationException pre-checks for non-list field types with DynamoDB-compatible error format
  • Error type prefix - ValidationException uses com.amazon.coral.validate# prefix matching real DynamoDB
  • BatchExecuteStatement uses short error codes (ResourceNotFound not fully qualified type) and rejects empty Statements array
  • UpdateTable GSI delete returns ResourceNotFoundException for non-existent GSI (previously ValidationException)
  • StreamSpecification included in DescribeTable response
  • Stack overflow protection: 32-level nesting depth limit on item validation (matches DynamoDB)
  • AND/OR short-circuit evaluation in condition expressions

Fixed

  • size() function no longer evaluates on invalid attribute types
  • Idempotency tokens correctly compared in TransactWriteItems
  • PutItem no longer double-reads item for conditional checks
  • GSI sort key replacement handles all edge cases
  • Nested projection preserves document structure (no longer flattens)
  • Double-quote identifier escaping in PartiQL
  • PartiQL DELETE with missing sort key returns proper error
  • PartiQL nested SET paths create correct nested structure (no longer creates literal dot-notation keys)
  • PartiQL SELECT with nested map paths resolves correctly
  • TTL expiry cleans up LSI entries (previously left orphans)
  • GSI/LSI name collision detected and rejected at CreateTable time
  • LSI pagination uses composite cursor to handle duplicate sort key values
  • ExecuteTransaction breaks on first failure (previously continued executing then rolled back)
  • Partition size calculation for ItemCollectionMetrics sums across base table and all LSI tables
  • Error message fidelity improvements across empty string, deletion protection, scan segment, and query validation messages

0.9.4

Added

  • Local Secondary Indexes (LSI) - full lifecycle: creation, query/scan routing, projection types (ALL, KEYS_ONLY, INCLUDE), sparse index behaviour, write path maintenance across all operations including TTL expiry
  • ExecuteTransaction - PartiQL transactional execution with all-or-nothing semantics, condition checks, per-statement cancellation reasons, ConsumedCapacity support
  • Parallel Scan - SQLite-level segment filtering via registered FNV-1a scalar function; validated segment/total parameters
  • CreateTable extensions - SSESpecification, TableClass (validated), Tags (inline), DeletionProtectionEnabled with enforcement on DeleteTable and toggle via UpdateTable
  • PartiQL WHERE clause extensions - BETWEEN, IN, CONTAINS, IS MISSING, IS NOT MISSING, OR, NOT, parenthesised grouping
  • PartiQL nested path projections - SELECT address.city, tags[0] FROM ... with correct nested structure preservation
  • PartiQL REMOVE clause - UPDATE ... REMOVE attribute
  • PartiQL SET expressions - arithmetic (count + 1), list_append, if_not_exists in SET clauses
  • PartiQL IF NOT EXISTS - INSERT ... VALUE {...} IF NOT EXISTS
  • PartiQL set literals - << 'a', 'b', 'c' >> syntax for SS/NS/BS
  • PartiQL COUNT(*) and LIMIT support
  • Item validation - empty string/set rejection, number precision validation (38 significant digits, ±9.99E+125 range), set deduplication (NS by numeric equivalence)
  • Unused expression attribute rejection - unreferenced ExpressionAttributeNames/ExpressionAttributeValues entries return ValidationException
  • ReturnItemCollectionMetrics - partition collection size across base table and all LSI tables
  • Per-GSI ConsumedCapacity - INDEXES mode returns per-GSI breakdown in GlobalSecondaryIndexes map

Changed

  • TrackedExpressionAttributes - unified expression resolution with usage tracking; removed duplicate untracked code paths (~400 LOC reduction)
  • ScanParams / QueryParams structs replace parameter sprawl in storage layer
  • CreateTableMetadata consolidates previously triple-duplicated row mapping
  • GSI/LSI secondary indexes on (base_pk, base_sk) / (table_pk, table_sk) columns - eliminates full table scans during index maintenance
  • Schema v5 migration with automatic secondary index creation on existing tables

0.9.3

Added

  • MCP Server - 33 tools exposing DynamoDB operations for coding agents (Claude Code, Cursor, etc.)
    • stdio and Streamable HTTP transports
    • --read-only, --max-items, --max-size-bytes safety flags
    • bulk_put_items tool for batch loading
    • OneTable --data-model integration with entity-aware agent context and --data-model-summary-limit
    • --mcp flag on dynoxide serve to run MCP alongside HTTP server
    • Snapshots: create_snapshot, restore_snapshot, list_snapshots, delete_snapshot with auto-snapshot before delete_table
    • get_database_info tool with data model context
  • Import CLI - dynoxide import for DynamoDB Export data (JSON Lines format)
    • Anonymisation rules: fake, mask, hash, redact, null actions
    • Cross-table consistency for specified fields
    • zstd compression (--compress)
    • --continue-on-error, --tables filtering, atomic --force overwrite
    • Stream-aware import (reproduces source table's StreamSpecification)
  • CLI restructuring - dynoxide serve, dynoxide mcp, dynoxide import subcommands
  • Database introspection and port conflict detection on startup
  • RUST_LOG debug tracing throughout HTTP and MCP servers

0.9.2

Added

  • SQLCipher encryption - encryption feature (vendored OpenSSL via SQLCipher) and encryption-cc feature (Apple CommonCrypto backend) for encryption at rest
  • Secure key handling via --encryption-key-file or DYNOXIDE_ENCRYPTION_KEY environment variable
  • UpdateTable - StreamSpecification support, GSI create/delete with backfill
  • Tag operations - TagResource, UntagResource, ListTagsOfResource
  • ReturnValuesOnConditionCheckFailure for TransactWriteItems
  • GitHub Action - nubo-db/dynoxide@v1 with optional snapshot-url preloading
  • Homebrew formula - brew install nubo-db/tap/dynoxide
  • Release CI workflow with cross-platform binary builds (Linux x86_64/aarch64/musl, macOS Intel/Apple Silicon, Windows)
  • Private-to-public repo publishing pipeline
  • DynamoDBStreams target prefix - server accepts DynamoDB_20120810.ListStreams and Streams-prefixed actions
  • From/TryFrom conversions for request/response types
  • item! macro for ergonomic item construction in tests
  • Table metadata cache for reduced SQLite round-trips
  • Stripped release binaries

Changed

  • nubo-appnubo-db GitHub organisation rename

0.9.1

Added

  • Server and X-Dynoxide-Version headers on all HTTP responses
  • TableArn, LatestStreamArn, and related ARN fields in API responses
  • Comprehensive benchmarking suite comparing Dynoxide against DynamoDB Local and LocalStack
    • Criterion, iai-callgrind, and custom benchmark binaries
    • CI workflows for regression detection and historical tracking
    • Standard 13-step workload with JVM warmup protocol

Changed

  • http-server feature is now enabled by default
  • Package renamed to dynoxide-rs for crates.io publishing

Fixed

  • Rustdoc warnings
  • README version reference

0.9.0

Added

  • Core DynamoDB emulator backed by SQLite via rusqlite
  • In-memory and persistent database modes
  • Table operations: CreateTable, DeleteTable, DescribeTable, ListTables
  • Item operations: PutItem, GetItem, DeleteItem, UpdateItem
  • Query and Scan with full expression support and pagination
  • Batch operations: BatchGetItem, BatchWriteItem
  • Transactions: TransactWriteItems, TransactGetItems
  • Global Secondary Indexes (GSI)
  • DynamoDB Streams (all four view types)
  • TTL with background sweep
  • Full expression language: KeyCondition, Filter, Condition, Projection, Update
  • PartiQL: ExecuteStatement, BatchExecuteStatement
  • ReturnConsumedCapacity (TOTAL and INDEXES modes)
  • HTTP server (axum-based, DynamoDB JSON wire protocol)
  • 300+ tests