Skip to content

Release notes

Eugene Lazutkin edited this page Jul 17, 2026 · 7 revisions

Release notes

Reverse-chronological. Links to the npm version history and this repo's git tags.

3.8.0 — 2026-07-17

The framework-adapter consolidation plus a broad additive round: cursor pagination end to end, configurable batch retry, and the REST wire gains resumable mass ops, richer results, and a delete-all guard. Additive except one deliberate default flip on the wire (the unscoped-DELETE / guard, below).

Framework adapters in the box

  • dynamodb-toolkit/express · /koa · /fetch · /lambda subpath exports. The four standalone dynamodb-toolkit-{express,koa,fetch,lambda} packages are absorbed as thin ports over one shared neutral-result engine (src/http/engine.js) — identical wire contract across all four and the bundled node:http handler, verified by a shared 30+ case contract suite run through each adapter's own harness. Runtime stays zero-dep: frameworks are duck-typed; express / koa / @types/* are devDeps only. dynamodb-toolkit/lambda/local.js ships createNodeListener / createFetchBridge local-debug bridges. The standalone packages get one final release each as frozen re-export thunks and are then archived.

Pagination

  • Cursor pagination, end to end. New cursorList mass primitive (native LastEvaluatedKey paging — constant cost per page, page-boundary cursors that never skip or duplicate on filtered queries) and adapter.getPage / getPageByParams sharing the whole list pipeline (filter / search / asOf / descriptor hiding / indirect second hop / revive). On the wire: GET /?cursor[=token] returns {data, limit, cursor?}; malformed tokens are 400 BadCursor. Offset paging is unchanged; the new Pagination guide covers the trade-offs and RCU math.
  • NDJSON list output. GET /?format=jsonl streams one item per line, no envelope; in cursor mode the next-page token rides the x-cursor response header. Backed by a new neutral text result type across the engine and all ports.

Batch retry

  • RetryOptions ({backoff?, maxAttempts?}). The hard-coded backoff(50, 20_000) + 8 attempts on BatchWriteItem / BatchGetItem loops is now a policy: pass it via an {options: {retry}} sentinel on applyBatch / getBatch (mirroring applyTransaction's sentinel), as a trailing argument on the mass helpers, or once per Adapter (retry constructor option, per-call options.retry override). A custom finite backoff running dry with work pending throws — no silent partial completion. Transactions are excluded (the SDK's adaptive retry owns those).

REST wire

  • -by-keys routes + keys param are canonical (getByKeys / deleteByKeys naming, mirrored on the wire); the -by-names / names spellings remain as aliases.
  • Unscoped-DELETE / guard — the one default behavior change. A DELETE / with no filter, no search, and an empty exampleFromContext scope is now 400 UnscopedMassDelete unless the request carries ?confirm=true. Restore the old behavior with policy.confirmMassDelete: false.
  • Resumable mass ops on the wire. The mass routes accept ?max-items=N (soft page-boundary cap) and ?resume=<token>; responses carry the full mass-result body — skipped / failed / conflicts / cursor appear only when non-empty, so clean runs keep the historical {processed: N} shape.
  • New rest-core exports: parseCursor (boundary validation for attacker-controlled tokens), parseMassOptions, buildMassResult; RestPolicyOverrides type (partial envelope / statusCodes overrides now typecheck).

Fixes and smaller improvements

  • random() is now uniform across [0-9a-z] (rejection sampling; the base-36 padStart encoding biased even positions toward 07).
  • Mass-op cursors carry a versioned envelope (v: 1); versionless pre-3.8 cursors still decode, unknown versions throw.
  • coerceFilterValue is public — custom filter paths can reuse the declared-type coercion.
  • applyFilter surfaces KCE fallbacks: clauses that lost the one-per-key-component promotion slot are listed on a non-enumerable params.filterFallbacks diagnostic.
  • buildCondition's in op takes the polymorphic value (array), matching the filter-clause shape; the plural values stays as a deprecated alias.
  • paginateList's offset boundary assumption documented (the REST layer clamps; programmatic callers are trusted).

Internal

  • Expression builders share one placeholder allocator (src/expressions/allocator.js) — six hand-rolled copies removed, emitted aliases byte-identical.
  • TypeScript 7 (native tsc) adopted for the type checks; dev deps swept to latest.
  • Tests: 865 Node · 841 Bun · 841 Deno · ts-check / js-check / lint green.

