## 3.5.1 - 2026-10-09

### Fixed

- **An `IN` list written inline stopped narrowing a query's results by `type`.** `filter: { type: "PaymentMethod", lastFour: ["1234"] }` typed its results as every entity in the partition rather than `PaymentMethod`, and a `readonly` array such as `["1234"] as const` was rejected with "No overload matches this call". Both worked through 3.2.0 and regressed in 3.3.0, when the filter value types stopped using the SDK's `any`: the query overloads infer their filter as a `const` generic, which reads an inline array literal as a readonly tuple, and the `IN` list was typed as a mutable array that a readonly tuple is not assignable to. The 3.3.0 to 3.5.0 entries did not list this, and 3.5.0 describes narrowing as unchanged; it was not intended.

  An `IN` list now accepts a `readonly` array, so an inline literal of one or more elements, a readonly tuple and a mutable array all compile and narrow by `type`. This covers every surface that takes filter conditions: a query filter, a filter beside `skCondition`, the key-conditions overload, and write conditions. The excess-property check on filter keys is unchanged, and `$between` and `$or` were not affected. A scalar value, an operator object and a `type`-only filter always narrowed.

  The fix is type-level only; the expressions sent to DynamoDB are unchanged. It widens what compiles, so no existing code needs an edit.

## 3.5.0 - 2026-10-08

> **Versioning policy.** From this release on, runtime behavior and the public API follow semantic versioning under a written policy. Type-level changes ship in minor releases, listed under "Breaking (type-level only)", when they only reject code that could not behave as written at runtime (it was silently ignored, matched nothing, or failed, in dyna-record or at DynamoDB), or when they widen a type so that only code assigning it to a narrower annotation needs an edit. A change that makes correct code stop compiling for any other reason ships in a major, as 2.0.0 did. Both type-level items in this release are of those kinds. See [Versioning](README.md#versioning).

### Added

- **Write conditions.** `update` (static and instance), `delete`, `create`, and a join table's `create` and `delete` accept a `condition` option. The write happens only if the condition holds, and DynamoDB checks it in the same transaction as the write, so the check and the write are one atomic step. Before this, a guarded write meant reading, deciding and then writing, where two processes could read the same state and both write; or dropping to the raw SDK, whose writes skip the denormalized copies and foreign key links dyna-record maintains.

  ```typescript
  // Cancel an Order only while it is still pending
  await Order.update(
    "order-1",
    { status: "cancelled" },
    { condition: { status: "pending" } }
  );
  ```

  A condition only decides whether the write happens. It never changes which items are written, it is never checked against an earlier read, and when it fails nothing is written: not the entity, not its copies, not its links. A write without a `condition` sends exactly the commands it did before.

- **A condition can guard the entity's own row and the rows around it.** One object holds three kinds of key, mirroring the entity's own shape:

  - **Its own attributes**, in the full query-filter vocabulary: equality, `IN`, `$beginsWith`, `$contains`, the comparison operators, `$between`, `$or` and dot paths, typed and validated as filters are. In a write condition `null` on a nullable attribute means "not set", since dyna-record removes a nulled attribute rather than storing it. An `undefined` operand is a `FilterError` rather than a dropped condition, because dropping part of a guard would loosen it. So are an empty `$or` and a `$or` branch that holds no conditions (`{ $or: [{}, { status: "pending" }] }`). An empty branch always holds: kept, it would make the whole `$or` check nothing, and dropped, as a query filter drops it, it would tighten the guard to the other branches. Query filters still drop both.
  - **Its relationships**, by property name. A BelongsTo or HasOne takes a condition on the related row, which the library finds. A HasMany or HasAndBelongsToMany takes `{ id, condition }` entries, and the same transaction verifies that each id is actually related.
  - **Foreign keys with no relationship property** — a join table's keys, the child side of a uni-directional HasMany, a standalone foreign key — guard the row they reference through a `target` wrapper.

  ```typescript
  await Order.update(
    "order-1",
    { status: "shipped", trackingNumber: "1Z999" },
    {
      condition: {
        trackingNumber: null,
        customer: { status: "active" },
        storeId: { target: { status: "open" } }
      }
    }
  );

  await CustomerStore.create(
    { customerId: "customer-1", storeId: "store-1" },
    { condition: { storeId: { target: { status: "open" } } } }
  );
  ```

  `$or` applies within one row and never across rows: DynamoDB evaluates a condition against exactly one item, so a relationship inside `$or` is a compile error. `create` takes no condition on the new row's own attributes, which must not exist yet, and guards only its BelongsTo relationships and typed foreign keys. `target` guards and create's relationship guards need the foreign key declared with its target type, `ForeignKey<Target>`; a bare `ForeignKey` keeps compiling everywhere else. A `FilterError` from the condition on a related row names where that condition sits, the key and a HasMany or HasAndBelongsToMany entry's id (`Invalid write condition for "stores" entry "store-1": ...`), so the failing part of a condition that guards several rows can be found; the error the condition itself raised is its `cause`.

- **`WriteConditionFailedError`**, a subclass of `ConditionalCheckFailedError`, delivered inside the usual `TransactionWriteFailedError` so existing catch blocks keep working. Its `entity`, `id` and `guards` fields name the write and every guard on the row whose check failed. A missing row, a failed referential-integrity check, a relationship changed by a concurrent write, and an id that is not related stay plain `ConditionalCheckFailedError`s with their own messages, so a lost race can be told apart from a broken one with `instanceof`.

- **New exported types:** `WriteCondition`, `CreateCondition`, `TargetCondition`, `RelatedEntityCondition`, `ForeignKeyTargetGuard`, `JoinTableCondition`, the options types `CreateOperationOptions`, `UpdateOperationOptions`, `DeleteOperationOptions`, `JoinTableCreateOptions` and `JoinTableDeleteOptions`, and the error surfaces `UntypedForeignKeyTargetError`, `BelongsToForeignKeyTargetError` and `CreateRelationshipConditionError`, which name the fix when a guard is written where it cannot apply. `delete` and `JoinTable.delete` gain an optional options argument to carry the condition.

### Breaking (type-level only)

- **`ConditionalCheckFailedError.code` is typed as `"ConditionalCheckFailedError" | "WriteConditionFailedError"`.** It was the single literal, which left no room for the subclass's own code. An instance of the base class still carries `"ConditionalCheckFailedError"`, and equality checks on `code` and `instanceof` behave as before. Only code that assigns `code` to a variable typed as the single literal notices.

- **A typed `query` filter refuses an undeclared field in an object operand.** `Entity.query(...)` infers its filter so the results can be narrowed by `type`, and that inferred type made any field written in an object operand acceptable: `{ "address.geo": { lat: 1, lng: 2, alt: 3 } }`, an undeclared field inside a top-level object operand, the same in a partition of several entities, a whole `$contains` element of a list of objects, and the key-conditions overload all compiled, as did a key no partition entity declares when written beside one it does (`{ location, region: "west" }`). Written as a literal (directly, in either branch of a conditional expression, or checked with `satisfies`), they are now compile errors, as they already were in write conditions. Narrowing by `type`, by filter keys and by `$or` blocks is unchanged. A filter typed `TypedFilterParams<T>` or `FilterParams` (a parameter, a property, an annotated variable) is accepted as before.

### Fixed

- **A range on an enum attribute takes any two of its members as bounds.** `$between` and composed comparisons were typed one member at a time, so both bounds had to be the same member: `{ $between: ["bronze", "gold"] }` and `{ $gte: "bronze", $lte: "gold" }` failed to compile, while `{ $between: ["gold", "gold"] }` compiled. An enum is stored as a string, which DynamoDB orders lexicographically, so a range across any two members is valid, and the expression builder already compiled it. This applies to filters, index key conditions and nested enum fields. A bound outside the enum, a number, `null` and a boolean are still rejected, and ranges on every other attribute kind are unchanged.

- **Deleting an entity that is its own parent deletes everything it wrote.** An entity in a uni-directional HasMany with itself, whose foreign key holds its own id, has a link copy in its own partition alongside its own row. `delete` took the copy for the entity itself, so it sent the copy's delete twice and left the entity's own row behind. The entity's row is now identified by its sort key, and the row and the copy are each deleted once.

- **A filter whose `$or` has one block no longer fails at DynamoDB.** When that block binds several values (two conditions, a `$between`, or a composed range) and the filter has other conditions beside the `$or`, the block was wrapped in a second pair of parentheses, `((#Name = :Name1 AND #Category = :Category2)) AND (#Price = :Price3)`, which DynamoDB rejects with "The expression has redundant parentheses". Such a block is now grouped once. A filter that is only the `$or` was already accepted and is unchanged.

- **A dot path naming nothing the schema declares is a `FilterError`.** A path naming a field its object does not declare (`"address.nope"`, `"entries[0].nope"`), or continuing below a field that is not an object (`"name.x"`, `"address.zip.nope"`), was compiled unjudged: a filter returned no rows, and a write condition failed as a `WriteConditionFailedError` indistinguishable from a real guard failure. Both are now rejected before anything is sent, in filters and write conditions alike, naming the path. The types already refused them. A path into a discriminated union variant is still left unjudged, because its fields depend on the variant.

- **`$contains` on a list checks its operand as one of the list's elements.** `{ "details.tags": { $contains: 1 } }` on a list of strings was sent and matched nothing. On a list the operand is one whole element, so it is now validated against the element's schema and converted to the form the element is stored in, as the types already described it, and a value no element can hold is a `FilterError`:

  - The operand must be stored in the form the elements are (a string for a list of strings and enums, a number for a list of numbers, an object for a list of objects).
  - On a list of enums it must be a member. A string outside the enum was sent and matched nothing.
  - On a list of objects each field must have its declared type, and a `Date` field is converted to its ISO string at any depth. A field of the wrong type was sent and matched nothing, and a `Date` field failed when the request was sent. A field the element does not declare is rejected rather than dropped, since DynamoDB compares the elements whole and no element could equal the operand. A nullable field may be omitted. A list of discriminated unions is checked against the variant its discriminator names.
  - On a list of dates the operand takes a `Date`, converted to the ISO string the elements are stored as. A `Date` there previously failed when the request was sent. The ISO string itself is still accepted.
  - On a list of numbers, `NaN`, `Infinity` and a `bigint` are rejected, as a write of the field rejects them.

  `$contains` on a string attribute is unchanged.

- **An object operand naming a field its schema does not declare is a `FilterError`.** An equality or `IN` element comparing a whole object attribute or object field (`{ address: { city: "Denver", region: "west" } }`, `{ "address.geo": [...] }`) was converted to its stored form by the attribute's serializer, which drops a field the schema does not declare, so the operand sent was not the one written. A query filter matched rows the operand did not describe, and a write condition `{ address: { city: "Denver", region: "west" } }` held against a stored `{ city: "Denver" }`, letting through a write the caller meant to stop. Such a field is now rejected before anything is sent, at any depth (in a nested object, in an element of a nested list of objects, in a union variant), naming its path, in filters and write conditions alike. An operand whose every field is declared is compiled as before. This had shipped in query filters before write conditions.

- **Linking an entity that does not exist through a join table is a `NotFoundError`.** `create` on a join table reads both entities before writing, and decided whether one was missing by counting the responses. DynamoDB returns a response for every read, with no item for an entity that does not exist, so the count always matched and `create` failed with `TypeError: Cannot convert undefined or null to object` instead. Only responses that carry an item now count, so a missing entity is reported as `NotFoundError` (`Entities not found: (Store: store-9)`) before anything is written, with or without `referentialIntegrityCheck` and with or without a condition. Nothing was ever written for a missing entity.

- **Linking two entities that share an id through a join table copies each into the other's partition.** `create` on a join table reads both entities and keyed what it read by id alone. Entities of different types can share an id when it is caller-supplied (`@IdAttribute`), and then the second overwrote the first: both link rows were written from the same entity, so one partition held a copy of itself in place of the entity it is linked to, with no error. What is read is now keyed by entity type and id, so each partition receives a copy of the other entity. When one of two such entities is missing, the `NotFoundError` now names it; it previously named neither. Links between entities with different ids are unchanged.

- **An update can set a nullable field inside a list element to `null`.** The update payload types offered `null` for a nullable field at the top level and inside an object attribute, but not inside an element of a list: an object element, a discriminated union variant, or an object or list within an element. So `update({ details: { history: [{ …, note: null }] } })` failed to compile, although the write accepted it and stored the element without the field. The types now offer `null` there too; each element is still written whole, so its other required fields stay required. The instance returned by `update` also leaves the field out instead of holding `null`, matching what is stored. Create is unchanged: a nullable field is omitted on create, inside a list element as anywhere else.

- **An index into a nullable list types the element it names.** A list-index path into a list declared `nullable` (`"details.history[0]"`, `"details.history[0].source"`) resolved to no field, so filters and write conditions fell back to the stored form there: a whole-element operand written as declared (`{ at: new Date(...), actor: "ann" }`) failed to compile although the expression builder accepted and matched it, while a value the builder rejects, such as a number for a string field, compiled. A nullable list is typed `Element[] | undefined`, and the index step tested that union against an array type without first removing `undefined`. The element, and every field below it, is now typed as it is in a list that is not nullable, in query filters and in every write condition, including an element that is a list or a union variant, and a write condition offers `null` on a nullable field below the index as "not set". Values the builder rejects are now compile errors there too.

- **A `$contains` on a list of objects can set a nullable field of the element to `null`.** A whole-element `$contains` operand was typed as the list's element, where a nullable field is optional but not `null`, so `{ "details.history": { $contains: { at, actor, note: null } } }` failed to compile, although the expression builder accepted it and left `note` out of the bound element. `contains` compares an element whole and an element is stored without a nulled field, so `null` there can only mean "an element without that field", as omitting it does. The operand now offers `null` on a nullable field at any depth of the element (its own fields, a nested object's, an element's of a list within it, a discriminated union variant's), in query filters and in every write condition. Nothing else widens: a field that is not nullable, an object field, an element of a nested list and the element itself still refuse `null`, and the conditions sent are unchanged.

- **An object compared whole by equality or `IN` can set a nullable field to `null`.** An equality value or `IN` element comparing an object whole (an object attribute, `{ address: { street, city, zip: null } }`; an object field reached by a dot path; a list element named by an index, `{ "details.history[0]": { at, actor, note: null } }`) was typed as declared, where a nullable field is optional but not `null`, so `null` there failed to compile, although the expression builder accepted it and left the field out of the bound value. A nulled field is stored by leaving it out, so the operand without it is the one that equals the stored value. These operands now offer `null` on a nullable field at any depth, as a `$contains` element does, in query filters and in every write condition. Nothing else widens: a field that is not nullable stays required, an object field, an element of a nested list and the value itself still refuse `null`, and the conditions sent are unchanged.

- **An `IN` of whole lists is validated and converted like any other whole value.** On a list field each `IN` element is a whole list (`{ "details.history": [[{ at, actor: "ann" }], []] }`), and it was bound exactly as written: a `Date` inside it reached DynamoDB unconverted and the request failed, a field the elements do not declare or a value of the wrong type was sent and matched nothing, and a nulled field was bound as NULL, which no stored list holds. Each list is now validated against the field's schema and converted to the form the table stores it in, as an equality value on an object is: an undeclared field, a value of the wrong type and a string outside an enum are a `FilterError` naming the attribute (and, for an undeclared field, its path) before anything is sent, a `Date` is sent as its ISO string, and a nulled field is left out. The types now offer `null` on a nullable field inside such a list, as they do for an equality value, in query filters and in every write condition. This had shipped in query filters before write conditions.

- **A range whose bounds are different kinds is a `FilterError`, even on a path into a discriminated union variant.** A path into a variant resolves to no field, because its fields depend on the variant, so nothing checked a range's bounds against each other there. A plain JavaScript caller's `{ "payment.method.cardNumber": { $between: ["4000", 4999] } }` was sent and rejected by DynamoDB with a `ValidationException` ("requires same data type for lower and upper bounds"), and `{ $gte: "4000", $lte: 4999 }` was sent, could never hold, and failed a write condition as a `WriteConditionFailedError` on the row's own guard. A `$between`, or a set of composed comparisons, whose bounds are not all one kind (all strings, all numbers, all bigints, all `Date`s or all binary, the kinds the types already required) is now rejected before anything is sent, naming the path, in filters and write conditions alike. A range whose bounds share a kind is sent unjudged as before, and on a field the schema resolves a mixed range was already rejected as a value its type does not hold. This had shipped in query filters before write conditions.

- **A query filter key naming no attribute of the partition is a `FilterError`.** A key no entity in the partition declares (`{ region: "west" }`, a dot path whose attribute is undeclared such as `"colour.shade"`, the same inside an `$or` block, or an untyped `$or` nested directly in an `$or` block) threw a plain `Error`, while every other filter refusal, and the same keys in a write condition, throw a `FilterError`. It is now a `FilterError` with the same message, still thrown before the query is sent. A `FilterError` is an `Error`, so existing catch blocks keep working. This predates write conditions.

- **Join table error messages quote ids like the rest of the library.** The messages for entities already linked, not linked, or missing when linked or guarded through a join table wrote ids bare (`Store with ID store-1 does not exist`), where every other message quotes them (`Store with ID 'store-1' does not exist`). They now quote them: `Customer with ID 'customer-1' is already linked to Store with ID 'store-1'`, `... is not linked to ...`, and `... does not exist`. Only the message text changes, so code matching it must be updated; the error classes are unchanged.

## 3.4.0 - 2026-10-03

### Added

- **List elements in a dot path.** A path through an array names one element with DynamoDB's own index syntax, and may continue below it when the elements are objects. The index is typed, so the path is offered by typeahead and narrows to the element's own field.

  ```typescript
  // One element of a list of scalars
  await Store.query("123", { filter: { "address.tags[0]": "home" } });

  // A field of one element of a list of objects
  await Store.query("123", { filter: { "address.contacts[0].name": "Jane" } });
  ```

  The key type offers indexes `0` through `9`. A higher index compiles and runs — the expression builder has no cap — but is not offered, because a condition on a specific element is in practice a condition on an early one.

  DynamoDB has no document path meaning "every element", so a path _through_ an array that omits the index reaches nothing: the condition compiles, matches no row and reports no error. dyna-record rejects it with a `FilterError` naming the segment and the index to add. An index on a field that holds no list is rejected the same way, for the same reason. To ask about a list as a whole, use `$contains`.

- **Comparison and range conditions.** `$gt`, `$gte`, `$lt`, `$lte` and `$between` are available on filters and on sort key conditions.

  ```typescript
  // Everything placed in January
  await Order.query("123", {
    filter: {
      orderDate: { $gte: new Date("2026-01-01"), $lt: new Date("2026-02-01") }
    }
  });

  // An inclusive range, as one condition
  await Order.query("123", { filter: { total: { $between: [50, 100] } } });
  ```

  Several comparison operators on one attribute compose with AND, which is how a half-open range is written. `$between` is inclusive on both bounds, and its pair is ordered. dyna-record rejects an inverted pair before the request, naming the attribute; DynamoDB rejects it too, but its message names only the operator, which is less help in a filter with several conditions. A composed range is the case to watch — DynamoDB validates `BETWEEN`'s bounds but applies a `$gte`/`$lt` pair as written, so an empty one returns no rows and no error. dyna-record rejects that too.

  Every operand is a **whole value** of the attribute, so it is named the way the entity declares it and converted to the stored form. A date attribute therefore takes `Date` operands. That is the same rule equality follows, and the opposite of `$beginsWith` and `$contains`, whose operands are a _fragment_ of the stored form.

- **Sort key conditions take ranges too**, which is where they pay for themselves: a range in a key condition narrows what DynamoDB reads, where a range in a filter reads the partition and discards rows afterwards.

  ```typescript
  await Customer.query("123", {
    skCondition: { $between: ["Order#100", "Order#200"] }
  });
  ```

  A sort key takes **one** condition, so the comparison operators do not compose there — a two-sided key range is `$between`. The partition key takes an equality: its value selects the partition to read, so there is nothing for another condition to narrow.

### Breaking (type-level only)

- **A filter value is named the way the entity declares the attribute.** Filter _keys_ were always entity-side — the attribute names you declared — while filter _values_ were table-side, the form DynamoDB stores. Those coincide for every attribute except the ones with serializers, which is why 3.3.0 had to exclude `Date` from filter values, exempt serialized attributes from validation, and tell you to write the ISO string yourself.

  A date attribute now takes a `Date`, and dyna-record converts it to the stored ISO string:

  ```typescript
  await Order.query("123", { filter: { orderDate: new Date("2026-01-15") } });
  ```

  Passing the stored string for equality no longer compiles or validates, because equality on a date means that date, not a fragment of how it is written. **Matching a date by partial value is unchanged** and is what `$beginsWith` has always been for — `filter: { orderDate: { $beginsWith: "2026" } }` still finds everything in that year. `$beginsWith` and `$contains` operands stay strings and scalars whatever the attribute's declared type, because each is a fragment of the stored form rather than a value of the attribute. Which attributes offer them at all is a separate question, answered below.

  Each element of an `IN` array is converted the same way, so `filter: { orderDate: [d1, d2] }` takes Dates.

- **Index key conditions take the declared form too.** `IndexKeyConditions` typed every attribute as a stored-form value, while the resolver that now converts and validates serves key conditions as well as filters — so a date attribute was typed as a string and validated as a `Date`. Both surfaces now agree: a key condition takes the declared value, a `$beginsWith` prefix, a comparison or a `$between` range. `PartitionKey` and `SortKey` still take plain strings; those brands mark the attribute's role, not a value you construct.

- **Nested fields are named the way they are declared too.** A dot-path condition resolves through the attribute's object schema to the field it names, so `filter: { "shipment.dispatchedAt": someDate }` takes a `Date` exactly as a top level date attribute does — matching `update()`, which has always taken a `Date` at a nested path. The field's own schema validates the value and its own definition converts it.

  A path that names no single field — one descending through an array element or a discriminated union variant, which a path alone does not identify — is left unvalidated and unconverted, and takes `StoredFilterTypes`. That is the one place a filter still speaks the stored form.

- **`null` is no longer a filter value.** `filter: { someNullableAttribute: null }` compiled before and does not now. dyna-record removes a nulled attribute rather than storing `NULL`, so an equality on `null` could never match a row — the condition was always a way of asking for nothing. For the same reason `null` is not a comparison or `$between` operand: there is no stored null to order against.