Wiki

3.7.1 — 2026-04-23

Performance fix on the filter compiler plus a substantial documentation refresh. No API-shape changes; drop-in over 3.7.0.

Performance

  • adapter.applyFilter auto-promotes sort-key comparison ops to KeyConditionExpression. lt / le / gt / ge on an indexed sort key now hit the primary index's key-range instead of landing in FilterExpression — range queries like ?gt-createdAt=2020 now benefit from DynamoDB's key-range optimisation (scan only the qualifying range) instead of the full-partition scan + post-filter path 3.7.0 used. Single-clause-per-key-component enforced: a second clause on the same pk/sk falls back to FilterExpression (DynamoDB rejects over-specified KCE). Previously only eq on pk and eq / beg / btw on sk auto-promoted.

Documentation

  • llms.txt / llms-full.txt deep refresh. Full coverage of the 3.7.0 surface — constructor options (structuralKey, indices, typeLabels, typeDiscriminator, typeField, technicalPrefix, filterable, versionField, createdAtField, relationships, descriptorKey), methods (typeOf, buildKey, getListUnder, applyFilter, edit / editListByParams, rename, cloneWithOverwrite, cascade primitives, swapPrefix / overlayFields), filter URL grammar (Option W) with op table, MassOpResult envelope, provisioning + CLI, marshalling usage, OC via versionField, scope-freeze via asOf, all error classes, 3.x milestone history.
  • Wiki refresh. Adapter: Hooks gains a "Built-in steps (composed with your hooks)" section documenting the automatic prepare / revive / prepareKey steps when declarative options are set, plus a "Canned prepare builders" section for stampCreatedAtISO / stampCreatedAtEpoch. Concepts rewritten with eleven new declarative-schema sections (Structural key, Type tags, Filter URL grammar, Optimistic concurrency, Scope-freeze, Cascade, Mass-op envelope, Descriptor record, Marshalling pairs, Declarative schema umbrella, technicalPrefix and managed fields). New Key and field design orientation guide covers pk/sk selection, LSI strategy, GSI patterns (all / keys-only / INCLUDE), and a 13-row technical-fields inventory. Four new recipes: Reservation with auto-release (OC + scope-freeze + resumable sweep), Keys-only GSI with runtime projection (indirect-index pattern with cost tables), Cascade subtree operations (deleteAllUnder / cloneAllUnder[By] / moveAllUnder[By] with partial-failure handling), Querying subtrees with buildKey (children / {self: true} / {partial: 'Dal'} shapes). New Recipes landing page with problem-first navigation.
  • src/handler/handler.d.ts — stale keyFromPath doc comment corrected (adapter.keyFields[0]adapter.keyFields[0].name to match the 3.2.0 KeyFieldSpec normalisation).
  • AGENTS.md + AI rule files refreshed for the 3.2–3.7 surface (declarative schema, sub-modules, built-in steps, error hierarchy, CLI). Rule files re-synced to byte-identical mirrors.

Tests

554 Node · 548 Bun · 548 Deno · 45 e2e against DynamoDB Local · ts-test 7/19 · npm audit clean · ts-check / js-check / lint green.

3.7.0 — 2026-04-23

Design round driven by the post-3.6.1 ergonomics review on the car-rental example. Breaking — no compat shims. Consolidates the naming, simplifies the most-used surfaces, and fills several rough edges (typed dispatch on every tier, bulk-load versioning, descriptor hiding). See dev-docs/car-rental-feedback.md for the full decision log.

Breaking changes

  • Filter grammar flipped. URL: ?<op>-<field>=<value> (e.g. ?eq-kind=car&gt-dailyPriceCents=5000). The f- prefix is gone; parser gates on a closed op-prefix set (eq|ne|lt|le|gt|ge|in|btw|beg|ct|ex|nx). Left-anchored split, so dashed field names fall out naturally (?eq-rental-name=Dallas → field rental-name).
  • Option renames. options.fFilteroptions.filter (structured clauses). Old options.filter (free-form search string) → options.search. Underlying identifiers renamed too: adapter.applyFFilterapplyFilter; parseFFilterparseFilter; old parseFilterparseSearch; buildFilterbuildSearch in the expressions subpath. Filenames follow the exports (parse-filter.*, parse-search.*, search.*).
  • Polymorphic filter-clause shape. No more values: [...] array on every clause; the clause is discriminated by op:
    • no-value ops (ex, nx) — {field, op} (no value)
    • multi-value ops (in, btw) — {field, op, value: [...]}
    • single-value ops — {field, op, value: <scalar>}
  • ensureTable split into two methods. planTable(adapter) is read-only and returns the plan; ensureTable(adapter) computes the plan, applies it, writes the descriptor, returns {plan, executed, descriptorWritten?}. No more {yes} / {dryRun} flags. CLI mirrors: dynamodb-toolkit plan-table <module> and dynamodb-toolkit ensure-table <module>.
  • adapter.buildKey simplified. Default is children (begins_with(_sk, 'base|')); no kind argument. {partial: 'abc'} narrows to items whose next tier starts with abc. {self: true} is new — includes the row at the supplied key plus descendants, via begins_with on base without the trailing separator. kind: 'exact' removed (use getByKey for single-record reads).
  • putItems (native) now initializes versionField. Bulk-loaded items get _version: 1 on first write, matching post / put behaviour. BatchWriteItem doesn't support per-item conditions, so optimistic-concurrency enforcement still requires the per-item path — use put or {strategy: 'sequential'} if OC is load-bearing.
  • Descriptor record hidden from list-op Scans. When descriptorKey is declared, getListByParams / deleteListByParams / etc. inject <pk> <> :descriptorKey into the FilterExpression on Scans (Queries already exclude it via pk condition). Opt out with {includeDescriptor: true} for introspection tools.

Additions

  • typeField — auto-populate the type discriminator. New Adapter option. Built-in prepare step writes adapter.typeOf(item) to the named field on every full-write insert when the field is absent. Pair with typeDiscriminator pointing at the same field — typeOf reads back what the built-in wrote. Enables the Pattern-1 sparse-GSI recipe (indices: {'by-kind': {type: 'gsi', pk: 'kind', ...}}) without a custom prepare hook.
  • adapter.getListUnder(partialKey, options) — shorthand for getListByParams(buildKey(partialKey), options). The common children-only case; compose buildKey + getListByParams directly for {self} / {partial} shapes.
  • filterable accepts {ops, type?} shape. E.g. filterable: {year: {ops: ['eq', 'ge', 'le'], type: 'number'}}. Lets non-keyField fields declare their coercion type explicitly; the old [ops] array shape still works.
  • stampCreatedAtISO() / stampCreatedAtEpoch() — canned prepare hook builders exported from the root. Replace the 4-line boilerplate every asOf-using adapter was copying. Generic in TItem, so they flow through typed Adapter hooks.
  • Shorthand for declaration knobs. String forms for structuralKey: '_sk', typeDiscriminator: 'kind', and index pk / sk (pk: 'status'pk: {name: 'status', type: 'string'}). Matches the keyFields string shorthand. Full descriptor form still accepted.
  • Additive-presence API principles pinned across the round — see dev-docs/car-rental-feedback.md §Design principles. "Split methods when complex interactions are unavoidable" is the corollary behind the planTable / ensureTable split.

Example refresh

  • examples/car-rental/ — every tier (state, facility, car, boat) now carries real data; putItems bulk-loads the whole hierarchy; marshalling wired through the adapter's prepare / revive hooks; new §LSI / §GSI sections.
  • examples/car-rental/ts/ — typed mirror (adapter.ts, seed-data.ts, run.ts). Runs natively on Node 25 (node examples/car-rental/ts/run.ts; no flag, no tsx). Strict discriminated-union record type; surfaces the remaining TS rough edges (record types must extends Record<string, unknown>; two as AnyRecord casts for union-narrowing-through-spread). Everything else flows clean.

Tests

551 Node · 545 Bun · 545 Deno · 45 e2e against DynamoDB Local · npm audit clean · ts-check / js-check green.

3.6.1 — 2026-04-22

Bug fixes surfaced by the post-implementation ergonomics review against a realistic hierarchical use case (runnable example at examples/car-rental/). All three fixes restore documented intent; no API-shape changes.