- **An operator is offered only where the attribute's stored form can carry it.** Each of DynamoDB's condition functions applies to particular types, and a condition outside that set is not an error — it simply matches nothing, which is indistinguishable from a query that legitimately found no rows. Those conditions are now absent from the type instead.

  - `$beginsWith` and `$contains` need a String (and `$contains` also accepts a List or Set). They are no longer offered on an attribute stored as a Number, a Boolean or a Map. `filter: { total: { $beginsWith: "1" } }` compiled before and does not now.
  - The comparison operators and `$between` need a value DynamoDB can order, which is a String, a Number or Binary. They are no longer offered on an attribute stored as a Boolean, a Map or a List.

  The **stored** form decides, not the declared one. That is why a date attribute keeps all of them: it is declared as a `Date` and stored as an ISO 8601 string, which orders lexicographically exactly as the date orders chronologically — so `{ orderDate: { $beginsWith: "2026" } }` and `{ orderDate: { $gte: someDate } }` both still work.

- **An `IN` element is shaped like the attribute's own value.** `IN` compares the whole attribute against each element, so on a List-stored field an element is a list. A list of scalars there — `filter: { "address.tags": ["home", "work"] }` — asks whether the list _equals_ `"home"` or `"work"`, which it never does. That now reports the mistake and points at the membership test it was reaching for:

  ```typescript
  // Membership
  filter: { "address.tags": { $contains: "home" } }

  // The whole list equals one of these
  filter: { "address.tags": [["home"], ["work"]] }
  ```

  An element that names an operator (`[{ $gt: 1 }]`) is rejected for the same reason: `IN` takes values, and an operator cannot nest inside one.

### Fixed

- **A filter whose every condition was dropped no longer sends an empty `FilterExpression`.** Forwarding an optional input is the documented way to build a filter, and the condition _was_ dropped from the expression — but the empty expression was still attached, and DynamoDB rejects it. `filter: { name: req.query.name }` with nothing in it now sends no filter at all, as the documentation says it does. The same applies to `{}`, to `$or: []`, and to an `$or` whose blocks all emptied — the last of which previously produced a `FilterExpression` beginning with a bare `" OR "`.

- **A nested field in a multi-entity partition is typed by the field, not by the stored form.** `EntityAttributesOnly` is built on `Omit`, which does not distribute, so a dot path into an attribute declared by only one entity of a partition resolved to nothing and fell back to the stored form — while the expression builder resolved the field exactly and validated in the declared form. The two surfaces accepted disjoint values: a nested date could not be filtered at all, because the type took only the stored string and the runtime took only a `Date`.

- **Whole-value equality on an `@ObjectAttribute` works again.** The operator-object guard added in this release read a plain object as a mistyped operator, which broke `filter: { address: { street, city } }`. DynamoDB's `=` does compare Maps, so an object naming no operator is the attribute's own value where the attribute stores one.

- **Conditions that could not match are reported rather than compiled.** Each produced a query DynamoDB accepts, answers with no rows, and reports nothing about — indistinguishable from an empty result:

  - An `IN` condition with no values, or with an element that resolved to `undefined`, which left a placeholder with nothing bound to it.
  - A comparison bounding an empty range — `{ $gte: 100, $lt: 1 }`, or an exclusive bound on equal endpoints. `$between` already rejected the inverted spelling of the same range.
  - An ordered comparison against a value DynamoDB cannot order, where the attribute's own form was unknown — a dot path through an array element or a union variant. A boolean, a Map, a List, `null`, `NaN` and `Infinity` are all rejected there now.
  - A `$beginsWith` operand that is not a string, or a `$contains` operand that is not a single value.

- **Values are validated in their declared form**, which removes the carve-out 3.3.0 added where attributes with serializers skipped validation entirely. A date filter is now checked — as a `Date` — where before it was not checked at all.

- **Key conditions are no longer compiled with the filter vocabulary.** A `KeyConditionExpression` accepted an `IN` array, a `$contains` and a nested Map path, none of which DynamoDB allows there — the condition compiled and the service rejected the request. Key conditions now compile under their own vocabulary and each unsupported construct is reported against the attribute that carries it. A `$or` block in a key condition was previously reported as an unknown attribute named `"$or"`.

- **A condition that cannot match is reported rather than compiled, on every arm that carries an operand.** These were spread unevenly: a check one arm had, its siblings often lacked.

  - An equality or `IN` operand that is not shaped like the attribute's value — a scalar compared against a List-stored field asks whether the list _equals_ that scalar. The error points at `$contains`, which is the membership test the caller wanted.
  - `null` as an equality value or `IN` element. dyna-record removes a nulled attribute rather than storing `NULL`, so no item holds one. An `IN` list mixing `null` with real values hid this best, since the real values still matched.
  - A `$contains` operand that is not a substring, where the attribute is stored as a String. A date attribute is stored as an ISO string, so a year is `"2026"` and not `2026`.
  - An `IN` element that names an operator, or an `IN` condition with no values at all.
  - A `$beginsWith` prefix or `$contains` substring that is the empty string. This is the one case that fails by matching _everything_ — `?? ""` on an optional input reaches it, and in a key condition it reads the whole partition while reading like a narrow.
  - A filter key with an empty path segment, which `` `${prefix}.${field}` `` produces when either half is empty.
  - A comparison bounding an empty range. Worth knowing: DynamoDB validates `BETWEEN`'s bounds itself but applies a composed `$gte`/`$lt` pair as written, so only the composed spelling fails silently.

- **A filter and an `$or` block that both compile to nothing no longer produce a malformed expression.** `{ $or: [], name: "x" }` compiled to `() AND (#Name = :Name1)` — empty parentheses, which DynamoDB rejects.

- **A nested field whose name is not a valid expression token can be filtered on.** An `@ObjectAttribute` schema key is an unrestricted string, so a field may legitimately be named `"order date"`. Its name was used verbatim as the `#` token, producing `#order date`, which DynamoDB rejects — the name is now mapped to a safe token, as expression attribute names exist to allow. Ordinary names are unchanged.

- **An operator object that names no supported operator is rejected.** `filter: { total: { $ne: 5 } }` compiled to an equality against the object itself — a condition DynamoDB accepts, matches nothing for, and reports no error about, making a mistyped operator indistinguishable from an empty result. The error now names the attribute and lists the operators that exist. An operator object that mixes families, such as a comparison and a `$between`, is rejected for the same reason: one would have been compiled and the rest silently dropped.

## 3.3.2 - 2026-10-03

### Fixed

- **A caller-supplied `ExclusiveStartKey` was discarded.** `DynamoClient.query` drains every page itself, and it declared the cursor it follows without seeding it from the caller's parameters. The pagination loop owns that field on every request, so page one went out with `ExclusiveStartKey: undefined` and the read restarted from the beginning of the partition. Nothing in dyna-record sets the field today, so no public operation was affected — but the method's own documentation keeps the door open for a cursor-based read API, and that door was already broken behind it.

### Changed

- **Command assertions in the test suite are whole commands rather than single fields.** Eleven assertions matched one property of a `QueryCommand`, a `SearchVectorsCommand` or a logged payload with `objectContaining`, which passes however the rest of the command drifts. The `ExclusiveStartKey` bug above is what that costs: the pagination tests asserted the cursor on pages two and three and never looked at page one.

  Asserting the shape also recorded two things no test had stated. The update prefetch on a vector-indexed table scopes itself with `FilterExpression: "#Type IN (:Type1)"`, which is why its key placeholder is `:PK2`; and it sends an explicit `ExclusiveStartKey: undefined`, which the plain query path does not.

- **Three partial assertions were left partial, with the reason written down.** A vector index's metadata carries a content fingerprint and its full search schema, so pinning the whole object in a test about one variant's dimensions would fail on every unrelated change to the index. `getEntitiesForTable` is asserted with `arrayContaining` because the claim is that four inherited entities are present, not that they are the only entities the fixture file declares. Where the whole object is the right assertion, the embedding model constants now pin it — including the score converter, matched by its type the way the decorator suites match a zod schema.

- **A redundant test is gone.** `defaults topK to 10 and passes an explicit topK through` asserted one field of a command that an adjacent `it.each([1, 100])` already asserts whole, and named a default that five other tests pin inside their own whole-command assertions.

## 3.3.0 - 2026-09-28

### Breaking (type-level only)

- **Filter values are checked.** `FilterTypes` — the value type behind every key in a query `filter` — resolved to `any`, because it was built on the AWS SDK's `NativeAttributeValue`, whose union ends in an `InstanceType<{ new (...args: any[]): any }>` branch that collapses the whole type. A filter value is now a string, number, bigint, boolean, `null` or binary, an array of those for an `IN` condition, or a `$beginsWith` / `$contains` operator object.

  Four shapes compiled before and no longer do. A **`Date`** — dyna-record stores dates as ISO 8601 strings, so filter on the string (`{ createdAt: { $beginsWith: "2026-09" } }`), which is what the documented examples already do. A **function or class instance**, which the document client could never marshal. **`$beginsWith` with a non-string**, since `begins_with` operates on strings. And an **unsupported operator** such as `$gt`, which the search builder already rejected at runtime with a `FilterError` and which query filters silently compiled into an equality against a map.

- **Index key conditions are checked.** `IndexKeyConditions<T>` typed every entity field's value as a whole `KeyConditions` record rather than as a condition on that field, and `KeyConditions` itself resolved to `{}` — a type that accepts any object. Both were consequences of the same `any`. Each attribute now takes a value to match or a `$beginsWith`, so `{ name: { someOtherAttribute: "x" } }` is a compile error where it previously passed.

- **`SortKeyCondition`, `BeginsWithFilter`, `ContainsFilter` and `DynamoTableItem` narrow accordingly**, as does the `Serializers` pair — a custom `toEntityAttribute` typed against the SDK's `NativeScalarAttributeValue` now takes `DynamoNativeValue`.

### Fixed

- **Query filter and key condition values are validated at runtime**, against the same attribute schemas the type system checks against. Compile-time typing is erased for JavaScript callers and for anything that reaches a filter as `unknown`, so a restriction that exists only in the types is not a restriction at all; this closes that gap for the query path, which vector search filters already had. A value that cannot match the attribute is rejected with a `FilterError` naming it, rather than compiled into a query that silently returns nothing.

  Three cases are deliberately left unvalidated, because in each the value is not a value of the attribute being compared. An attribute stored differently from how it is declared — a date, declared as `Date` and stored as an ISO string — since the filter names the stored form. A nested `@ObjectAttribute` field reached by dot path, since it is not a value of the object that contains it. And the operands of `$beginsWith` and `$contains`, which are a prefix and a fragment rather than a whole value. Each element of an `IN` array _is_ validated, since those are equality candidates.

- **A filter condition explicitly set to `undefined` is dropped rather than compiled.** Filter keys are optional, so forwarding an optional input — `filter: { name: req.query.name }` — is an ordinary way to build one. Previously that compiled an expression referencing a placeholder with nothing bound to it, and named the attribute in `ExpressionAttributeNames` without referencing it, either of which DynamoDB rejects with a `ValidationException` that names neither the attribute nor the cause. An undefined `$or` drops the same way instead of being read as an attribute named `"$or"`.

- **A key condition with no value is rejected** with a `FilterError` naming the attribute, rather than dropped. The asymmetry is the point: key conditions are what scope a query to a partition, so silently dropping one widens the query to everything under that partition, while dropping a filter only widens the result set inside the partition the key conditions already bounded. One is a quietly wrong answer; the other is a narrower answer than intended.

- **An operator given no value is rejected** with a `FilterError` naming the attribute and the operator — `{ name: { $beginsWith: undefined } }` — instead of emitting a `begins_with` or `contains` whose placeholder has nothing bound to it.

### Added

- **`DynamoScalarValue` and `DynamoNativeValue`** are exported: the value a DynamoDB attribute can hold, and its recursive form including documents and sets. They are dyna-record's own unions, held inside the SDK's scalar union by an unexported type-level assertion so a type the service stops supporting fails the build. The assertion is one-directional because `DynamoScalarValue` is deliberately narrower than the SDK's: no `undefined`, no `NumberValue` wrapper (rejected on a supplied client since 3.2.0), and no wide binary union, since dyna-record models no binary attribute type. `DynamoNativeValue` does include `undefined`, which is how an absent attribute and an unset serializer result are represented.

### Changed

- **The update expression types no longer derive from `UpdateCommandInput`**, and two type-level assertions hold them equal to the SDK's. `ExpressionAttributeValues` is deliberately unasserted: the SDK's value type is `any`, so any check against it passes vacuously — which is why it was worth owning.

- **Published type declarations naming the AWS SDK drop from twelve files to four.** The four that remain are the command shapes dyna-record sends, the query command input `QueryBuilder` builds, and `DynamoDBClientConfig` — none of which can be duplicated without maintaining a parallel copy of the SDK's command surface.

- **Seven `no-unsafe-assignment` suppressions are gone** from the source, along with two casts in the filter guards, because nothing on these paths is `any` any more.

## 3.2.0 - 2026-09-28

> **Read before upgrading: the DynamoDB client is configurable, and no longer pinned to `us-west-2`.** dyna-record built its client with `region: "us-west-2"` written in, which overrode `AWS_REGION`, `AWS_DEFAULT_REGION`, and the profile's region. Fixing that defect changes which region a deployed application talks to, and **nothing breaks at the type level** — the new options are additive and optional — so the compiler will not flag it for you. If your table is in `us-west-2`, check the migration below before upgrading.

### Breaking (runtime behavior)

- **The region is no longer forced to `us-west-2`.** dyna-record now constructs its client with an empty config, so region, credentials, and endpoint resolve through the SDK's own chain: `AWS_REGION` then `AWS_DEFAULT_REGION` then the shared config profile, the standard credential provider chain, and `AWS_ENDPOINT_URL_DYNAMODB` / `AWS_ENDPOINT_URL`. On Lambda, ECS, and EC2 this is already correct and nothing changes.

  Before this release every dyna-record table had to live in `us-west-2`, since nothing else was reachable. So the upgrade affects you if your environment resolves any other region: a Lambda outside `us-west-2`, where `AWS_REGION` is always the function's own region, or a local script under a profile defaulting elsewhere. Those reach the wrong region and fail with `ResourceNotFoundException` rather than a build error. An environment that resolves no region at all fails with the SDK's `Region is missing`.

  Either set `AWS_REGION=us-west-2`, or declare it on the table, which is the durable fix: `@Table({ name: "my-table", clientConfig: { region: "us-west-2" } })`.

### Added

- **`clientConfig` on `@Table`.** Takes the AWS SDK's `DynamoDBClientConfig`, so a table can fix its own region, endpoint, credentials, retry posture, or request handler without touching the environment. dyna-record builds that table's document client from it, once. Two tables in different regions or accounts are now expressible.

- **`client` on `@Table`.** Replaces the client dyna-record builds, for sharing one with the rest of your application, instrumenting it (X-Ray), or substituting a fake in tests. Mutually exclusive with `clientConfig` — a compile error if both are declared, and a throw naming the table class for JavaScript callers.

  The option is typed structurally (`DynaRecordDocumentClient`: anything with a `send`) rather than as `DynamoDBDocumentClient`. The SDK's client class extends a smithy `Client` carrying a private member, which makes TypeScript compare it nominally: a client built from a second installed copy of the AWS SDK fails to satisfy it even when the two interoperate — measured between `3.1100.0` and `3.1105.0`, five releases apart. Since a project whose lockfile predates dyna-record's floor gets exactly that second copy, the nominal type would have rejected correct clients for most existing projects. A runtime check naming the table class replaces it, and a hand-written test double now type-checks with no cast.

  Commands are built from dyna-record's own copy of `@aws-sdk/lib-dynamodb`, but marshalling follows the client you supply — the SDK hands each command the sending client's config, and lib-dynamodb reads `translateConfig` from it. Supply a client built with the SDK's default marshalling: one with `unmarshallOptions.wrapNumbers` enabled is rejected where the table is declared, since every number attribute would otherwise hydrate as a wrapper object and fail its schema on the first read. A client configured that way belongs to your application, not to dyna-record — give dyna-record `clientConfig` instead. If you inject a client, build it from `@aws-sdk/lib-dynamodb` 3.1103.0 or later — the release that introduced `SearchVectorsCommand`. This applies only to injected clients.

- **Exported types: `DynaRecordDocumentClient`, `TableClientOptions`.**

### Changed

- **The client is built on first use, not at import.** Importing dyna-record no longer constructs a DynamoDB client, and each table resolves and reuses exactly one client — so a table configured with its own client never falls back to the default, and two tables never share one. Tables that configure nothing share a single default client for the life of the process.

- **`VectorDistanceFunction` is dyna-record's own union rather than a re-export of the SDK's.** Its three values are unchanged (`"COSINE" | "DOT_PRODUCT" | "EUCLIDEAN"`) and nothing about its use changes. The re-export made TypeScript emit a path into the SDK's internal module layout — `@aws-sdk/client-dynamodb/dist-types/models/enums.js` — inside dyna-record's own published declarations, and that layout has moved between SDK releases. An unexported type-level assertion holds the union equal to the SDK's in both directions, so a service addition fails dyna-record's build.

- **The declared minimum AWS SDK version is corrected to `^3.1103.0`**, the first release containing `SearchVectorsCommand`. It was `^3.1105.0`, which was simply the current version when vector search shipped.

## 3.1.0 - 2026-09-17

### Changed

- **Search filter values are typed from the attribute's declaration.** `filter` validated which attributes could be filtered but not what values they could hold: every filterable key accepted the flat `string | number | boolean` union, so an enum filterable took any string and a string filterable took a number. Both were already rejected at runtime by the attribute's registered zod type — the gap was entirely at compile time, over a rule the library already enforced. A filter value now resolves from the declaration: an enum accepts only its declared members, a number filterable rejects a string, and a filterable foreign key still accepts a plain string.

  When several members of an index declare the same filterable property, the accepted value is the **union** of their declared types, and `in:` narrows it to the named member's — mirroring the runtime resolver, which already accepted a value matching any member's type. Two members declaring `tier` as `"gold" | "silver"` and `"bronze" | "copper"` accept all four values un-narrowed, and two apiece under `in:`.

- **Consumer-defined brands survive into filter values.** dyna-record strips the brands it imposes — `ForeignKey`, `NullableForeignKey`, `Searchable` — from every caller-facing input, because a consumer never asked for them. A brand you define with `Brand<...>` is not stripped, in filters or in `create`/`update` inputs: it exists so that a bare value fails, which is the whole reason it was declared.

### Breaking (type-level only)

- **`boolean` is no longer a filterable attribute type.** Every vector-index inline filter must be declared in the table's `AttributeDefinitions`, whose `ScalarAttributeType` set is `B | N | S`. There is no BOOL, so `@SearchFilterable()` over a `@BooleanAttribute` described a table DynamoDB refuses to create. `SearchFilterValue` is now `string | number`, and the `SearchFilterable` brand and decorator reject `boolean`. No working search is affected — on an index member such a filter could never be provisioned, and off every index it was inert — so drop the `@SearchFilterable()` mark from any boolean attribute.

- **Filter values that were widened now need narrowing at the call site.** Two shapes compiled and ran correctly before and no longer compile, because brands and enum literals have no runtime representation for the runtime check to catch: a `string`-typed variable passed to an enum filterable, and a plain value passed to a consumer-branded filterable. Parse or narrow at the boundary — `filter: { status: parseStatus(req.query.status) }` rather than passing a widened `string` through.

### Added

- **Metadata initialization rejects a filterable whose members provision differently.** One filterable property is one table attribute, and one `AttributeDefinitions` entry carries one `ScalarAttributeType`. Members may declare a shared filterable with different _declared_ types — two enum value sets — but declaring it a string on one member and a number on another now throws at initialization, naming both entities and their scalar types, instead of failing later at `CreateTable`.

## 3.0.0 - 2026-09-16

> **Major release: per-index vector attributes and explicit membership.** The version is major because previously-valid vector search declarations no longer compile or initialize: the per-call `vectorIndex()` static is removed in favor of a single `vectorIndexes({...})` declaration per table, every index must declare its own unique `vectorAttribute` and its complete `members` list, and the pre-3.0 shared-attribute model — where all of a table's indexes derived overlapping membership over one `__dyna_vector` attribute — is gone, and the entity-anchored `Parent.search(...)` surface is removed in favor of searching the index construct. **The DynamoDB side is not breaking**: an existing index migrated with `vectorAttribute: "__dyna_vector"` keeps its exact provisioning contract — no re-provision, no data migration (its `fingerprint` changes format once; see below). The migration is mechanical; the README's [Migrating from 2.x](README.md#migrating-from-2x) section is the complete guide. Tables without vector indexes upgrade with no source changes.

### Added

- **Physically disjoint vector indexes.** Each index declares its own `vectorAttribute` — the table attribute its members' vectors are written under, and therefore (per DynamoDB's sparse-index semantics) its physical membership surface. Each searchable entity belongs to exactly **one** index: it is embedded by that index's model, written under that index's attribute, and ingested, billed, and searchable only there. Two indexes may share a scope parent and remain fully independent corpora — separately ranked, separately billed, never crowding each other's results. Zero or multiple memberships fail fast at metadata initialization.
- **Per-index embedding config.** Model, provider, dimensions, and distance function are declared per index; the cross-index equality restriction is removed. Query embedding and precomputed-vector dimension validation follow the invoked index.
- **Compile-time declaration validation.** The `vectorIndexes` type surface rejects a duplicate `vectorAttribute` or `IndexName`, a missing or wrongly-prefixed vector attribute (`__dyna_vector` or `__dyna_vector_*` — the reserved prefix consumer attributes may not use), a member entity with no `@Searchable` attribute, an entity claimed by more than one index, and unknown options — each as a compile error on the offending entry, with metadata initialization as the runtime backstop for plain JS.
- **The one-owner rule is enforced at the declaration site.** An entity listed in two indexes' `members` is now a compile error on _both_ declarations (`MemberOfMultipleIndexesError`), not only a metadata-initialization throw. Membership is physical — an entity carries exactly one index's vector attribute — so two indexes claiming it disagree about which vector its row holds and which model embedded it. The check compares each member's `type` discriminator, which is an entity's only identity in a structural type system, and stays silent for a member whose `type` widened to `string` rather than risk reporting two unrelated entities as colliding; `@Entity` already refuses such a class, so that path is a backstop rather than a gap. Repeating an entity within a single index's own `members` is unaffected — it still has exactly one owner.
- **Typed unscoped indexes.** Membership is always explicit, so unscoped (formerly "global") index constructs infer their member unions, `in:` values, filter keys, and result types exactly like scoped constructs — previously they fell back to the base result type.
- **Stale-vector convergence.** On tables with several indexes, a write that embeds or clears the vector also removes the table's other _declared_ vector attributes from the row (names-only; a no-op when absent), so an entity moved between still-declared indexes converges back to exactly one vector on its next embed — including the documented `forceEmbed` backfill. Two cases are outside it: an update whose searchable value is unchanged skips the embed and writes no vector clauses at all, and an attribute whose index declaration has been removed entirely is no longer a sibling to remove (drain it before retiring the index, or clear it with a one-off pass).