Bug fixes

  • adapter.buildKey now emits the partition-key equality on composite keys. Previously the helper returned only the sort-key condition (begins_with(#sk, :v) or #sk = :v), so every resulting Query failed with ValidationException: Query condition missed key schema element. Now emits #pk = :pkv AND (…sk condition…) via the primitive's existing pkName / pkValue knobs. Existing buildKey tests updated to assert the corrected shape.
  • adapter.patch + versionField now conditions on existence by default. Calling patch(key, partial) without {expectedVersion} previously emitted attribute_not_exists(<pk>) — the wrong semantic for patch (fails on every existing record). Now emits attribute_exists(<pk>) unless expectedVersion is supplied, matching patch's intended "update-if-exists" contract. The version still ADDs +1 via UpdateExpression regardless. Version-conditioned patches ({expectedVersion}) are unchanged.
  • getListByParams now honors options.fFilter / options.filter / options.asOf. Callers who hand-built query params (e.g., via buildKey) and passed them to getListByParams(params, options) found these three options silently dropped — only getList(options, example, index) honored them via _buildListParams. Moved option-to-params translation into getListByParams so both entry points funnel through one application point. getList remains a thin wrapper.

Additions

  • examples/car-rental/ — runnable end-to-end walkthrough against DynamoDB Local exercising the full 3.6.0 surface (typed declaration, buildKey, f- filter grammar, mass ops with cursor resume, cascade primitives, optimistic concurrency, scope-freeze, typeOf dispatch for multi-type-same-tier, ensureTable / verifyTable, descriptor round-trip). Serves as integration test + documentation-adjacent template. See the Ergonomics review note.

Tests

535 Node · 529 Bun · 529 Deno · npm audit clean. Typed + JS checks pass.

3.6.0 — 2026-04-22

Consolidated publish of the hierarchical-use-case workstream (3.2 → 3.6). Tags 3.2.0, 3.3.0, 3.4.0, 3.5.0 exist locally for history; 3.6.0 is the first npm publish since 3.1.2. No load-bearing breaking changes — renamed bulk-individual helpers ship alongside deprecated aliases. The sections below cover what landed in each tag, newest first.

3.6.0 — T1 / T2 provisioning

New subpath dynamodb-toolkit/provisioning and CLI bin/dynamodb-toolkit. Opt-in; IaC-managed deployments ignore it entirely.

  • ensureTable(adapter, {yes?, dryRun?}) — ADD-only planner. Returns a plan by default ({tableName, steps, summary[]}); {yes: true} executes. Plan steps: create (CreateTable), add-gsi (UpdateTable with a single {Create}), skip-extra-gsi/skip-extra-lsi (reported, never dropped), skip-missing-lsi (surfaced with a note — LSIs can only be declared at CreateTable). Never emits destructive operations. Delegates declaration legality to DynamoDB.
  • verifyTable(adapter, {throwOnMismatch?, requireDescriptor?}) — structured diff. Compares base KeySchema, AttributeDefinitions, GSI/LSI key schemas + projections. Billing mode / stream config compared only when declared on the adapter. Extras in the live table are warn (non-blocking); declared-but-missing are error. {throwOnMismatch: true} throws TableVerificationFailed carrying the same diffs.
  • Opt-in descriptor record via options.descriptorKey on the Adapter (typical value '__adapter__'). ensureTable writes a JSON snapshot under __toolkit_descriptor__ on the reserved-key record; verifyTable reads it back and flags drift that DescribeTable can't see (marshalling fields, searchable mirrors, filterable allowlist, versionField/createdAtField names). Absent descriptor is neutral by default; {requireDescriptor: true} opts into "this table must have been toolkit-provisioned" strictness.
  • CLIdynamodb-toolkit ensure-table <adapter-module> / verify-table <adapter-module>. Loads the module via dynamic ESM import and accepts default or named adapter export. Flags --yes, --strict, --require-descriptor, --json, -h/--help.
  • New error: TableVerificationFailed (root re-export).

Tests: unit against a mock client + e2e against DynamoDB Local (Docker, skipped when unavailable).

3.5.0 — Cascade + A6' relationship declaration

Developer-primitive cascade for hierarchical deletes / clones / moves. Relationship gate is explicit — the toolkit does not infer cascade scope from composite keyFields alone.

  • options.relationships: {structural: true} on Adapter construction. Validated (requires keyFields.length > 1 + structuralKey declared). Forward-compatible shape for future cross-adapter / via-GSI kinds.
  • Five cascade primitives — two intentional styles per op (no options-bag overload):
    • deleteAllUnder(srcKey, options) — leaf-first descendant delete + ifExists self-delete (absent → skipped).
    • cloneAllUnder(srcKey, dstKey, options) — root-first prefix-swap subtree clone. options.mapFn composes after the swap via mergeMapFn.
    • cloneAllUnderBy(srcKey, mapFn, options) — mapFn-driven subtree clone; destinations as mapFn dictates (fan-out across different subtrees).
    • moveAllUnder(srcKey, dstKey, options) / moveAllUnderBy(srcKey, mapFn, options) — two-phase idempotent copy+delete (shared _subtreeRename with rename).
  • Throws CascadeNotDeclared when cascade primitives are called without relationships.structural.
  • Default REST handler unchanged. DELETE /key stays single-row; developers wire cascade endpoints themselves by calling adapter.deleteAllUnder(key) from their handler.
  • Resumability: maxItems / resumeToken flow through the descendants phase; self-op runs only when pagination completes (and is skipped on resumeToken for cloneAllUnder* to prevent re-processing).

3.4.0 — Concurrency + Marshalling

Optimistic-concurrency pair (versionField + asOf) and the new /marshalling subpath.

  • options.versionField — opt-in OC. Requires technicalPrefix. Universal write condition attribute_not_exists(<pk>) OR <versionField> = :observed; auto-increment on every write; delete accepts {expectedVersion}; mass editListByParams routes ConditionalCheckFailed to a dedicated conflicts: [{key, reason: 'VersionConflict', sdkError}] bucket. Preserved across revive so callers round-trip versions naturally.
  • options.createdAtField + {asOf} mass-op option — scope-freeze. Mass ops accept {asOf: Date | string | number}; the toolkit AND-merges <createdAtField> <= :asOf into FilterExpression. Requires the adapter to opt in — throws CreatedAtFieldNotDeclared otherwise. No auto-write; the user's prepare hook owns the format.
  • dynamodb-toolkit/marshalling — standalone helpers in the SDK marshall/unmarshall naming family:
    • marshallDateISO / marshallDateEpoch — no generic marshallDate alias (forces ISO-vs-epoch intent at the call site).
    • marshallMap(map, valueTransform?) — optional nested-value transform.
    • marshallURLURL instance round-trip.
    • Marshaller<TRuntime, TStored> type pair with concrete dateISO, dateEpoch, url pairs.
  • New errors: CreatedAtFieldNotDeclared.

3.3.0 — Mass-op resumability, edit, subtree macros

Cursor-based resumability for list mass ops, plus the edit primitive and two subtree macros.

  • encodeCursor / decodeCursor in dynamodb-toolkit/mass (base64url; Node Buffer fast path + TextEncoder/btoa fallback for Bun/Deno).
  • MassOpOptions (maxItems, resumeToken, ifNotExists, ifExists) + MassOpResult ({processed, skipped, failed, conflicts, cursor?}).
  • Resumable list opsdeleteListByParams / cloneListByParams / moveListByParams return MassOpResult; page-boundary cursor emission; ifNotExists/ifExists on clone switch from BatchWriteItem to per-item PutItem with ConditionExpression.
  • adapter.edit(key, mapFn, {readFields?}) — read → shallow-diff → UpdateCommand. Throws KeyFieldChanged on keyField mutation; {allowKeyChange: true} opts into auto-promotion to a move. No-op short-circuit suppresses WCU on empty diffs.
  • adapter.editListByParams — resumable per-item edit across a scan.
  • adapter.rename(from, to, options) — put-if-not-exists (dst) then delete (src); two idempotent phases.
  • adapter.cloneWithOverwrite(from, to, options) — delete (dst) then put (dst); source stays intact.
  • Canned mapFn buildersswapPrefix(src, dst), mergeMapFn(...fns) for composing transforms.
  • GIGO cleanup: removed AmbiguousDestination, removed typeof mapFn !== 'function' guards, removed ifNotExists + ifExists mutual-exclusion check. TS types are the contract.
  • New errors: KeyFieldChanged.

3.2.0 — Foundation: declarative schema, A1', filter grammar, naming cleanup

The largest release of the workstream. Makes hierarchical adapters declarative, ships the read-side key-condition helpers, lands the filter grammar, cleans up misleading "List" names.

Declarative schema

  • options.technicalPrefix (opt-in) — namespaces adapter-managed fields. Prepare rejects incoming user items touching the prefix; revive strips prefixed fields before the user's hook sees them.
  • options.keyFields accepts full descriptors: {name, type?: 'string' | 'number' | 'binary', width?: number}. width required on {type: 'number'} in composite keys (zero-padding).
  • options.structuralKey — required for composite keyFields. Shorthand '_sk' or {name, separator?} (defaults '|').
  • options.indices — single discriminated map {<name>: {type: 'gsi' | 'lsi', pk?, sk?, projection?, sparse?, indirect?}}. Legacy indirectIndices: {name: 1} entries synthesise into minimal {type: 'gsi', indirect: true, projection: 'keys-only'}.
  • options.typeLabels (paired 1:1 with keyFields) + options.typeDiscriminator — power adapter.typeOf(item) (type detection by discriminator field → structural depth → raw depth).
  • Built-in prepare / revive steps — structural-key formation on prepare (contiguous-from-start + zero-padding), stripping on revive. User hooks still compose after built-ins.

A1' — read-side key-condition helpers

  • buildKeyCondition(input, params) primitive in dynamodb-toolkit/expressions. Follows the build<Target> convention + counter-based placeholder naming.
  • adapter.buildKey(values, options, params) — ergonomic Adapter method. values is an object keyed by keyFields names (validated contiguous-from-start). options.kind: 'exact' | 'children' | 'partial'; options.partial appends after the separator. Merges params exactly like buildCondition.

Filter grammar

  • f-<field>-<op>=<value> grammar replaces the v2 prefix/pair conventions. Single prefix f-, no flt-.
  • Op vocabulary: eq, ne, lt, le, gt, ge, in, btw, beg, ct, ex, nx. SQL-standard ge/le (not Django's gte/lte).
  • options.filterable: {<field>: [ops]} — allowlist on the Adapter. Unlisted field → BadFilterField; disallowed op → BadFilterOp.
  • Multi-value via first-character delimiter with , fallback (f-cost-in=,1,2,3 or f-cost-in=|1|2|3).
  • ?prefix= absorbed into f-<sk>-beg=… and removed. One grammar.

Naming cleanup (with deprecated aliases)

Triggered by the design-principle audit that distinguished bulk-individual helpers (plural of single-op; caller supplies the set) from list operations (toolkit produces the list).

  • readListByKeys + readOrderedListByKeysreadByKeys (always ordered, length-preserving — undefined at missing positions per the D2 fix).
  • deleteListByKeysdeleteByKeys.
  • writeListwriteItems.
  • Old names remain as deprecated aliases with a one-time console.warn; removed in a future minor.
  • File renames: read-ordered-list-by-keys.jsread-by-keys.js (callers switch); delete-list-by-keys.jsdelete-by-keys.js.

D2 — length-preserving bulk reads

getByKeys and the wire -by-names endpoint no longer silently drop missing items. Result array is length-aligned with the input keys: result[i] === undefined at missing positions. Callers who want a compact array call .filter(Boolean) themselves.

New errors

NoIndexForSortField, ConsistentReadOnGSIRejected, BadFilterField, BadFilterOp (root re-exports). ToolkitError base class for instanceof discrimination.

Standing rules pinned this release

  • Thin SDK-helper — consumes/produces SDK types, same semantics; introduces toolkit concepts only when strictly needed.
  • No client-side list manipulation — don't sort/filter/reshape lists post-read. Refuse with a clear error when the DB can't answer natively. ?sort=<field> without a matching index now throws NoIndexForSortField instead of in-memory sorting.
  • User callbacks throw; toolkit does not wrap — every caller-supplied extension point (hooks, mapFn, sparse.onlyWhen predicate, exampleFromContext, valueTransform) propagates errors unchanged. Caller's error class / message / stack surfaces intact.
  • GIGO: no runtime type-checking of args — TS types are the contract; no typeof !== 'function' guards on mapFn args, etc.

3.1.2 — 2026-04-20

Rest-core extractions + streaming body reader. Purely additive; no breaking changes. Framework adapters (koa / express / fetch / lambda) adopted these extractions at their 0.2.0 release the same day.

Rest-core extractions (Tier-A)

Pulled out of the dynamodb-toolkit/handler + dynamodb-toolkit/rest-core internals so framework adapters can share them:

  • buildListOptions(query) — constructs the ListOptions bag from parsed query state (offset/limit/sort/filter/fields).
  • resolveSort(sortField, options) — maps ?sort=<field> to the resolved index / descending flag.
  • stripMount(path, mountPath) — trailing-slash-safe mount-point stripping.
  • coerceStringQuery(raw) — null-prototype accumulator coercing multi-value query shapes to a flat {k: string | string[]} map.
  • validateWriteBody(body) — gates POST / / PUT /:key / PATCH /:key on object-shaped bodies. Returns 400 BadBody on null, arrays, or primitives. Previously those bodies were silently spread ({...null, ...key} → key-only row).

Handler

  • readJsonBody(req, {maxBodyBytes, destroy?}) — streaming TextDecoder per chunk; peak memory ≈ 1× body size (was 3× with the Buffer-concat path). Partial UTF-8 codepoints across chunk boundaries handled by the streaming decoder.
  • matchRoute HEAD → GET auto-promoteHEAD /key now matches the GET /key route (unless a dedicated HEAD handler is registered).
  • Body-always-parsed invariantPUT /-clone / PUT /-move always receive the parsed body in exampleFromContext, even when the body is empty.
  • Byte-accurate maxBodyBytes — limit applied to raw bytes, not character count.

Security (continued from 3.1.1)

Three CodeQL alerts cleared (workflow permissions, two ReDoS candidates).

3.1.1 — 2026-04-19

Security + correctness hardening sweep. Full module-by-module audit — 19 bug fixes + 9 defensive improvements. All changes are either bug fixes restoring the documented / intended contract, or additive opt-in options with sensible defaults — so this is a patch release.

Security

  • Prototype pollution guards (4 vectors closed):
    • paths/getPath no longer reads through the prototype chain. getPath({}, 'toString') now returns defaultValue instead of Function.prototype.toString.
    • paths/setPath / paths/deletePath refuse to walk or write through __proto__ / constructor / prototype segments.
    • rest-core/parsePatch uses a null-prototype accumulator — a PATCH body containing "__proto__" no longer reassigns the patch's prototype.
    • mass/readOrderedListByKeys uses Object.create(null) accumulators — a record with partition-key value '__proto__' no longer pollutes Object.prototype globally.
  • DoS-on-self vectors closed (3):
    • batchWrite / batchGet cap at 8 retry attempts (was infinite loop on persistent throttling).
    • handler rejects bodies larger than 1 MB with HTTP 413 PayloadTooLarge (was unbounded memory accumulation).
    • parsePaging caps ?offset= at 100 000 by default (was unbounded — ?offset=1e15 could DoS via astronomical skip-page calls).
  • AWS-aligned retry backoff: backoff() default per-retry cap is now 20 s (was undocumented 60 s with infinite retries). Matches AWS SDK v3 default and AWS DynamoDB retry guidance ("stop around one minute"). With MAX_ATTEMPTS = 8, worst-case total wait is ~43 s.

Correctness

  • Adapter.moveByKeys and mass/moveList now pair put + delete per item. Previously, a falsy mapFn return could drop the put but leave the delete in flight — silent data loss. Now both legs drop together.
  • Adapter constructor rejects non-array keyFields. new Adapter({..., keyFields: 'name'}) previously silently produced broken queries (attribute alias 'n'); now throws.
  • applyBatch / applyTransaction throw on unknown action values (were silently dropped — caused phantom "processed" counts and missing writes).
  • buildUpdate throws on unknown arrayOp.op values (typos like 'increment' were silently dropped, producing partial UpdateExpressions).
  • buildUpdate guards options.delete and options.arrayOps with Array.isArray (string input no longer iterates characters).
  • applyBatch total counts only items actually added to the batch (was inflating the count with dropped items).
  • normalizeFields(',,,') now returns null instead of [] (restores the "no fields requested → project everything" contract).
  • applyPatch(o, null) returns o unchanged instead of crashing on Object.keys(null).
  • applyPatch options.delete now gated with Array.isArray.
  • cloneParams(null) returns a fresh clone instead of crashing.
  • handler strips leading // from req.url so GET //evil.com/path cannot pivot the URL's origin; sanitizes the Host header to prevent crashes on malformed headers.
  • handler 405 error body no longer echoes url.pathname (minor information disclosure removed).

Additive options (opt-in, sensible defaults)

  • createHandler(adapter, {maxBodyBytes}) — HTTP body-size cap. Default 1 MB.
  • parseFields(input, {maxItems}) — output-length cap. Default 1000.
  • parseNames(input, {maxItems}) — output-length cap. Default 1000.
  • parseFilter(input, {maxLength}) — query-string length cap. Default 1024.
  • parsePaging(input, {maxOffset}) — offset ceiling. Default 100 000.
  • defaultPolicy.maxOffset added (same default).

Behavior changes to be aware of (all fix footguns)

Change Before After
backoff() default cap undocumented 60 s 20 s
batchWrite / batchGet attempts infinite retry 8 (throws after)
Unknown action in applyBatch / applyTransaction silent drop throws
Unknown arrayOp.op in buildUpdate silent drop throws
Adapter with non-array keyFields silently accepted (broken) throws
Handler request body > 1 MB unbounded 413 PayloadTooLarge
?offset= on list routes unbounded capped at 100 000
getPath({}, 'toString') Function.prototype.toString defaultValue

See the per-page wiki updates (REST core, HTTP handler, Batch and transactions, Mass operations) for the detailed surface.

3.1.0 — 2026-04-18

Additive features. No breaking changes.

  • returnFailedItem option on post / put / patch / delete and their make* batch builders. Sets ReturnValuesOnConditionCheckFailure: 'ALL_OLD'; the thrown ConditionalCheckFailedException carries the colliding item on its Item field (or the matching CancellationReason.Item inside a transaction).
  • {options: ...} sentinel for applyTransaction — position-independent descriptor carrying transaction-level knobs: clientRequestToken (idempotency), returnConsumedCapacity, returnItemCollectionMetrics.
  • explainTransactionCancellation(err, ...descriptors) in /batch. Maps TransactionCanceledException.CancellationReasons back to the input descriptors and returns {failures, message} | null. Paired with returnFailedItem to surface the colliding item in one call.
  • Cross-runtime test matrix — Node, Deno, Bun (npm test, npm run test:deno, npm run test:bun).
  • CJS smoke test + TypeScript smoke test wired into the suite.
  • LICENSE file shipped (BSD-3-Clause). llms.txt + llms-full.txt included in the npm tarball.
  • CI lint step re-enabled (.github/dependabot.yml in .prettierignore).
  • Three CodeQL alerts cleared (workflow permissions, two ReDoS candidates).

Typed .d.ts sidecars updated throughout. See Adapter: CRUD methods, Batch and transactions, and Compatibility for the detailed coverage.

3.0.0 — 2026-04-16

Green-field rewrite on AWS SDK v3. Not a drop-in upgrade from v2. v2 consumers stay on dynamodb-toolkit@2.3.0. The v3 line is the actively-developed branch.

Highlights:

  • AWS SDK v3 (@aws-sdk/client-dynamodb + @aws-sdk/lib-dynamodb) peer deps, replacing v2's aws-sdk.
  • ESM-only, "type": "module", hand-written .d.ts sidecars next to every .js. No build step.
  • One data format — plain JS via lib-dynamodb middleware. The v2 Raw / DbRaw pair collapsed into a single Raw<T> bypass marker.
  • Options bags everywhere: put(item, {force: true}) instead of put(item, true); patch(key, patch, {delete, separator, arrayOps, conditions}).
  • Hooks renamed: prepareListParamsprepareListInput, updateParamsupdateInput. Hooks bag (options.hooks) is the canonical extension point.
  • Patch wire format: _delete / _separator (single underscore); configurable via policy.metaPrefix.
  • REST split: dynamodb-toolkit/rest-core is framework-agnostic; dynamodb-toolkit/handler is the node:http adapter. Koa / Express / Hono / Fetch / Lambda adapters live in separate packages.
  • Transaction auto-upgrade: single-op CRUD automatically upgrades to TransactWriteItems when checkConsistency returns extra descriptors.
  • Indirect-index second-hop for sparse GSIs with key-only projection.
  • Dropped: makeClient, getProfileName, specialTypes, converter, converterOptions — use @aws-sdk/credential-providers directly.

See Migration: v2 to v3 and SDK v2 to v3 cheat sheet.

v2.x and earlier

The v2 documentation snapshot lives at the v2.3-docs git tag of this wiki repo. The v2 source code remains on npm as dynamodb-toolkit@2.3.0 and on GitHub at the matching git tag.

Clone this wiki locally