### Changed

- **`vectorIndex()` is removed; `vectorIndexes({...})` is the sole declaration surface.** One call per table, keyed by export name, returning the typed constructs; a second call for the same table throws. Existing indexes must set `vectorAttribute: "__dyna_vector"` — any other value is a destructive re-provision.
- **Membership is explicit everywhere.** `members:` replaces `include:` and is required on every index: nothing is derived from the scope parent's declared relationships, and the automatic all-searchable-entities membership of global indexes no longer exists (a formerly-global index enumerates its corpus; the zero-owner init error lists anything missed). A 2.x searchable entity that was silently in no index now fails fast at init.
- **Search runs through the index construct; the entity-anchored surface is removed.** `Parent.search(scopeId, query, options)` is gone — `Organization.search(orgId, q)` becomes `orgSearchIndex.search(orgId, q)` — and `in:` now names **member entities** (`{ in: "Product" }`) rather than relationship properties (`{ in: "products" }`). Every affected call site is a compile error, so none is missed silently.

  The parent surface typed its results from the parent's declared _relationships_ while the runtime searched the index's _members_, so the two disagreed exactly when an index had a member with no relationship on the scope parent — a `Review` reachable only by foreign key was returned by the search but typed as a `Listing`. That shape could not be repaired in place: a parent entity's static type is fixed at its class declaration, and the index that scopes it is declared afterward, so the parent surface can never learn its membership. The index construct already does, which makes it the only surface that can be exact. Un-narrowed results are now typed as the index's full declared membership, matching what the search returns.

  Two removed restrictions follow. A parent may now scope **any number** of vector indexes — the "parent search surface is ambiguous" runtime error is gone along with the surface that needed it — and an entity with no searchable relationships is no longer a special case, since search was never hanging off it. Removed type exports: `ParentSearchOptions`, `ParentSearchedEntities`, `SearchableRelationshipProperties`, `SearchableRelationshipEntities`, `HasSearchableRelationships`, `SearchNotAvailable`.

- **Index constructs are immutable.** `vectorIndexes({...})` returns the library's own live metadata — the same objects the registry holds and the search path reads — so those objects are now frozen once metadata initialization resolves them. Assigning to any field throws a `TypeError`, and `memberEntities`, `inlineFilterAliases`, and `members` are frozen arrays rather than mutable ones.

  This closes a real hole rather than a theoretical one: `hashAlias` carried no `readonly` at all, so `myIndex.hashAlias = "TenantId"` type-checked and repointed the attribute compiled into the emitted `SearchVectors` condition — the enforced scope boundary. Two consequences to know about. `model` is now a frozen **copy** of the descriptor you passed, so `myIndex.model === TitanTextEmbedV2` is no longer true (compare fields, not identity); the copy means freezing never reaches a descriptor you share across indexes. And the resolved fields are getters rather than own properties, so `JSON.stringify(myIndex)` and `{ ...myIndex }` no longer include them — use `MyTable.metadata()`, which is the supported serialization path and unchanged.

- **`fingerprint` gains the vector attribute as a hash input**, so a `vectorAttribute` change — a destructive replacement — is detectable by fingerprint diffing. Every fingerprint therefore changes format once on upgrade with no underlying config change; tooling must not destroy or rebuild an index on a fingerprint change alone.
- **The published package no longer ships declaration maps.** `files` narrows from `dist/**/*` to `dist/**/*.js` plus `dist/**/*.d.ts`, dropping 100 `.d.ts.map` files that pointed at TypeScript sources the package never contained — every one of them was a dangling reference. The install is ~100 files and 96 KB (unpacked) smaller with no functional change: types, hover documentation, and runtime behavior all come from the `.d.ts` and `.js`, which still ship, and stack traces were never affected (no JavaScript source maps are generated). `declarationMap` stays on for local development, where the sources are present and editor go-to-definition resolves.

- **Five internal serializers are no longer part of the public surface.** `dateSerializer`, `createObjectSerializer`, `objectToTableItem`, `tableItemToObject`, and `convertFieldToTableItem` were never documented or intended as API — they reached consumers only because the attribute barrel re-exported their module wholesale and the package entry point re-exports that barrel. They back `@ObjectAttribute` and `@DateAttribute` internally, their signatures are free to change, and nothing in the documented API accepts or returns them. Anyone who imported one should treat it as an internal detail that was never supported; the attribute decorators are the supported surface. The entry point's runtime export list is now pinned by a test, so a barrel cannot widen the public API again without the change being explicit.

- **Export surface.** Removed: `vectorSearchKeys`. Added value exports: `reservedVectorAttributePrefix`, `isValidVectorAttributeName`. Added type exports: `VectorAttributeName`, `VectorIndexConstructs`, `VectorIndexMembers`, `VectorIndexScoped`, `ValidateVectorIndexes`, `DuplicateVectorAttributeError`, `DuplicateIndexNameError`, `NonSearchableMemberError`, `MemberOfMultipleIndexesError`, `UnknownVectorIndexOptionError`. Removed type exports: the parent-search types listed above. **`VectorIndexMetadata` is now a type-only export** — it remains fully usable in type positions, but `new VectorIndexMetadata(...)` no longer compiles: a construct is produced by `Table.vectorIndexes({...})`, which registers and resolves it, and a hand-constructed one would be unregistered and unresolved. `VectorIndexOptions` changes shape (`vectorAttribute` and `members` required; `include` removed), and `SerializedVectorIndexMetadata.vectorAttribute` narrows to the `VectorAttributeName` template type.

### Known issues

- The AWS-side rejection of updates to transactionally-written items on vector-indexed tables documented in the 2.0.0 entry remains tracked there; its status is unchanged as far as this release is aware.

## 2.0.1 - 2026-09-08

### Fixed

- **`update` no longer silently skips every denormalization write when the entity has a child whose id is its own.** An id is only unique per entity type, but the update pre-fetch identified the entity being updated by id alone. A `HasOne`/`BelongsTo` child declared with `@IdAttribute` on its foreign key takes its parent's id as its own id (the documented one-per-parent pattern), and it denormalizes into the parent's partition carrying that id — so the pre-fetch of that partition returned two rows whose `Id` equalled the id being updated, and the child overwrote the parent as the update subject whenever its type name sorted after the parent's (`"ActionPlan"` before `"PlanAgentLease"`, `"Catalog"` before `"CatalogItem"`). The subject was then left holding the child's attributes, so the parent's foreign keys were gone: the `BelongsTo`/`OwnedBy` denormalization built nothing (it saw an undefined foreign key), and the parent never reached the related-entity pass so that built nothing either. **The transaction committed the canonical row alone and succeeded** — this was never a partial transaction failure, and being a transaction offered no protection, because atomicity covers the items that were enqueued and these were never enqueued at all. No error, warning, or return-value difference distinguished it from a correct write.

  The blast radius is narrow but the failure is severe and permanent where it lands: only entities that have such a child are affected, and only for updates issued while that child row exists (for the ephemeral single-writer-lock pattern this shape is most often used for, that is the window in which the lock is held). Within that window every denormalized copy of the entity falls behind its canonical row, so reads through a parent partition (`query` on the owning entity, `findById` with includes) return stale attributes while `findById` on the entity itself returns correct ones — a split that persists indefinitely, because nothing subsequently reconciles it.

  **Upgrading fixes future writes; it does not heal copies that have already diverged.** Each update propagates only the attributes it sets, so an attribute that diverged stays diverged until some later update writes that same attribute again. Consumers who may have hit this should reconcile existing copies directly (compare each denormalized copy against its canonical row and rewrite the differing attributes) rather than assuming the upgrade repairs them.

  One behavioral consequence worth planning for: the parent's copy in the child's partition now joins the transaction under `attribute_exists`, so if that child row is deleted between the pre-fetch and the commit — plausible when the child is a short-lived lock released on a `finally` — the update now fails with a retryable `ConditionalCheckFailedError` instead of quietly under-writing. That is the intended trade (a loud, retryable failure in place of silent permanent divergence), but callers on that pattern should be prepared to retry.

  No source changes are required for consumers; bumping to `2.0.1` is sufficient.

## 2.0.0 - 2026-08-21

> **Major release: DynamoDB native vector search.** The version is major for one reason: `DynaRecord` gains a static `search` method, and a subclass that already declares its own static `search` member no longer compiles against the new base-class signature — rename or align it when upgrading, per the 1.0.0 commitment that breaking changes to the public API only ship in a new major version. No other 1.0.x API or behavioral contract changes shape; consumers without their own static `search` member upgrade with no source changes. (There is deliberately no instance-level `search`: instances hydrated by `findById`/`query` are typed without relationship properties, so an instance surface could not be compile-checked — the static surface always can be, and `Parent.search(instance.id, ...)` covers the same call.)

### Added

- **DynamoDB native vector search.** Entities declare one searchable attribute with the layered `@Searchable()` decorator (and the `Searchable` property brand); creates and updates embed the value synchronously through a consumer-supplied embedding provider and write the vector onto the entity's canonical row inside the operation's existing single transaction — no second write, no eventual-consistency pipeline, and rows are searchable sub-second after the write acknowledges. The vector lives on canonical rows only; denormalized relationship copies and join-table link records never carry it. Metadata initialization validates the whole configuration up front (membership, filter and scope-key alias consistency, one embedding configuration per table) and fails fast with actionable messages.
- **Two search surfaces, fully inferred.** `Parent.search(scopeId, query, options)` (available when exactly one vector index is `scopedBy` the class) and `myIndex.search(...)` on the construct returned by the new `Table.vectorIndex({ name, model, provider, scopedBy?, include? })` static — always unambiguous, with the scope value first on scoped indexes. `in:` narrows to the parent's searchable relationship names (member entity names on the index construct) and result unions narrow accordingly; `filter` keys narrow to the searched entities' `@SearchFilterable()` attributes (strings, numbers, booleans, enums, and foreign keys — including nullable attributes, whose brand composes with the optional form; a row where the attribute is absent never matches an equality filter on it). Search filters are an equality-only `AND` conjunction — an AWS grammar constraint, enforced at compile time and at runtime. A `scopedBy` index's `HASH` is the enforced isolation boundary (a tenant-scoped index cannot search across tenants); filterable foreign keys narrow within it in the same single operation. Results compile to exactly one `SearchVectors` operation and return complete typed entity instances with `similarity` (higher is better, converted per the model's distance function) and the raw AWS `score`.
- **Bring-your-own embedding provider.** The library ships **no embedding implementation and no embedding SDK dependency** — the required `provider` is an embed function you own (credentials, region, retry, timeout), paired with the shipped pure-data `TitanTextEmbedV2` model descriptor (1024 dimensions), its `TitanTextEmbedV2Dim512`/`TitanTextEmbedV2Dim256` variants (smaller vectors for lower storage and write cost; the provider must request the matching output size from the model), or your own `EmbeddingModelDescriptor`. Provider failures fail the whole write with the new `EmbeddingError` (provider error on `cause`).
- **Unchanged values skip embedding; `forceEmbed` overrides.** An update whose searchable value matches the stored value skips the provider call and the vector write, and the transaction is conditioned on that value still being in place at commit — a concurrent write changing it fails the update with a retryable `ConditionalCheckFailedError` rather than leaving the row silently missing from the index. Pass `forceEmbed: true` to `update` to embed anyway: the affordance for backfilling rows that predate searchability and for re-embedding after an embedding model change.
- **New root exports.** Value exports: `Searchable`, `SearchFilterable` (decorators, previously unexported), `TitanTextEmbedV2`, `VectorIndexMetadata`, `vectorSearchKeys`, and the `EmbeddingError`/`FilterError` error classes. Type exports: `Searchable`/`SearchFilterable` (property brands), `EmbeddingProvider`, `EmbeddingModelDescriptor`, `VectorDistanceFunction`, `VectorIndexOptions`, `SearchQuery`, `SearchOptions`, `SearchResult`, `SearchResults`, `SearchFilter`, `SearchFilterParams`, `SearchFilterValue`, `SerializedTableMetadata`, `SerializedVectorIndexMetadata`, and every search inference helper (`SearchableRelationshipProperties`, `InferSearchResults`, `ParentSearchOptions`, `IndexSearchOptions`, and friends) — verified against the packed tarball via a real npm pack-and-resolve consumer compile.

### Changed

- **`DynaRecord` gains a static `search` method — the breaking change this major version carries.** See the release note above: subclasses that happened to define their own static `search` member collide with the base class; rename or align with the new signature when upgrading. Instance members and attributes named `search` are unaffected — the base class adds no instance-level `search`.
- **`metadata()` gains vector index fields — this is the IaC provisioning contract.** On tables that declare a vector index, the serialized table metadata now carries a `vectorIndexes` array (index name, model descriptor _name_ only — never the provider, client config, or credentials — dimensions, distance function, `ALL` projection, the search schema as table aliases with the auto-declared entity `type` inline filter, a `scopedBy` entity name when scoped, and a stable configuration `fingerprint` so IaC can detect that a decorator change implies a destructive index replacement). The exported `SerializedTableMetadata` type gains these fields accordingly; consumers structurally typing against `metadata()`'s return shape should account for the new optional field. Tables without a vector index serialize exactly as before.
- **Ordinary reads on vector-indexed tables exclude the vector via projection.** `findById`, `query`, and the internal update/delete pre-fetches on a table that declares a vector index now send a `ProjectionExpression` covering every registered attribute alias except the vector — a bandwidth and latency saving (reads bill on full item size regardless of projection; the row is permanently larger, as the README's cost model spells out). Read commands on tables **without** a vector index are byte-identical to 1.0.1. The vector never appears on hydrated entities, which carry only the entity's declared attributes.

### Known issues

- **AWS-side: DynamoDB rejects updates to transactionally-written items on vector-indexed tables.** As of this release, the DynamoDB service (vector search GA 2026-08-05) permanently rejects all `UpdateItem`/transactional-Update calls against any item whose write history includes `TransactWriteItems`, with a spurious `ValidationException` (`The update expression attempted to update the secondary index key to unsupported type`) — even for items carrying no vector and updates touching no index attribute. dyna-record writes through transactions, so **on vector-indexed tables, `update` currently fails against the live service; `create`, `search`, `findById`, `query`, and `delete` are unaffected**, and tables without a vector index are unaffected entirely. This is a service defect, not a library one — it reproduces with the raw AWS SDK and no dyna-record code, contradicts AWS's documented behavior, and is reported with a minimal reproduction at <https://repost.aws/questions/QUT2cAymeBS36JpaRiL2b-NQ>. This entry will be updated when AWS resolves it.

## 1.0.1 - 2026-07-25

### Fixed

- **Query-backed reads no longer silently truncate at DynamoDB's 1MB page limit.** A single DynamoDB `Query` returns at most 1MB of data per call plus a `LastEvaluatedKey` cursor when more results exist; dyna-record previously issued one request and discarded the cursor, so any read whose results exceeded ~1MB was cut off at the first page with no error, warning, or indication that results were incomplete. This affected every read that runs a query under the hood: `query` (a partition or index with more than ~1MB of matching items returned only the first page) and `findById` with includes (includes are resolved by querying the entity's partition for related records, so a parent with a large `HasMany` relationship — or many included associations — could return an incomplete set of related entities). Reads now follow the cursor and return the complete result set for both. Return types are unchanged — you receive the same arrays as before, no longer truncated — so no source changes are required for consumers; bumping to `1.0.1` is sufficient. Note that reads over large partitions now issue multiple sequential requests to DynamoDB and materialize the full result set in memory rather than returning a partial first page, increasing read-capacity consumption and latency in proportion to the data matched. A caller-provided `Limit` is honored as a cap on the total number of items returned (not a per-page limit), leaving room for an opt-in bounded/cursor-based read API in a future release; the current change is additive and forward-compatible with that work.

## 1.0.0 - 2026-07-10

> **Stable release.** This is the `1.0.0` promised by the 0.7.0 modernization note — the public API surface that settled across the 0.7.x line is now locked in under semantic versioning. **There are no API or behavioral changes from 0.7.3**: every entity decorator, relationship decorator, `DynaRecord` method, and exported type keeps the same shape and the same import path. Bumping from `0.7.x` to `1.0.0` requires no source changes.

### Changed

- **Semantic-versioning commitment.** From this release forward, breaking changes to the public API will only ship in a new major version. The TypeScript 6 / ESM-only / Node 22 baseline established in 0.7.0 is the supported foundation going forward.
- **Dependency refresh.** Dev and transitive dependencies were updated (`package-lock.json`) with no change to the published runtime footprint.

## 0.7.3 - 2026-07-04

### Fixed

- **Entities can now extend base classes (entity inheritance).** The `@Entity` decorator previously assumed an entity's direct parent class was the `@Table` class, so an entity extending an abstract base class (or another entity) registered a bogus table name and crashed on first use with `TypeError: Cannot destructure property 'defaultAttributes' of ... as it is undefined`. The decorator now resolves the table by walking the class hierarchy until it finds a class decorated with `@Table` — the same strategy the partition/sort key decorators already used. Shared attributes and relationships declared on an intermediate abstract base class register on each concrete entity (including runtime schema validation); the base class itself is never registered as an entity and never appears in table metadata. An entity whose hierarchy contains no `@Table` class now throws a descriptive error at class definition time instead of failing later with the opaque `TypeError`.

## 0.7.2 - 2026-06-01

### Added

- **`Brand` re-exported from the package root.** `Brand<K, T>` is the utility behind `PartitionKey`, `SortKey`, `ForeignKey`, and `NullableForeignKey` — it was previously only reachable via the internal `./types.js` path, which the 0.7.0 `exports` map closed off. Exporting it from the root lets consumers declare their own branded primitives (e.g. `type OrgId = Brand<string, "OrgId">`) without dipping into internal paths, and keeps the public type graph self-contained under TypeScript 6's isolated/portable declarations model. No source changes are required for existing consumers; bumping to 0.7.2 is sufficient.

## 0.7.1 - 2026-05-30

### Fixed

- **Inferred return types now resolve correctly in consumers under strict module resolution.** The 0.7.0 `exports` map closed off deep `dist/...` paths, which inadvertently left a handful of internal types referenced by inferred return signatures unreachable through the public entry. Under `moduleResolution: bundler` or `nodenext`, this surfaced two ways:

  - **`TS2883`** ("The inferred type of X cannot be named without a reference to Y") at consumer call sites for any inferred return type that names `Optional<T>` — e.g. `findById`, `findById` with includes.
  - **Silent degradation to `any`** (with `TS7006` cascades on downstream callback parameters such as `.map((u) => …)`) for the deeply-recursive conditional types in `InferQueryResults`. The threshold is relationship-count-driven: at ~16+ relationships on an entity, TS partially resolves the conditional and writes a deferred reference to internal narrowing helpers; with no reachable path to those helpers, the result collapsed to `any` rather than erroring. Entities with a handful of relationships were unaffected.

  The fix re-exports the affected types from the package root so the public entry can name them:

  - `Optional` (from `./types.js`) — appears directly in `findById`'s return signature.
  - `ShouldNarrow`, `NarrowByNames`, `FallbackToFilterKeys`, `IntersectTypeWithOr` (from `./operations/index.js`) — the top-level branching helpers in the `InferQueryResults<T, F, SK>` conditional chain.

  No source changes are required for consumers; bumping to 0.7.1 is sufficient. Note that this regression only reproduces through real npm resolution — `npm link`, workspace paths, and `file:` dependencies all bypass the `exports` map under most tooling and will not surface it.

## 0.7.0 - 2026-05-29

> Modernization release. Brings the library onto TypeScript 6, ESM-only publishing, plain `tsc` build, Node 22 runtime floor, Vitest tests, and a clean low-maintenance dev-dependency surface. **The public API surface is unchanged** — every entity decorator, relationship decorator, `DynaRecord` method, and exported type re-exports the same shape from the same import path. Stays at `0.7.0` (not `1.0.0`) to keep semver headroom while the new shape settles in real consumer projects; a future `1.0.0` will lock it in.

### Added

- **ESM publish via `exports` map.** `import DynaRecord from "dyna-record"` works directly. Types resolve correctly under TypeScript 6 + `moduleResolution: NodeNext` or `bundler`.
- **`sideEffects: false`** so consumer bundlers can tree-shake unused exports aggressively.
- **`publint` as the publish gate** (run automatically via `prepublishOnly`).

### Changed

- **TypeScript 6 (`^6.0.3`)** is the new minimum compiler requirement. `tsconfig.json` is fully explicit (no `@tsconfig/recommended` extends) with `module: NodeNext`, `moduleResolution: NodeNext`, `target/lib: ES2025`, `types: ["node"]`.
- **Build: plain `tsc`** emits multi-file output mirroring `src/` structure (preserves consumer tree-shaking at file granularity). No bundler dependency.
- **Test runner: Vitest (`^3.2.4`)** replaces Jest + ts-jest. The test API surface is near-identical (`describe`, `it`, `expect`, `vi.*` matching `jest.*`). Pass count, per-suite breakdown, and coverage match the pre-migration Jest baseline (30 suites, 983 tests). Coverage instrumentation switched from Istanbul to V8; the same 85/60/85/85 thresholds (branches/functions/lines/statements) are enforced.
- **ID generation:** ID defaults use Node's built-in `globalThis.crypto.randomUUID()` (same v4 format, same string shape). Mocking-friendly via a thin `src/id.ts` indirection.
- **Source uses `.js` extensions on relative imports** — spec-compliant Node ESM under `moduleResolution: NodeNext`. Internal style change only; no consumer-visible effect.

### Removed

- **`uuid` dependency.** Replaced internally by `crypto.randomUUID()`; was never re-exported, so there is no consumer-visible type change.
- **Node `<22` runtime support.** Node 18 EOL'd in 2025; Node 20 EOL'd April 2026.
- **CJS publish.** No `dist/index.cjs`, no `.d.cts`, no `require` condition, no `main` field. ESM-only.

### Breaking

- **Node engine floor raised to `>=22`.** Aligns with the AWS Lambda `nodejs22.x` runtime baseline.
- **ESM-only.** Consumers must `import` (or use Node 22+'s `await import()` from CJS context). Direct `require('dyna-record')` no longer works without Node 22.12+'s stabilized `require(esm)`.
- **Deep imports of internal `dist/...` paths are no longer reachable.** Only the root `.` entry (and `./package.json`) is advertised through the `exports` map. Any consumer who was importing from `dyna-record/dist/...` will need to switch to the root entry — all publicly-supported symbols are re-exported there.

### Migration

- Bump your CI / runtime to Node `>=22` (for AWS Lambda: switch to the `nodejs22.x` runtime).
- Bump your TypeScript dev-dependency to `>=6`.
- If you have any `require('dyna-record')` call sites in CJS code, replace with `await import('dyna-record')`, or upgrade to Node `22.12+` for unflagged `require(esm)` support.
- Replace any `import X from "dyna-record/dist/..."` with `import { X } from "dyna-record"`.
- No source changes are required for the `uuid` removal; the package was never re-exported.

### Notes

- **`experimentalDecorators` was never officially supported** by dyna-record; this release clarifies that **TC39 Stage 3 decorators are the only supported decorator flavor**. The library has been built around Stage 3 decorators since the 0.x line — this is a clarification, not a behavior change.

## 0.6.8 - 2026-05-11

### Added

- **Attribute type introspection via `DynaRecord.metadata()`:** Every attribute in the serialized metadata now carries a `kind` field identifying its logical type: `"string" | "number" | "boolean" | "date" | "enum" | "object" | "foreignKey"`. Consumers can now switch exhaustively on `attr.kind` for use cases like code generation, form builders, admin UIs, and documentation generation — without inferring the type from side-channel fields. Available on entity attributes, table attributes, default attributes, partition keys, and sort keys.
- **Enum domain in serialized metadata:** Attributes with `kind: "enum"` expose a `values: readonly [string, ...string[]]` array of the allowed enum values, so consumers can render dropdowns, validate input, or generate type literals without re-deriving the domain from the source decorator.
- **Object schema in serialized metadata:** Attributes with `kind: "object"` expose a `schema: ObjectSchema` field carrying the user-authored object schema (the same value passed to `@ObjectAttribute({ schema })`). Consumers can walk the schema to inspect nested fields, types, and nullability.
- **Discriminated `SerializedTableMetadata` type:** The exported `SerializedTableMetadata` type is now a discriminated union keyed on `kind`, so TypeScript narrows kind-specific payloads automatically. For example, inside `if (attr.kind === "enum")`, `attr.values` is typed as `readonly [string, ...string[]]`; inside `if (attr.kind === "object")`, `attr.schema` is typed as `ObjectSchema`.
- **`AttributeKind` and `SerializedTableMetadata` exported from the package root:** Both types are now importable from the `dyna-record` package entry point, enabling consumers to write exhaustive `switch` blocks with full compile-time coverage.

### Fixed

- **`findById` with includes now correctly resolves `@IdAttribute` entities:** When a child entity uses `@IdAttribute` (e.g., a foreign key that doubles as its ID), the child's `id` equals the parent's `id`. Previously, `filterQueryResults` used only `id` equality to distinguish the parent from related entities, causing the child to overwrite the parent instead of being added to `relatedEntities`. The parent-detection condition now also checks `res.type === this.EntityClass.name`, correctly disambiguating parent from child even when IDs match. No behavioral change for entities with distinct IDs.

## 0.6.5 - 2026-03-24

### Added

- **Discriminated unions as array items:** `ArrayFieldDef.items` now accepts `FieldDef` (previously `NonUnionFieldDef`), allowing `{ type: "array", items: { type: "discriminatedUnion", ... } }`. Arrays of discriminated unions use full replacement on update, and each item is serialized and deserialized using variant-aware logic.

### Changed

- **`NonUnionFieldDef` scope narrowed:** `NonUnionFieldDef` is now only used for `DiscriminatedUnionFieldDef.variants` (preventing DU-inside-DU nesting). It is no longer used for `ArrayFieldDef.items`.
- **DRY refactor of serializer DU handling:** Extracted shared discriminated union conversion logic into `convertDiscriminatedUnion` helper, used by both serialization and deserialization paths.

## 0.6.4 - 2026-03-24

### Added

- **Discriminated union fields in `@ObjectAttribute` schemas:** A new `"discriminatedUnion"` field type allows defining tagged unions within object schemas. Each variant is described by an `ObjectSchema` keyed by its discriminator value. The discriminator key is automatically added to each variant's inferred TypeScript type as a string literal. Discriminated unions support `nullable: true` and always use full replacement on update. See `DiscriminatedUnionFieldDef`.
- **`InferDiscriminatedUnion` utility type:** Infers the TypeScript union type from a `DiscriminatedUnionFieldDef`, producing `{ [discriminator]: VariantKey } & InferObjectSchema<VariantSchema>` for each variant.

## 0.6.3 - 2026-03-21

### Added

- **SK-scoped filter validation:** When `skCondition` narrows to specific entities, the `filter` parameter now only accepts attributes from those entities. For example, `{ skCondition: { $beginsWith: 'Order' }, filter: { lastFour: '1234' } }` now produces a compile error because `lastFour` is a `PaymentMethod` attribute, not an `Order` attribute. This applies to top-level filter keys, `type` values, and `$or` blocks.
- **`$beginsWith` prefix matching for multiple entities:** `$beginsWith` in `skCondition` now accepts partial entity name prefixes that match multiple entity types. For example, if a partition contains "Invoice" and "Inventory" entities, `{ $beginsWith: 'Inv' }` is accepted and narrows the return type to `Invoice | Inventory`. Similarly, `{ $beginsWith: 'PaymentMethod' }` matches both `PaymentMethod` and `PaymentMethodProvider` when one entity name is a prefix of another. Filter attributes are scoped to the union of matched entities.
- **`SKScopedFilterParams<T, SK>` utility type:** Computes the filter type based on SK narrowing. Exported for use in generic contexts.

### Changed

- **`OptionsWithoutIndex` is now generic on SK:** Accepts an optional `SK` type parameter (defaults to `unknown`) to scope the `filter` property based on sort key narrowing. When SK narrows to specific entities, only those entities' attributes are accepted in the filter. Backward compatible — existing code without SK defaults to the full `TypedFilterParams<T>`.
- **`TypedAndFilter`, `TypedOrFilter`, `TypedFilterParams` are now aliases:** These types are now defined as aliases over the new entity-parameterized `AndFilterForEntities`, `OrFilterForEntities`, and `FilterParamsForEntities` types applied to the full partition. Resolved types are identical — no behavioral change.

### Breaking (type-level only)

- **Query overload generic parameter order:** The non-index query overload now declares `SK` before `F` (was `F` before `SK`) because `F`'s constraint depends on `SK`. Code that explicitly specifies these type parameters (e.g., `Customer.query<Customer, FilterType, SKType>(...)`) must reorder them. This does not affect callers who rely on type inference (the vast majority of usage).

## 0.6.2 - 2026-03-21

### Fixed

- **`skCondition` now intersects with filter narrowing instead of being overridden by it:** Previously, `skCondition` was only used for return type narrowing as a fallback — when no filter-based narrowing applied. This meant that a query like `{ skCondition: { $beginsWith: 'Insight' }, filter: { status: 'active' } }` would incorrectly widen the return type to all entities with a `status` attribute (e.g., `Insight | VendorProfile`), ignoring that `skCondition` physically limits the scan to only `Insight` items. Now, `skCondition` is always intersected with filter-based narrowing at every level of the type inference chain (top-level `type`, filter keys, and `$or` blocks). When `skCondition` and filter narrowing are disjoint (e.g., SK targets `Order` but filter type targets `PaymentMethod`), the filter is rejected at compile time.

## 0.6.1 - 2026-03-19

### Changed

- **Documentation URL update:** Updated all API documentation links in the README from `dyna-record.com` to `docs.dyna-record.com`.

## 0.6.0 - 2026-03-17

### Breaking

- **`@Entity` requires `declare readonly type`:** All entity classes must now declare their `type` property as a string literal matching the class name (e.g., `declare readonly type: "Order"`). This is a pure type annotation with zero runtime impact — the ORM continues to set `type` automatically. Entities missing this declaration will produce a compile error at the `@Entity` decorator. This change enables compile-time type safety for query filters and return types. To migrate, add `declare readonly type: "ClassName";` to each entity class body.
- **Typed query filters:** The `query` method's `filter` option now validates filter keys at compile time. Only attributes that exist on entities in the queried partition are accepted. Invalid keys, relationship property names, and partition/sort key attributes produce compile errors. Existing code that passes arbitrary string keys in filters will need to be updated. Filter values remain untyped (`FilterTypes` / `any` from AWS SDK's `NativeAttributeValue`).
- **Typed `type` filter field:** The `type` field in query filters now only accepts valid entity class names from the partition. Previously any string was accepted. Code that filters by `type` with values that are not entity class names (e.g., `type: ["Beer", "Brewery"]`) must be updated to use valid entity names.

### Added

- **Strongly-typed query filters:** Query filter keys are validated against the attributes of all entities in the queried partition. This includes entity-defined attributes, default fields (`id`, `createdAt`, `updatedAt`), `ForeignKey` and `NullableForeignKey` attributes, and dot-path keys for `@ObjectAttribute` nested fields (e.g., `"address.city"`, `"address.geo.lat"`).
- **`type` field narrowing:** When filtering by `type` with a single entity name (e.g., `type: "Order"`), filter keys in `$or` elements are narrowed to only that entity's attributes. When `type` is an array (IN operator), all partition entity attributes are accepted.
- **Return type narrowing:** The return type is automatically narrowed based on the filter:
  - `type: "Order"` → `Array<EntityAttributesInstance<Order>>`
  - `type: ["Order", "PaymentMethod"]` → `Array<EntityAttributesInstance<Order> | EntityAttributesInstance<PaymentMethod>>`
  - Filter keys: `{ orderDate: "2023" }` → narrows to entities that have `orderDate` (e.g., `Order`)
  - `$or` blocks: each element narrows by `type` or by filter keys; return type is the union across blocks
  - AND intersection: when both top-level conditions (type or keys) and `$or` blocks narrow to specific entities, the return type is the intersection. If no entity satisfies both, the return type is `never[]`.
  - No type/keys specified → `QueryResults<T>` (full union)
- **Sort key condition validation:** `skCondition` values are typed to only accept valid entity names from the partition (exact entity name or entity name prefix). Invalid sort key values produce compile errors. When `skCondition` is an exact entity name or `$beginsWith` with an exact entity name, the return type is automatically narrowed to that entity type.
- **New utility types:** `DotPathKeys<T>`, `ObjectDotPaths<T>`, `EntityFilterableKeys<T>`, `NonRecursiveLeaf`, `PartitionEntities<T>`, `PartitionEntityNames<T>`, `TypedAndFilter<T>`, `TypedFilterParams<T>`, `NarrowedQueryResults<T, F>`, `InferQueryResults<T, F, SK>`, and supporting types for filter validation and return type inference.
- **`IsAny<T>` utility type:** General-purpose type-level `any` detection, defined in `src/types.ts`. Used internally by query filter types to guard against `any` propagation.

### Changed

- **`@Entity` decorator is now generic:** Uses a conditional intersection type to enforce the `declare readonly type` requirement at compile time. Runtime behavior is unchanged.
- **`OptionsWithoutIndex` is now generic:** Accepts a type parameter `T extends DynaRecord` to scope the `filter` property to `TypedFilterParams<T>`. Defaults to `DynaRecord` for backward compatibility in generic contexts.
- **`DotPathKeys<T>` has a depth limiter:** Recursion stops at 8 levels to prevent "Type instantiation is excessively deep" errors with deeply nested `@ObjectAttribute` schemas.
- **Query method overload updated:** The non-index query overload now uses `const F` and `SK` generic parameters for literal type inference, enabling return type narrowing. The index query overload is unchanged and continues to use untyped `FilterParams`.

## 0.5.3 - 2026-03-09

### Fixed

- **Changelog correction:** Fixed version number in 0.5.2 changelog entry (was incorrectly labeled as 0.6.0).

## 0.5.2 - 2026-03-09

### Changed

- **Zod v4 upgrade:** Upgraded from Zod v3 (`^3.23.8`) to Zod v4 (`^4.3.6`). This is the only dependency change and has no effect on the library's public API or runtime behavior for consumers. All exported types, validation semantics, and error handling remain identical.
- **`z.record()` two-argument form:** Updated all `z.record(schema)` calls in metadata serialization schemas to the required v4 form `z.record(z.string(), schema)`.
- **`.omit()` strict key validation:** Zod v4 throws when `.omit()` receives keys not present in the schema. Added `filterReservedKeys()` in `EntityMetadata` to filter reserved keys to only those present in the entity's attribute schema before calling `.omit()`.
- **`ZodSchema` removed:** Replaced `ZodSchema` type references with `ZodType`, as `ZodSchema` was removed in Zod v4.

### Breaking

- **Validation error issue format:** The `cause` array on `ValidationError` contains Zod issue objects whose shape changed in v4. The `received` field is no longer present, `message` strings use a new format (e.g. `"Invalid input: expected string, received undefined"` instead of `"Required"`), and enum validation errors use `code: "invalid_value"` with a `values` array instead of `code: "invalid_enum_value"` with an `options` array. Code that inspects `ValidationError.cause` issue objects directly will need to be updated.

## 0.5.1 - 2026-03-07

### Changed

- **ESLint 9 migration:** Migrated from ESLint 8 to ESLint 9 with flat config (`eslint.config.mjs`). Removed legacy `.eslintrc.js` and `.eslintignore`. Added `@eslint/js@^9`, `typescript-eslint@^8`, `globals@^16`. Removed `@typescript-eslint/parser@6`, `eslint-config-standard-with-typescript`, `eslint-plugin-import`, `eslint-plugin-n`, `eslint-plugin-promise`.
- **Strict type-checked linting:** Upgraded from `tseslint.configs.strict` to `tseslint.configs.strictTypeChecked` for non-test files, enforcing stricter type-aware lint rules across the codebase.
- **Decorator type safety:** Removed unnecessary `context.kind` runtime checks from all decorator functions — TypeScript's type system already enforces correct decorator placement.
- **Relationship metadata constructors:** Removed unnecessary `if (item !== undefined)` guards from all relationship metadata constructors (BelongsTo, HasOne, HasMany, HasAndBelongsToMany, OwnedBy).
- **Record key existence checks:** Replaced `record[key] !== undefined` patterns with `key in record` for proper type narrowing with `Record<string, X>` types across MetadataStorage, FindById, Update, QueryBuilder, and utility files.
- **Type guard improvements:** Changed relationship type guards in `metadata/utils.ts` to use `in` operator for checking discriminant properties (`foreignKey`, `joinTableName`).
- **`EntityMetadata.idField`:** Made properly optional (`string` → `string?`) to reflect that not all entities have custom ID fields.
- **`extractForeignKeyFromEntity` return type:** Widened to `ForeignKey | null | undefined` to accurately reflect nullable foreign key values at runtime, with corresponding null guards in Create and Update operations.
- **`DynaRecord.query()` overloads:** Combined duplicate overload signatures into a single unified signature.

## 0.5.0 - 2026-03-03

### Changed

- **Partial `@ObjectAttribute` updates:** Updating an `@ObjectAttribute` is now a **partial merge** instead of a full replacement. Only the fields you provide are modified — omitted fields are preserved. Under the hood, dyna-record generates DynamoDB document path expressions (e.g., `SET #address.#street = :address_street`) instead of replacing the entire map. Nested objects are recursively merged. Arrays within objects are still full replacement. Setting a nullable field within an object to `null` generates a `REMOVE` expression for that specific field.
- **Object attributes are never nullable:** `@ObjectAttribute` fields no longer support `nullable: true`. DynamoDB cannot update nested document paths (e.g., `address.geo.lat`) if the parent object does not exist, causing a `ValidationException`. To prevent this, object attributes always exist as at least `{}`. Nested `"object"` fields within schemas are also never nullable. Non-object fields (primitives, enums, dates, arrays) can still be nullable.
- **Update types for `@ObjectAttribute`:** All fields within object attributes are now optional (`Partial<>`) in update payloads, matching the partial update semantics. This is a compile-time change — you no longer need to provide all required fields when updating a subset of an object attribute.
- **Update validation for `@ObjectAttribute`:** The Zod schema used for update validation is now a deep partial schema for object attributes. All fields are optional (can be omitted), but type validation still applies for provided fields. Non-nullable fields still reject `null`.
- **Instance `update` deep merge:** The instance `update` method now deep merges object attributes, preserving existing fields not included in the partial update. Previously, `Object.assign` would shallow-replace the entire object attribute value.

## 0.4.11 - 2026-03-02

### Added

- **`"date"` field type for `@ObjectAttribute`:** Define date fields within object schemas using `{ type: "date" }`. Dates are stored as ISO 8601 strings in DynamoDB and exposed as JavaScript `Date` objects on entities, mirroring `@DateAttribute` behavior. Supports `nullable: true` for optional date fields. Works at any nesting level — top-level, inside nested objects, or as array items.
- **Object attribute serialization:** `@ObjectAttribute` now registers `toTableAttribute` and `toEntityAttribute` serializers. These handle date conversion to/from ISO strings and strip `null`/`undefined` values for nullable fields, ensuring removed fields are omitted from stored objects rather than persisted as `null` in DynamoDB.

### Changed

- **Nullable field semantics in `@ObjectAttribute`:** Nullable fields (`nullable: true`) now omit the field from the stored object when set to `null` or `undefined`, rather than persisting `null` in DynamoDB. Inferred types for nullable fields changed from `T | null | undefined` to `T | undefined` — use `undefined` to omit a nullable field.
- **Update operation typing for nested object attributes:** `AllowNullForNullable` now recurses into plain object values (e.g. object schema attributes) so that nullable fields at any nesting depth can receive `| null` in update payloads, matching root-level nullable attribute behavior. This allows explicitly passing `null` for nested nullable fields during updates to remove them.

## 0.4.10 - 2026-02-22

### Added

- **`@ObjectAttribute` decorator:** Define structured, typed object attributes on entities. Objects are validated at runtime via Zod and stored as native DynamoDB Map types. Declare schemas using `ObjectSchema` and derive TypeScript types with `InferObjectSchema<typeof schema>`.
  - **Supported field types:** `"string"`, `"number"`, `"boolean"`, `"enum"`, nested `"object"`, and `"array"`
  - **Enum fields:** Use `{ type: "enum", values: ["a", "b"] }` to define string literal union fields with compile-time type inference and runtime validation. Enum fields work at any nesting level — top-level, inside nested objects, or as array items.
  - **Nullable fields:** Individual non-object fields support `nullable: true`. Object attributes and nested object fields are never nullable.
  - **Nested objects and arrays:** Arbitrarily deep nesting via `"object"` with `fields` and `"array"` with `items`, including arrays of objects and nested arrays
- **`$contains` query filter operator:** Filter on List attributes containing a specific element or string attributes containing a substring. Maps to DynamoDB's `contains()` function. Works with both top-level attributes and nested fields via dot-path notation.
- **Dot-path query filtering for nested Map attributes:** Filter on fields within `@ObjectAttribute` using dot notation (e.g., `"address.city"`, `"address.geo.lat"`). All standard filter operators work with dot-paths: equality, `$beginsWith`, `$contains`, and `IN`.

## 0.4.9 - 2026-02-16

### Fixed

- **FindById with HasOne includes now returns class instances with instance methods:** When `findById` was called with `include` for a HasOne association, the included related record was returned as a serialized plain object without instance methods. The included HasOne record is now returned as a class instance (with instance methods such as `update`), aligning with BelongsTo and collection associations. This is a runtime fix.

## 0.4.8 - 2026-02-16

### Fixed

- **FindById include typing for optional single associations:** When an included relationship was optional (single HasOne or BelongsTo that may be undefined), the included property was typed as the full related entity type union `undefined` instead of the serialized attribute type. The result type now correctly uses `EntityAttributesOnly<Related> | undefined` for optional included single associations, matching the serialized shape. Type-only fix; no runtime behavior changed.

## 0.4.7 - 2026-02-16

### Fixed

- **FindById include typing for single associations (HasOne/BelongsTo):** When `findById` was called with `include: [{ association: 'foo' }]` and `foo` was a single related entity (HasOne or BelongsTo), the included property was incorrectly typed as the parent entity’s attributes (`EntityAttributesOnly<Parent>`) instead of the related entity’s attributes. The result type now correctly uses `EntityAttributesOnly<Related>` for included HasOne/BelongsTo associations, so TypeScript infers the related entity’s attributes (e.g. `EntityAttributesOnly<RevenueCache>`) rather than the parent’s. HasMany and optional single associations are unchanged. This is a type-only fix; no runtime behavior changed.

## 0.4.2 - 2026-02-02

### Added

- `DynaRecord.metadata()` static method that returns serialized table metadata containing only serializable values. This method provides a plain object representation of the table metadata, with functions, class instances, and other non-serializable data converted to their string representations or omitted. Useful for introspection, debugging, and generating documentation.

## 0.4.1 - 2025-12-01

### Added

- `referentialIntegrityCheck` option for `Create` and `Update` operations (defaults to `true`). When set to `false`, skips explicit condition checks that verify foreign key references exist before creating or updating entities. This can be beneficial in high-contention or high-throughput systems where the overhead of these checks might impact performance.
- `referentialIntegrityCheck` option for `JoinTable.create()` method, allowing referential integrity checks to be skipped when creating join table entries.

## 0.4.0 - 2025-11-12

### Breaking Changes

- `@ForeignKeyAttribute` now requires a target entity factory (for example `@ForeignKeyAttribute(() => Customer, { alias: "CustomerId" })`). This guarantees that every foreign key knows the entity it references so that referential integrity checks can run even when no relationship decorator is present.
- `Create` and `Update` operations add DynamoDB `ConditionCheck` steps for foreign keys that are not paired with a relationship decorator. This closes the gap where standalone foreign keys could point at non-existent records, ensuring writes fail fast with `ConditionalCheckFailedError` instead of persisting broken references.

### Added

- `EntityMetadata.foreignKeyAttributes` and `EntityMetadata.standaloneForeignKeyAttributes` expose foreign-key metadata with guaranteed targets, giving operations a canonical view of all foreign keys that require integrity enforcement.
- Type guard `isForeignKeyAttributeMetadata` for working with foreign-key attribute metadata.

### Changed

- `BelongsTo` typing now enforces that the decorated relationship target matches the branded `ForeignKey` type, preventing mismatched associations at compile time.
- Standalone foreign-key validation in `Create` and `Update` leverages existing string helpers, removing redundant casting and aligning transaction item ordering.
