dyna-record
    Preparing search index...

    dyna-record

    Dyna-Record

    API Documentation

    Medium Article

    Dyna-Record is a strongly typed Data Modeler and ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the single-table design pattern and the adjacency list design pattern. All operations are ACID compliant transactions*. To enforce data integrity beyond the type system, schema validation is performed at runtime.

    Note: ACID compliant according to DynamoDB limitations

    Requires Node.js >=22 and TypeScript >=6.

    npm install dyna-record
    # or
    yarn add dyna-record
    # or
    pnpm add dyna-record

    dyna-record is ESM-only as of 0.7.0:

    // ESM
    import DynaRecord, { Entity, Table } from "dyna-record";

    If your project is still on CommonJS, use a dynamic import:

    // CommonJS (any modern Node) via dynamic import
    const { default: DynaRecord, Entity, Table } = await import("dyna-record");

    Direct require("dyna-record") only works on Node 22.12+ via Node's stabilized require(esm) and is not an officially supported contract — prefer dynamic import() for portability.

    dyna-record uses TC39 Stage 3 decorators (the TypeScript 5+/6 default). Do not enable experimentalDecorators in your tsconfig.json.

    Entities in Dyna-Record represent your DynamoDB table structure and relationships. Think of each entity as a table in a relational database, even though they will be represented on a single table.

    Docs

    Create a table class that extends DynaRecord base class and is decorated with the Table decorator. At a minimum, the table class must define the PartitionKeyAttribute and SortKeyAttribute.

    import DynaRecord, {
    Table,
    PartitionKeyAttribute,
    SortKeyAttribute,
    PartitionKey,
    SortKey
    } from "dyna-record";

    @Table({ name: "my-table" })
    abstract class MyTable extends DynaRecord {
    @PartitionKeyAttribute({ alias: "PK" })
    public readonly pk: PartitionKey;

    @SortKeyAttribute({ alias: "SK" })
    public readonly sk: SortKey;
    }
    import DynaRecord, {
    Table,
    PartitionKeyAttribute,
    SortKeyAttribute,
    PartitionKey,
    SortKey
    } from "dyna-record";

    @Table({
    name: "mock-table",
    delimiter: "|",
    defaultFields: {
    id: { alias: "Id" },
    type: { alias: "Type" },
    createdAt: { alias: "CreatedAt" },
    updatedAt: { alias: "UpdatedAt" }
    }
    })
    abstract class MyTable extends DynaRecord {
    @PartitionKeyAttribute({ alias: "PK" })
    public readonly pk: PartitionKey;

    @SortKeyAttribute({ alias: "SK" })
    public readonly sk: SortKey;
    }

    Docs

    Each entity must extend the Table class. To support single table design patterns, they must extend the same tables class.

    Each entity must declare its type property as a string literal matching the class name. This enables compile-time type safety for query filters and return types. Omitting this declaration will produce a compile error at the @Entity decorator.

    By default, each entity will have default attributes

    • The partition key defined on the table class
    • The sort key defined on the table class
    • id - The id for the model. This will be an autogenerated UUID (v4, via Node's built-in crypto.randomUUID()) unless IdAttribute is set on a non-nullable entity attribute.
    • type - The type of the entity. Value is the entity class name. Must be declared as a string literal via declare readonly type: "ClassName".
    • createdAt - The timestamp of when the entity was created
    • updatedAt - Timestamp of when the entity was updated last
    import { Entity } from "dyna-record";

    @Entity
    class Student extends MyTable {
    declare readonly type: "Student";
    // ...
    }

    @Entity
    class Course extends MyTable {
    declare readonly type: "Course";
    // ...
    }

    Note: declare readonly type is a pure TypeScript type annotation with zero runtime impact. The ORM sets type to the class name automatically. The declaration simply tells TypeScript the exact literal type, enabling typed query filters and return type narrowing.

    Entities do not have to extend the table class directly. The @Entity decorator resolves an entity's table by walking the class hierarchy until it finds a class decorated with @Table, so shared attributes and relationships can live in an abstract base class between the table and the concrete entities:

    import {
    Entity,
    StringAttribute,
    NumberAttribute,
    BooleanAttribute
    } from "dyna-record";

    // Not decorated with @Entity — this class is never registered as an entity,
    // never appears in table metadata, and no records of its own type exist
    abstract class Vehicle extends MyTable {
    @StringAttribute({ alias: "Make" })
    public readonly make: string;

    @NumberAttribute({ alias: "Year" })
    public readonly year: number;
    }

    @Entity
    class Car extends Vehicle {
    declare readonly type: "Car";

    @NumberAttribute({ alias: "Doors" })
    public readonly doors: number;
    }

    @Entity
    class Motorcycle extends Vehicle {
    declare readonly type: "Motorcycle";

    @BooleanAttribute({ alias: "HasSidecar" })
    public readonly hasSidecar: boolean;
    }

    Car and Motorcycle are full entities of MyTable: each inherits make and year (including runtime schema validation for them), and all CRUD operations work as if the attributes were declared on the entity itself. The abstract base class is only a container for shared code — it cannot be queried or persisted.

    A few things to keep in mind:

    • If an entity class does not extend a class decorated with @Table anywhere in its hierarchy, the @Entity decorator throws at class definition time.
    • Extending a concrete entity (e.g. class Pickup extends Truck) is supported at runtime, but TypeScript will not allow the subclass to narrow the inherited type literal ("Pickup" is not assignable to "Truck"). Prefer abstract base classes for shared attributes.
    • There is no polymorphic querying: entities are stored and queried by their exact class name. Querying Car will never return Motorcycle records, even though they share a base class.
    • A base class couples its subclasses to one table, since it must extend a specific table class. To share a shape across entities in different tables, use a TypeScript interface instead (each entity must still declare its own decorated attributes).

    Use the attribute decorators below to define attributes on a model. The decorator maps class properties to DynamoDB table attributes.

    import { Entity, Attribute } from "dyna-record";

    @Entity
    class Student extends MyTable {
    declare readonly type: "Student";

    @StringAttribute({ alias: "Username" }) // Sets alias if field in Dynamo is different then on the model
    public username: string;

    @StringAttribute() // Dynamo field and entity field are the same
    public email: string;

    @NumberAttribute({ nullable: true })
    public someAttribute?: number; // Mark as optional
    }

    Use @ObjectAttribute to define structured, typed object attributes on an entity. Objects are validated at runtime and stored as native DynamoDB Map types.

    Define the shape using an ObjectSchema and derive the TypeScript type with InferObjectSchema:

    import { Entity, ObjectAttribute } from "dyna-record";
    import type { ObjectSchema, InferObjectSchema } from "dyna-record";

    const addressSchema = {
    street: { type: "string" },
    city: { type: "string" },
    zip: { type: "number", nullable: true },
    tags: { type: "array", items: { type: "string" } },
    category: { type: "enum", values: ["home", "work", "other"] },
    createdDate: { type: "date" },
    geo: {
    type: "object",
    fields: {
    lat: { type: "number" },
    lng: { type: "number" }
    }
    }
    } as const satisfies ObjectSchema;

    @Entity
    class Store extends MyTable {
    declare readonly type: "Store";

    @ObjectAttribute({ alias: "Address", schema: addressSchema })
    public readonly address: InferObjectSchema<typeof addressSchema>;
    }
    • Supported field types: "string", "number", "boolean", "date" (stored as ISO strings, exposed as Date objects), "enum" (via values), nested "object" (via fields), "array" (via items), and "discriminatedUnion" (via discriminator + variants)
    • Nullable fields: Set nullable: true on individual non-object fields within the schema to make them optional
    • Object attributes are never nullable: DynamoDB cannot update nested document paths (e.g., address.geo.lat) if the parent object does not exist. To prevent this, @ObjectAttribute fields always exist as at least an empty object {}. Nested object fields within the schema are also never nullable. Non-object fields (primitives, enums, dates, arrays) can still be nullable.
    • Alias support: Use the alias option to map to a different DynamoDB attribute name
    • Storage: Objects are stored as native DynamoDB Map types
    • Partial updates: Updates are partial — only the fields you provide are modified. Omitted fields are preserved. Nested objects are recursively merged. See Updating Object Attributes
    • Filtering: Object attributes support dot-path filtering in queries — see Filtering on Object Attributes

    Use { type: "enum", values: [...] } to define a field that only accepts specific string values. The TypeScript type is inferred as a union of the provided values, and invalid values are rejected at runtime via Zod validation.

    Enum fields can appear at any nesting level — top-level, inside nested objects, or as array items:

    const schema = {
    // Top-level enum: inferred as "active" | "inactive"
    status: { type: "enum", values: ["active", "inactive"] },

    // Nullable enum: inferred as "home" | "work" | "other" | undefined
    category: { type: "enum", values: ["home", "work", "other"], nullable: true },

    // Enum inside a nested object
    geo: {
    type: "object",
    fields: {
    accuracy: { type: "enum", values: ["precise", "approximate"] }
    }
    },

    // Array of enum values: inferred as ("admin" | "user")[]
    roles: { type: "array", items: { type: "enum", values: ["admin", "user"] } }
    } as const satisfies ObjectSchema;

    The schema must be declared with as const satisfies ObjectSchema so TypeScript preserves the literal string values for type inference. At runtime, providing an invalid value (e.g., status: "unknown") throws a ValidationError.

    Use { type: "discriminatedUnion", discriminator: "...", variants: { ... } } to define a field that is a tagged union of object types. Each variant is an ObjectSchema keyed by its discriminator value. The discriminator key is automatically included in each variant's inferred type as a string literal.

    const drawingSchema = {
    shape: {
    type: "discriminatedUnion",
    discriminator: "kind",
    variants: {
    circle: { radius: { type: "number" } },
    square: { side: { type: "number" } }
    }
    }
    } as const satisfies ObjectSchema;

    @Entity
    class Drawing extends MyTable {
    declare readonly type: "Drawing";

    @ObjectAttribute({ alias: "Drawing", schema: drawingSchema })
    public readonly drawing: InferObjectSchema<typeof drawingSchema>;
    }

    // TypeScript infers:
    // drawing.shape → { kind: "circle"; radius: number } | { kind: "square"; side: number }

    Unlike nested "object" fields, discriminated union fields can be nullable (nullable: true) because they always use full replacement on update rather than document path expressions:

    // Replaces the entire shape field — no partial merge
    await Drawing.update("123", {
    drawing: { shape: { kind: "square", side: 10 } }
    });

    Discriminated unions can be used as array items. Each element in the array is validated and serialized using variant-aware logic:

    const dashboardSchema = {
    widgets: {
    type: "array",
    items: {
    type: "discriminatedUnion",
    discriminator: "type",
    variants: {
    "metric-card": {
    label: { type: "string" },
    value: { type: "number" }
    },
    chart: {
    title: { type: "string" },
    chartType: { type: "enum", values: ["bar", "line", "pie"] }
    }
    }
    }
    }
    } as const satisfies ObjectSchema;

    // TypeScript infers:
    // dashboard.widgets → Array<
    // | { type: "metric-card"; label: string; value: number }
    // | { type: "chart"; title: string; chartType: "bar" | "line" | "pie" }
    // >

    Arrays of discriminated unions use full replacement on update (the entire array is replaced), consistent with all array fields.

    Scoping constraints:

    • Supported at the ObjectAttribute root level, as fields within an ObjectSchema, and as array items
    • Not supported nested inside other discriminated unions

    Define foreign keys in order to support @BelongsTo relationships. A foreign key is required for @HasOne and @HasMany relationships.

    • The alias option allows you to specify the attribute name as it appears in the DynamoDB table, different from your class property name.
    • Set nullable foreign key attributes as optional for optimal type safety
    • Attempting to remove an entity from a non-nullable foreign key will result in a NullConstrainViolationError
    • Always provide the referenced entity class to @ForeignKeyAttribute (for example @ForeignKeyAttribute(() => Customer)); this allows DynaRecord to enforce referential integrity even when no relationship decorator is defined.
    • Create and Update automatically add DynamoDB condition checks for standalone foreign keys (those without a relationship decorator) to ensure the referenced entity exists, enabling referential integrity even when no denormalised access pattern is required.
    import {
    Entity,
    ForeignKeyAttribute,
    ForeignKey,
    NullableForeignKey,
    BelongsTo
    } from "dyna-record";

    @Entity
    class Assignment extends MyTable {
    declare readonly type: "Assignment";

    @ForeignKeyAttribute(() => Course)
    public readonly courseId: ForeignKey<Course>;

    @BelongsTo(() => Course, { foreignKey: "courseId" })
    public readonly course: Course;
    }

    @Entity
    class Course extends MyTable {
    declare readonly type: "Course";

    @ForeignKeyAttribute(() => Teacher, { nullable: true })
    public readonly teacherId?: NullableForeignKey<Teacher>; // Set as optional

    @BelongsTo(() => Teacher, { foreignKey: "teacherId" })
    public readonly teacher?: Teacher; // Set as optional because its linked through NullableForeignKey
    }

    Dyna-Record supports defining relationships between entities such as @HasOne, @HasMany, @BelongsTo and @HasAndBelongsToMany. It does this by de-normalizing records to each of its related entities partitions.

    A relationship can be defined as nullable or non-nullable. Non-nullable relationships will be enforced via transactions and violations will result in NullConstraintViolationError

    Docs

    import {
    Entity,
    ForeignKeyAttribute,
    ForeignKey,
    BelongsTo,
    HasOne
    } from "dyna-record";

    @Entity
    class Assignment extends MyTable {
    declare readonly type: "Assignment";

    // 'assignmentId' must be defined on associated model
    @HasOne(() => Grade, { foreignKey: "assignmentId" })
    public readonly grade: Grade;
    }

    @Entity
    class Grade extends MyTable {
    declare readonly type: "Grade";

    @ForeignKeyAttribute(() => Assignment)
    public readonly assignmentId: ForeignKey<Assignment>;

    // 'assignmentId' Must be defined on self as ForeignKey or NullableForeignKey
    @BelongsTo(() => Assignment, { foreignKey: "assignmentId" })
    public readonly assignment: Assignment;
    }

    Docs

    import { Entity, NullableForeignKey, BelongsTo, HasMany } from "dyna-record";

    @Entity
    class Teacher extends MyTable {
    declare readonly type: "Teacher";

    // 'teacherId' must be defined on associated model
    @HasMany(() => Course, { foreignKey: "teacherId" })
    public readonly courses: Course[];
    }

    @Entity
    class Course extends MyTable {
    declare readonly type: "Course";

    @ForeignKeyAttribute(() => Teacher, { nullable: true })
    public readonly teacherId?: NullableForeignKey<Teacher>; // Mark as optional

    // 'teacherId' Must be defined on self as ForeignKey or NullableForeignKey
    @BelongsTo(() => Teacher, { foreignKey: "teacherId" })
    public readonly teacher?: Teacher;
    }

    By default, a HasMany relationship is bi-directional—records are denormalized into both the parent and child entity partitions. This setup supports access patterns that allow each entity to retrieve its associated records. However, updating a HasMany entity requires updating its denormalized record in every associated partition, which can lead to issues given DynamoDB's 100-item transaction limit.

    To mitigate this, you can specify uniDirectional in the HasMany decorator and remove the BelongsTo relationship from the child entity. With this configuration, only the parent-to-child access pattern is supported.

    import { Entity, NullableForeignKey, BelongsTo, HasMany } from "dyna-record";

    @Entity
    class Teacher extends MyTable {
    declare readonly type: "Teacher";

    // 'teacherId' must be defined on associated model
    @HasMany(() => Course, { foreignKey: "teacherId", uniDirectional: true })
    public readonly courses: Course[];
    }

    @Entity
    class Course extends MyTable {
    declare readonly type: "Course";

    @ForeignKeyAttribute(() => Teacher, { nullable: true })
    public readonly teacherId?: NullableForeignKey<Teacher>; // Mark as optional
    }

    Docs

    HasAndBelongsToMany relationships require a JoinTable class. This represents a virtual table to support the relationship

    import {
    Entity,
    JoinTable,
    ForeignKey,
    HasAndBelongsToMany
    } from "dyna-record";

    class StudentCourse extends JoinTable<Student, Course> {
    public readonly studentId: ForeignKey;
    public readonly courseId: ForeignKey;
    }

    @Entity
    class Course extends MyTable {
    declare readonly type: "Course";

    @HasAndBelongsToMany(() => Student, {
    targetKey: "courses",
    through: () => ({ joinTable: StudentCourse, foreignKey: "courseId" })
    })
    public readonly students: Student[];
    }

    @Entity
    class Student extends OtherTable {
    declare readonly type: "Student";

    @HasAndBelongsToMany(() => Course, {
    targetKey: "students",
    through: () => ({ joinTable: StudentCourse, foreignKey: "studentId" })
    })
    public readonly courses: Course[];
    }

    Docs

    The create method is used to insert a new record into a DynamoDB table. This method automatically handles key generation (using v4 UUIDs or a custom id field if IdAttribute is set), timestamps for createdAt and updatedAt fields, and the management of relationships between entities. It leverages AWS SDK's TransactWriteCommand for transactional integrity, ensuring either complete success or rollback in case of any failure. The method handles conditional checks to ensure data integrity and consistency during creation. If a foreignKey is set on create, dyna-record will de-normalize the data required in order to support the relationship

    To use the create method, call it on the model class you wish to create a new record for. Pass the properties of the new record as an object argument to the method. Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation.

    const myModel: MyModel = await MyModel.create({
    someAttr: "123",
    otherAttr: 456,
    someDate: new Date("2024-01-01")
    });
    const grade: Grade = await Grade.create({
    gradeValue: "A+",
    assignmentId: "123",
    studentId: "456"
    });

    By default, when creating entities with foreign key references, dyna-record performs condition checks to ensure that referenced entities exist. This prevents creating entities with invalid foreign key references. However, in high-contention or high-throughput systems where the same foreign key may be referenced in parallel operations, these condition checks can fail due to transaction conflicts. In scenarios such as bulk imports or when you've already verified the references, you may want to skip these checks to prevent such failures.

    To skip referential integrity checks, pass an options object as the second parameter with referentialIntegrityCheck: false:

    const grade: Grade = await Grade.create(
    {
    gradeValue: "A+",
    assignmentId: "123",
    studentId: "456"
    },
    { referentialIntegrityCheck: false }
    );

    Note: When referentialIntegrityCheck is set to false, the condition checks that verify foreign key references exist are skipped. This means you can create entities even if the referenced entities don't exist, which may lead to data integrity issues. Use this option with caution.

    The method is designed to throw errors under various conditions, such as transaction cancellation due to failed conditional checks. For instance, if you attempt to create a Grade for an Assignment that already has one, the method throws a TransactionWriteFailedError.

    • Automatic Timestamp Management: The createdAt and updatedAt fields are managed automatically and reflect the time of creation and the last update, respectively.
    • Automatic ID Generation: Each entity created gets a unique id as a v4 uuid.
      • This can be customized IdAttribute to support custom id attributes
    • Relationship Management: The ORM manages entity relationships through DynamoDB's single-table design patterns, creating and maintaining the necessary links between related entities.
    • Conditional Checks: To ensure data integrity, the create method performs various conditional checks, such as verifying the existence of entities that new records relate to.
    • Error Handling: Errors during the creation process are handled gracefully, with specific errors thrown for different failure scenarios, such as conditional check failures or transaction cancellations.

    Docs

    Retrieve a single record by its primary key.

    findById performs a direct lookup for an entity based on its primary key. It utilizes the GetCommand from AWS SDK's lib-dynamodb to execute a consistent read by default, ensuring the most recent data is fetched. Moreover, it supports eagerly loading related entities through the include option, making it easier to work with complex data relationships. findById provides strong typing for both the fetched entity and any included associations, aiding in development-time checks and editor autocompletion.

    To retrieve an entity, simply call findById on the model class with the ID of the record you wish to find.

    If no record is found matching the provided ID, findById returns undefined. This behavior is consistent across all usages, whether or not related entities are included in the fetch.

    const course = await Course.findById("123");

    // user.id; - ok for any attribute
    // user.teacher; - Error! teacher relationship was not included in query
    // user.assignments; - Error! assignments relationship was not included in query
    const course = await Course.findById("123", {
    include: [{ association: "teacher" }, { association: "assignments" }]
    });

    // user.id; - ok for any attribute
    // user.teacher - ok because teacher is in include
    // user.assignments - ok because assignments is in include

    Docs

    The query method is a versatile tool for querying data from DynamoDB tables using primary key conditions and various optional filters. This method enables fetching multiple items that match specific criteria, making it ideal for situations where more than one item needs to be retrieved based on attributes of the primary key (partition key and sort key).

    There are two main patterns; query by id and query by primary key

    To query items using the id, simply pass the partition key value as the first parameter. This fetches all items that share the same partition key value.

    The result will be an array of the entity or related entities that match the filters

    Querying using the id will abstract away setting up the partition key conditions.

    const customers = await Customer.query("123");
    

    Query by partition key and sort key

    const result = await Customer.query("123", {
    skCondition: "Order"
    });

    To be more precise to the underlying data, you can specify the partition key and sort key directly. The keys here will be the partition and sort keys defined on the table class. The sk value is typed to only accept valid entity names from the partition.

    const orders = await Customer.query({
    pk: "Customer#123",
    sk: { $beginsWith: "Order" }
    });

    The query method supports advanced filtering using the filter option. This allows for more complex queries, such as filtering items by attributes other than the primary key.

    const result = await Course.query(
    {
    myPk: "Course|123"
    },
    {
    filter: {
    type: ["Assignment", "Teacher"],
    createdAt: { $beginsWith: "202" },
    $or: [
    {
    name: "Potions",
    updatedAt: { $beginsWith: "2023-02-15" }
    },
    {
    type: "Assignment",
    createdAt: { $beginsWith: "2021-09-15T" }
    },
    {
    id: "123"
    }
    ]
    }
    }
    );

    When using @ObjectAttribute, you can filter on nested Map fields using dot-path notation and check List membership using the $contains operator.

    Use dot notation to filter on fields within an @ObjectAttribute. All standard filter operators work with dot-paths: equality, $beginsWith, and IN (array of values).

    // Equality on a nested field
    const result = await Store.query("123", {
    filter: { "address.city": "Springfield" }
    });

    // $beginsWith on a nested field
    const result = await Store.query("123", {
    filter: { "address.street": { $beginsWith: "123" } }
    });

    // IN on a nested field
    const result = await Store.query("123", {
    filter: { "address.city": ["Springfield", "Shelbyville"] }
    });

    // Deeply nested fields
    const result = await Store.query("123", {
    filter: { "address.geo.lat": 40 }
    });

    Use $contains to check if a List attribute contains a specific element, or if a string attribute contains a substring. Works on both top-level attributes and nested fields via dot-path.

    // Check if a List contains an element
    const result = await Store.query("123", {
    filter: { "address.tags": { $contains: "home" } }
    });

    // Check if a top-level string contains a substring
    const result = await Store.query("123", {
    filter: { name: { $contains: "john" } }
    });

    Dot-path filters and $contains work with all existing AND/OR filter combinations.

    const result = await Store.query("123", {
    filter: {
    "address.city": "Springfield",
    "address.geo.lat": 40,
    $or: [
    { "address.tags": { $contains: "home" } },
    { name: { $beginsWith: "Main" } }
    ]
    }
    });

    Query filters are strongly typed based on the entities in the queried partition. A partition includes the entity itself plus all entities reachable through its declared relationships (@HasMany, @HasOne, @BelongsTo, @HasAndBelongsToMany). For example, if Customer has @HasMany(() => Order) and @HasOne(() => ContactInformation), then Customer's partition entities are Customer, Order, and ContactInformation.

    The type system validates:

    • Filter attribute keys: Only attributes that exist on the entity or its related entities are accepted. Relationship property names, partition keys, and sort keys are excluded.
    • type field values: The type field only accepts entity names from the partition — the entity itself and its declared relationships. Entities from other tables or unrelated entities on the same table are rejected.
    • Sort key values: Both skCondition and the sk property in key conditions only accept entity names from the partition. This matches dyna-record's single-table design where sort key values always start with an entity class name.
    • SK-scoped filters: When skCondition narrows to specific entities, the filter parameter is scoped to only those entities' attributes. For example, skCondition: { $beginsWith: "Order" } restricts the filter to Order's attributes — using lastFour (a PaymentMethod attribute) produces a compile error.
    • type narrowing in $or: Each $or element is independently narrowed. When an $or block specifies type: "Order", only Order's attributes are allowed in that block.
    • Dot-path keys: Nested @ObjectAttribute fields are available as typed filter keys using dot notation (e.g., "address.city").
    // Valid: 'name' exists on Customer, 'lastFour' on PaymentMethod
    await Customer.query("123", {
    filter: { name: "John", lastFour: "1234" }
    });

    // Error: 'nonExistent' is not an attribute on any entity in Customer's partition
    await Customer.query("123", {
    filter: { nonExistent: "value" } // Compile error
    });

    // Error: 'orders' is a relationship property, not a filterable attribute
    await Customer.query("123", {
    filter: { orders: "value" } // Compile error
    });
    // Valid entity names only
    await Customer.query("123", {
    filter: { type: "Order" } // OK: "Order" is in Customer's partition
    });

    await Customer.query("123", {
    filter: { type: "NonExistent" } // Compile error
    });

    // Array form (IN operator) accepts valid entity names
    await Customer.query("123", {
    filter: { type: ["Order", "PaymentMethod"] }
    });

    Each $or element narrows independently based on its own type value:

    await Customer.query("123", {
    filter: {
    $or: [
    { type: "Order", orderDate: "2023" }, // OK: orderDate is on Order
    { type: "PaymentMethod", lastFour: "1234" } // OK: lastFour is on PaymentMethod
    ]
    }
    });

    // Error in $or: lastFour is not an attribute on Order
    await Customer.query("123", {
    filter: {
    $or: [
    { type: "Order", lastFour: "1234" } // Compile error
    ]
    }
    });

    When querying a partition with no filter or sort key condition, the return type is a union of the entity itself and all its related entities:

    // Return type: Array<EntityAttributesInstance<Customer> | EntityAttributesInstance<Order>
    // | EntityAttributesInstance<PaymentMethod> | EntityAttributesInstance<ContactInformation>>
    const results = await Customer.query("123");

    When the filter specifies a type value, the return type automatically narrows to only the matching entities:

    // Return type: Array<EntityAttributesInstance<Order>>
    const orders = await Customer.query("123", {
    filter: { type: "Order" }
    });

    orders[0]?.orderDate; // OK: orderDate is accessible

    // Return type: Array<EntityAttributesInstance<Order> | EntityAttributesInstance<PaymentMethod>>
    const mixed = await Customer.query("123", {
    filter: { type: ["Order", "PaymentMethod"] }
    });

    Sort key values are typed to only accept valid entity names from the partition, matching dyna-record's single-table design where SK values always start with an entity class name. This applies to both the skCondition option and the sk property in key conditions:

    // Both forms validate sort key values against partition entity names

    // skCondition option (string form)
    await Customer.query("123", { skCondition: "Order" }); // OK
    await Customer.query("123", { skCondition: "Order#123" }); // OK
    await Customer.query("123", { skCondition: { $beginsWith: "Order" } }); // OK
    await Customer.query("123", { skCondition: "NonExistent" }); // Compile error

    // sk property in key conditions (object form)
    await Customer.query({ pk: "Customer#123", sk: "Order" }); // OK
    await Customer.query({ pk: "Customer#123", sk: "Order#001" }); // OK
    await Customer.query({ pk: "Customer#123", sk: { $beginsWith: "Order" } }); // OK
    await Customer.query({ pk: "Customer#123", sk: "NonExistent" }); // Compile error

    Return type narrowing works with skCondition when the value is an exact entity name or $beginsWith with an entity name:

    // skCondition narrows the return type
    const orders = await Customer.query("123", { skCondition: "Order" });
    // orders is Array<EntityAttributesInstance<Order>>

    const orders2 = await Customer.query("123", {
    skCondition: { $beginsWith: "Order" }
    });
    // orders2 is Array<EntityAttributesInstance<Order>>

    // Suffix prevents narrowing (delimiter is configurable)
    const specific = await Customer.query("123", { skCondition: "Order#123" });
    // specific is QueryResults<Customer> (full union)

    $beginsWith also accepts partial entity name prefixes that match multiple entity types. When a prefix matches more than one entity name, the return type and filter are scoped to the union of all matching entities:

    // "C" matches both "Customer" and "ContactInformation"
    const results = await Customer.query("123", {
    skCondition: { $beginsWith: "C" }
    });
    // results is Array<EntityAttributesInstance<Customer> | EntityAttributesInstance<ContactInformation>>

    // When one entity name is a prefix of another (e.g., PaymentMethod / PaymentMethodProvider):
    const results = await PaymentMethod.query("123", {
    skCondition: { $beginsWith: "PaymentMethod" }
    });
    // results includes both PaymentMethod and PaymentMethodProvider

    // Longer prefix narrows further
    const results = await PaymentMethod.query("123", {
    skCondition: { $beginsWith: "PaymentMethodP" }
    });
    // results is Array<EntityAttributesInstance<PaymentMethodProvider>>

    // Prefixes that don't match any entity name are rejected
    await Customer.query("123", {
    skCondition: { $beginsWith: "X" } // Compile error
    });

    When skCondition narrows to specific entities, the filter parameter is automatically scoped to only those entities' attributes. This prevents filtering on attributes from entities that can't appear in the results:

    // SK narrows to Order — filter accepts only Order attributes (+ default fields)
    await Customer.query("123", {
    skCondition: { $beginsWith: "Order" },
    filter: { orderDate: "2023", customerId: "c1" } // OK: both are Order attributes
    });

    // Error: lastFour is a PaymentMethod attribute, not available when SK scopes to Order
    await Customer.query("123", {
    skCondition: { $beginsWith: "Order" },
    filter: { lastFour: "1234" } // Compile error
    });

    // $or blocks are also scoped by SK
    await Customer.query("123", {
    skCondition: { $beginsWith: "Order" },
    filter: {
    $or: [
    { type: "Order", orderDate: "2023" }, // OK
    { type: "PaymentMethod", lastFour: "1234" } // Compile error: PaymentMethod outside SK scope
    ]
    }
    });

    // When $beginsWith matches multiple entities, filter accepts attributes from all matches
    await Customer.query("123", {
    skCondition: { $beginsWith: "C" }, // matches Customer and ContactInformation
    filter: { name: "John", email: "j@test.com" } // OK: name is on Customer, email on ContactInformation
    });

    When using the object key form, sort key values are validated but the return type is not narrowed. Use filter: { type: "Order" } alongside key conditions for return type narrowing:

    // sk is validated but does NOT narrow the return type
    const results = await Customer.query({ pk: "Customer#123", sk: "Order" });
    // results is QueryResults<Customer> (full union)

    // Combine with filter type for return type narrowing
    const orders = await Customer.query(
    { pk: "Customer#123", sk: { $beginsWith: "Order" } },
    { filter: { type: "Order" } }
    );
    // orders is Array<EntityAttributesInstance<Order>>

    Note: Return type narrowing applies to the top-level type filter field, type values within $or elements, and to the skCondition option. When $or elements specify type values, the return type narrows to the union of those entity types. The sk property in key conditions validates values but does not narrow return types. Index queries ({ indexName: "..." }) use untyped filters.

    Filter key narrowing: When no type is specified, the return type automatically narrows based on which entities have the filtered attributes. For example, filter: { orderDate: "2023" } narrows to Order if only Order has orderDate. In $or blocks, each element narrows independently — by type if present, or by filter keys otherwise — and the return type is the union across all blocks.

    AND intersection: Since DynamoDB ANDs top-level filter conditions with $or blocks, the return type reflects this. When both top-level conditions and $or blocks independently narrow to specific entity sets, the return type is their intersection. If no entity satisfies both (e.g., { orderDate: "2023", $or: [{ lastFour: "1234" }] } where orderDate is on Order and lastFour is on PaymentMethod), the return type is never[] — correctly indicating that no records can match.

    SK intersection: skCondition is always intersected with filter-based narrowing because it is a DynamoDB key condition that physically limits which items are scanned. When both skCondition and a filter narrow to different entity sets, the return type is their intersection.

    For querying based on secondary indexes, you can specify the index name in the options.

    const result = await Customer.query(
    {
    pk: "Customer#123",
    sk: { $beginsWith: "Order" }
    },
    { indexName: "myIndex" }
    );

    Docs

    The update method enables modifications to existing items in a DynamoDB table. It supports updating simple attributes, handling nullable fields, and managing relationships between entities, including updating and removing foreign keys. Only attributes defined on the model can be updated, and will be enforced via types and runtime schema validation.

    await Customer.update("123", {
    name: "New Name",
    address: "New Address"
    });

    Note: Attempting to remove a non nullable attribute will result in a NullConstraintViolationError

    await ContactInformation.update("123", {
    email: "new@example.com",
    phone: null
    });

    To update the foreign key reference of an entity to point to a different entity, simply pass the new foreign key value

    await PaymentMethod.update("123", {
    customerId: "456"
    });

    Nullable foreign key references can be removed by setting them to null

    Note: Attempting to remove a non nullable foreign key will result in a NullConstraintViolationError

    await Pet.update("123", {
    ownerId: null
    });

    Object attribute updates are partial — only the fields you provide are modified, and omitted fields are preserved. This uses DynamoDB document path expressions under the hood (e.g., SET #address.#street = :address_street) for efficient field-level updates.

    // Only updates street — city, zip, geo, etc. are preserved
    await Store.update("123", {
    address: { street: "456 New St" }
    });

    Nested objects are recursively merged:

    // Only updates lat — lng and accuracy are preserved
    await Store.update("123", {
    address: {
    geo: { lat: 42 }
    }
    });

    Nullable fields within the object can be removed by setting them to null:

    // Removes zip, preserves all other fields
    await Store.update("123", {
    address: { zip: null }
    });

    Arrays within objects are full replacement (not merged):

    // Replaces the entire tags array
    await Store.update("123", {
    address: { tags: ["new-tag-1", "new-tag-2"] }
    });

    Discriminated unions within objects are also full replacement (not merged):

    // Replaces the entire shape — switches from circle to square
    await Drawing.update("123", {
    drawing: { shape: { kind: "square", side: 10 } }
    });

    The instance update method returns a deep-merged result, preserving existing fields:

    const updated = await storeInstance.update({
    address: { street: "New Street" }
    });
    // updated.address.city → still has the original value

    There is an instance update method that has the same rules above, but returns the full updated instance.

    const updatedInstance = await petInstance.update({
    ownerId: null
    });

    By default, when updating entities with foreign key references, dyna-record performs condition checks to ensure that referenced entities exist. This prevents updating entities with invalid foreign key references. However, in high-contention or high-throughput systems where the same foreign key may be referenced in parallel operations, these condition checks can fail due to transaction conflicts. In scenarios such as bulk updates or when you've already verified the references, you may want to skip these checks to prevent such failures.

    To skip referential integrity checks, pass an options object as the third parameter with referentialIntegrityCheck: false:

    await PaymentMethod.update(
    "123",
    { customerId: "456" },
    { referentialIntegrityCheck: false }
    );

    For instance methods:

    const updatedInstance = await paymentMethodInstance.update(
    { customerId: "456" },
    { referentialIntegrityCheck: false }
    );

    Note: When referentialIntegrityCheck is set to false, the condition checks that verify foreign key references exist are skipped. This means you can update entities even if the referenced entities don't exist, which may lead to data integrity issues. Use this option with caution.

    Docs

    The delete method is used to remove an entity from a DynamoDB table, along with handling the deletion of associated items in relationships (like HasMany, HasOne, BelongsTo) to maintain the integrity of the database schema.

    await User.delete("user-id");
    

    When deleting entities involved in HasMany or HasOne relationships:

    If a Pet belongs to an Owner (HasMany relationship), deleting the Pet will remove its denormalized records from the Owner's partition. If a Home belongs to a Person (HasOne relationship), deleting the Home will remove its denormalized records from the Person's partition.

    await Home.delete("123");
    

    This deletes the Home entity and its denormalized record with a Person.

    For entities part of a HasAndBelongsToMany relationship, deleting one entity will remove the association links (join table entries) with the related entities.

    If a Book has and belongs to many authors:

    await Book.delete("123");
    

    This deletes a Book entity and its association links with Author entities.

    If deleting an entity or its relationships fails due to database constraints or errors during transaction execution, a TransactionWriteFailedError is thrown, possibly with details such as ConditionalCheckFailedError or NullConstraintViolationError for more specific issues related to relationship constraints or nullability violations.

    dyna-record supports DynamoDB vector search: entities declare a searchable attribute, writes embed it automatically through an embedding provider you supply, and similarity searches run as exactly one SearchVectors operation returning complete typed entity instances. No separate vector store, no hydration pipeline — the vectors live on your table's canonical rows.

    Requirements: vector indexes are an AWS feature of on-demand capacity mode tables only, and searches require an ACTIVE index. dyna-record surfaces the AWS errors directly; it does not attempt workarounds.

    Mark exactly one attribute per entity as its searchable text with the layered @Searchable() decorator and the Searchable property brand. Attributes that searches may filter on are declared with @SearchFilterable() and the SearchFilterable brand — strings, numbers, booleans, enums, and foreign keys qualify (dates and objects do not). Nullable attributes compose: instantiate the brand with the optional form (Filterable<NullableForeignKey<Brand>>, Filterable<Optional<string>>) and the property stays optional — a row where the attribute is absent simply never matches an equality filter on it:

    import DynaRecord, {
    Entity,
    Searchable,
    SearchFilterable,
    StringAttribute,
    ForeignKeyAttribute,
    BelongsTo,
    HasMany,
    type Searchable as SearchableText,
    type SearchFilterable as Filterable,
    type ForeignKey,
    type NullableForeignKey
    } from "dyna-record";

    @Entity
    class Organization extends MyTable {
    declare readonly type: "Organization";

    @StringAttribute({ alias: "Name" })
    public readonly name: string;

    @HasMany(() => Product, { foreignKey: "organizationId" })
    public readonly products: Product[];
    }

    @Entity
    class Product extends MyTable {
    declare readonly type: "Product";

    @Searchable()
    @StringAttribute({ alias: "Description" })
    public readonly description: SearchableText;

    @SearchFilterable()
    @StringAttribute({ alias: "Category" })
    public readonly category: Filterable;

    @SearchFilterable()
    @ForeignKeyAttribute(() => Brand, { alias: "BrandId" })
    public readonly brandId: Filterable<ForeignKey<Brand>>;

    // Nullable filterable: optional on the entity, filterable when present
    @SearchFilterable()
    @ForeignKeyAttribute(() => Supplier, { alias: "SupplierId", nullable: true })
    public readonly supplierId?: Filterable<NullableForeignKey<Supplier>>;

    @ForeignKeyAttribute(() => Organization, { alias: "OrganizationId" })
    public readonly organizationId: ForeignKey<Organization>;

    @BelongsTo(() => Organization, { foreignKey: "organizationId" })
    public readonly organization: Organization;
    }

    Organization's searchable relationships (products) are what its search surfaces infer from — they define the parent-level in: values, result unions, and its scoped index's membership.

    The brands add no friction at call sites — create and update accept plain values. An entity may declare at most one @Searchable attribute; a second is a compile error at the @Entity decorator and a runtime error at metadata initialization.

    To search across multiple fields, compose them yourself into one searchable attribute. dyna-record does not auto-concatenate fields, deliberately: the composed text is exactly what gets embedded (and billed per embed), so field order, separators, and which fields participate all shape search quality — and any change to a composed field would silently trigger a re-embed. Owning the composition keeps the embedded text, and what causes it to change, visible in your code:

    await Product.create({
    name,
    summary,
    // Self-composed search text spanning several fields
    searchText: `${name}\n${summary}`
    });

    Vector indexes are declared on the table class with vectorIndex. The returned construct is the index's search surface and its provisioning definition. The provider is required — dyna-record ships no embedding implementation and no embedding SDK dependency; you own the client, credentials, region, retry, and timeout posture:

    import { TitanTextEmbedV2, type EmbeddingProvider } from "dyna-record";
    import {
    BedrockRuntimeClient,
    InvokeModelCommand
    } from "@aws-sdk/client-bedrock-runtime";

    const bedrock = new BedrockRuntimeClient({ region: "us-west-2" });

    const embed: EmbeddingProvider = async text => {
    const res = await bedrock.send(
    new InvokeModelCommand({
    modelId: TitanTextEmbedV2.name,
    body: JSON.stringify({ inputText: text })
    })
    );
    return JSON.parse(new TextDecoder().decode(res.body)).embedding;
    };

    // Scoped index: searches run within one scope value at a time
    export const orgSearchIndex = MyTable.vectorIndex({
    name: "org-search-index",
    model: TitanTextEmbedV2,
    provider: embed,
    scopedBy: () => Organization,
    include: [() => Review] // members with the FK but no declared inverse relationship
    });

    // Global index: no scope; searches span the whole table's searchable entities
    export const globalSearchIndex = MyTable.vectorIndex({
    name: "global-search-index",
    model: TitanTextEmbedV2,
    provider: embed
    });
    • model takes a pure-data descriptor: the shipped TitanTextEmbedV2 (1024 dimensions), its smaller variants TitanTextEmbedV2Dim512 and TitanTextEmbedV2Dim256 (lower vector storage and write cost for an accuracy trade-off — your provider must request the matching output size, EX: body: JSON.stringify({ inputText: text, dimensions: 512 })), or your own EmbeddingModelDescriptor.
    • scopedBy takes a thunk returning one of your dyna-record entity classes — the scope parent. The parent's foreign key becomes the index HASH, so every search runs within exactly one of that entity's ids. Multi-tenancy is the canonical use (scope by your tenant/organization entity), but any parent whose searches should never cross instances works the same way — a workspace, a project, a store. Index membership is the scope parent's searchable relationships union the include: list.
    • include: adds searchable entities that carry the scoping foreign key but have no declared inverse relationship on the parent. It applies to scoped indexes only — a global index already spans every searchable entity of the table, so there is nothing to include.
    • A global index (no scopedBy) has no HASH and searches the whole table's searchable entities.

    Creating or updating a searchable entity embeds the value synchronously through your provider and writes the vector onto the entity's canonical row inside the operation's single transaction — there is no second write, no eventual-consistency pipeline, and rows are searchable sub-second after the write acknowledges. Denormalized relationship copies never carry the vector.

    • Failure semantics: if the provider rejects or returns the wrong dimensions, the whole write fails with EmbeddingError (the provider error on cause) — no row is ever written searchable-but-not-embedded. Error messages carry entity, attribute, model, and dimension identities only, never your text.
    • Unchanged values skip embedding: updates compare the incoming searchable value against the stored one (read through the pre-update fetch) and skip the provider call and the vector write when it is unchanged. Exception: entities with no relationships have no pre-update fetch to supply the stored value, so an update carrying the searchable attribute embeds unconditionally rather than forcing a new read.
    • forceEmbed overrides the skip: update(id, { description }, { forceEmbed: true }) embeds even when the value appears unchanged. This is the affordance for indexing rows that predate searchability — loop a backfill over existing entities re-saving their own values — and for re-embedding a table after switching embedding models.
    • Clearing: setting a nullable searchable attribute to null (or "") removes the vector — the row leaves the index. Empty text never reaches your provider.
    • Latency: the provider call bounds write latency. Measured against Bedrock Titan V2: ~320 ms p50 / ~400 ms p90 per embed — roughly 7× the DynamoDB write it accompanies. The embed runs concurrently with the operation's existing reads where possible.

    Three surfaces, each compiling to exactly one SearchVectors operation. Results are ordered most-similar-first; each result carries the complete typed entity (entity), a similarity (higher is better, converted per the model's distance function — for COSINE, 1 − score), and the raw AWS score.

    The parent surface (Parent.search(scopeId, query, options)) is available when exactly one index is scoped by the class. Starting from the simplest call and adding one option at a time:

    // No options: searches the organization's whole scoped index — Products,
    // and Reviews via the include list — returning the 10 most similar (the
    // default topK). Results are typed by the searchable relationships
    // (Product); an include-member result is discriminated via entity.type
    const results = await Organization.search("orgId", "waterproof hiking boots");
    // results is Array<SearchResult<Product>>

    // in: narrows to one searchable relationship by its property name — only
    // Products are searched, and the results are typed Product accordingly
    const products = await Organization.search("orgId", "waterproof hiking boots", {
    in: "products"
    });
    // products is Array<SearchResult<Product>> — and with several searchable
    // relationships, omitting in: widens results to the union of their targets

    // filter: equality conditions on @SearchFilterable attributes, applied in
    // the same single operation (no post-fetch filtering)
    const footwear = await Organization.search("orgId", "waterproof hiking boots", {
    in: "products",
    filter: { category: "Footwear" }
    });

    // Filterable foreign keys narrow by relationship in the same way — products
    // of one brand, within the organization's scope
    const brandFootwear = await Organization.search("orgId", "hiking boots", {
    in: "products",
    filter: { category: "Footwear", brandId: "brand-id" }
    });

    // topK: how many results to return — default 10, max 100 (an AWS limit;
    // there is no pagination)
    const topFifty = await Organization.search("orgId", "hiking boots", {
    topK: 50
    });

    // Every result carries the typed entity, similarity, and raw score;
    // discriminate on entity.type when the search spans multiple entity types
    results.forEach(({ entity, similarity, score }) => {
    if (entity.type === "Product") console.log(entity.description, similarity);
    });

    The third surface is the index construct itself — the value vectorIndex(...) returned. It runs the same search as the parent surfaces, but is anchored on the index rather than an entity class, which makes it the right surface in three situations:

    • It is always unambiguous. The parent surfaces exist only while exactly one index is scoped by that class; if a parent ever scopes a second index, they error with guidance and the construct — which names its index — is the way to search.
    • It is the only surface for global indexes, which have no scope parent to hang a method off of.
    • It is the only surface where include: members are reachable by name. Members added through include: have no relationship property on the scope parent, so the parent surfaces' in: (relationship names) cannot name them. The construct's in: takes member entity names instead — which covers them.

    The signature follows the index's shape: scoped constructs take the scope value first (they are searchable only within one scope value); global constructs take the query first and reject a scope id.

    // Scoped construct, no options: same search as Organization.search("orgId", ...)
    const all = await orgSearchIndex.search("orgId", "waterproof hiking boots");
    // all is Array<SearchResult<Product> | SearchResult<Review>> — the full
    // member union, include: members included

    // Scoped construct with in: — note the vocabulary difference: member entity
    // names here, not relationship property names. Review is an include: member,
    // reachable by name only on this surface
    const reviews = await orgSearchIndex.search("orgId", "sizing runs small", {
    in: "Review"
    });
    // reviews is Array<SearchResult<Review>>

    // Filters compile identically on every surface
    const filtered = await orgSearchIndex.search("orgId", "hiking boots", {
    in: "Product",
    filter: { category: "Footwear" }
    });

    // Global construct: query first, no scope id — searches every searchable
    // entity of the table
    const everything = await globalSearchIndex.search("hiking boots");
    // everything is SearchResults — the base result type; a global index's
    // member set is only known at runtime, so narrow via entity.type

    // in: works on global indexes too. The member set is only known at runtime
    // (any searchable entity of the table), so the name is validated at runtime
    // rather than by the type system
    const articles = await globalSearchIndex.search("hiking boots", {
    in: "Article"
    });

    Every surface also accepts a precomputed vector in place of query text: { vector: number[] }. The embedding provider is not called — useful when you already hold an embedding (computed by a pipeline outside the request path, cached from an earlier query, or produced in a batch) or when reusing one query embedding across several searches, since each text search bills and waits for its own provider call:

    // Vector in place of text, on any surface — the provider is never called
    await globalSearchIndex.search({ vector: myVector });

    // One provider call, reused across searches
    const vector = await embed("waterproof hiking boots");

    await orgSearchIndex.search("orgId", { vector }, { in: "Product" });
    await orgSearchIndex.search("orgId", { vector }, { in: "Review" });

    The vector must come from the same model and dimension count the index was provisioned with — a different model's embedding produces meaningless similarity scores rather than an error, which dyna-record cannot detect. What it can detect, it does: a vector whose length differs from the index's declared dimensions is rejected with a ValidationError before any AWS call.

    Both sides of every search are inferred from your declarations — the same brand-driven inference that powers typed query filters:

    • Inputs: in: accepts only the parent's searchable relationship names (the index construct accepts its member entity names — including include: members, which have no relationship property on the parent to name). filter keys narrow to exactly the searched entities' @SearchFilterable attributes, and narrow further when in: is present; non-filterable attributes and unsupported operator shapes are compile errors. Scoped index constructs require the scope value first; global constructs reject one. Calling search on a parent with no searchable relationships is a compile error (and a runtime error in plain JS).
    • Responses: the return type is inferred, not declared. in: present → Array<SearchResult<ThatEntity>>; in: omitted → the widened union across every searched entity (Array<SearchResult<A> | SearchResult<B>>), where only shared attributes are accessible until you discriminate on entity.type — exactly like query result narrowing. Global index constructs return the base SearchResults type, since their member set is only known at runtime.

    DynamoDB's search condition grammar is an equality-only conjunction: attribute = value conditions joined by AND, at most one condition per attribute. This is an AWS constraint, not a library choice — $or, $beginsWith, $contains, IN arrays, and range operators are rejected at compile time and at runtime.

    Two mechanisms narrow a search, and they are not interchangeable:

    • scopedBy is the enforced isolation boundary. The HASH equality is set by the library on every search of a scoped index and cannot be omitted or overridden — a tenant-scoped index physically cannot search across tenants.
    • filter narrows within a boundary. Filterable foreign keys enable sub-scope narrowing (products within a brand, within the organization scope) in the same single operation. Never use filter as tenant separation on a global index — it is a narrowing convenience, not an isolation mechanism.

    Search filters are runtime-guarded for untrusted input: unknown keys, non-filterable attributes, unsupported operator shapes, and mistyped values are rejected before any AWS call. Still, allowlist keys before spreading request input into filter — a valid-but-unintended filterable key is indistinguishable from an intended one.

    Vector indexes are infrastructure. dyna-record declares and validates the configuration and executes searches, but does not create the index — provision it with your IaC tool using the serialized contract from metadata():

    MyTable.metadata().vectorIndexes;
    // [{
    // name: "org-search-index",
    // model: "amazon.titan-embed-text-v2:0",
    // vectorAttribute: "__dyna_vector",
    // dimensions: 1024,
    // distanceFunction: "COSINE",
    // projection: "ALL",
    // searchSchema: { hash: "OrganizationId", inlineFilters: ["Category", "Type"] },
    // fingerprint: "…",
    // scopedBy: "Organization"
    // }]

    Notes on the contract:

    • Search-schema entries are table aliases (the names stored in DynamoDB), and each must also appear in the table's AttributeDefinitions. The entity type discriminator is auto-declared as an inline filter on every index; DynamoDB allows at most 18 inline filters per index (the HASH does not count).
    • The provider never appears in serialized metadata — only the model descriptor's name. No credentials or client configuration can leak through metadata().
    • Index configuration is immutable in DynamoDB. Adding or removing any @Searchable/@SearchFilterable declaration on a live index is a destructive re-provision: the index must be deleted and recreated, and because vectors are written at write time, the recreated index's corpus recovers only as rows are re-written. The fingerprint field exists for IaC to detect that a decorator change implies replacement before it happens.
    • Adopting vector search on an existing table: pre-existing rows have no vector and are unsearchable until their next write. dyna-record ships no backfill — re-write rows through update at your own pace to bring them into the index.
    • Configuration is validated when metadata initializes (lazily, at the first operation, with the failure cached and re-thrown on every subsequent operation). Calling MyTable.metadata() during startup fails fast at deploy time instead of on the first live request.
    • dynamodb:SearchVectors is a new IAM action — existing policies granting DynamoDB read access do not include it. Roles that call search need it in addition to the usual read/write actions dyna-record already requires.
    • Your embedding provider needs its own permissions — for the Bedrock example above, bedrock:InvokeModel on the model, with model access enabled in your account and region. dyna-record never touches these credentials; the provider function owns them.
    • SearchVectors resolves to a separate endpoint (search-dynamodb.<region>.amazonaws.com, not the standard DynamoDB endpoint). If your network restricts egress through a VPC endpoint, proxy, or allowlist, permit the search hostname too — otherwise writes succeed and only searches fail, with a connection error that does not indicate the cause.

    Vector search changes the write and read economics of searchable rows — dyna-record's design choices here exist to manage that, so it's worth understanding what they can and cannot save you:

    • Vector writes dominate, and the unchanged-value skip is the mitigation. DynamoDB bills a full vector write (max(1024, 4 × dimensions) bytes — 4 KB at Titan's 1024 dimensions) on every material write to a vector-bearing row, even updates that don't touch the searchable attribute. dyna-record's unchanged-value comparison avoids the embedding call on unchanged values; the vector write billing on other updates is inherent to keeping the vector on the row.
    • dyna-record truncates embeddings to 7 significant digits (float32 precision — verified zero effect on search results). This roughly halves the searchable row's storage and ordinary write-capacity footprint. It does not reduce vector billing — no precision trick does.
    • Searchable rows are permanently larger, and reads bill on full item size. dyna-record excludes the vector from findById, query, and internal pre-fetches via projection, which saves bandwidth and latency — but DynamoDB bills reads on the full item regardless of projection, so every ordinary read of a searchable row costs more forever. Factor this in before marking high-read-traffic entities searchable.

    Dyna-Record integrates type safety into your DynamoDB interactions, reducing runtime errors and enhancing code quality.

    • Entity Type Declaration: The @Entity decorator enforces that each entity declares readonly type as a string literal matching the class name (declare readonly type: "MyEntity"). This is required for compile-time query type safety.
    • Attribute Type Enforcement: Ensures that the data types of attributes match their definitions in your entities.
    • Method Parameter Checking: Validates method parameters against entity definitions, preventing invalid operations.
    • Relationship Integrity: Automatically manages the consistency of relationships between entities, ensuring data integrity.
    • Typed Query Filters: Query filter keys are validated against the attributes of entities in the partition. Invalid keys, relationship property names, and non-existent attributes produce compile errors. The type field only accepts valid entity class names.
    • Return Type Narrowing: When a query filter specifies a type value, the return type is automatically narrowed to only the matching entity types instead of the full partition union.
    • $or Element Narrowing: Each element in a $or filter array is independently type-checked based on its own type field, preventing attribute mismatches.
    • Searchable Brands: @Searchable() and @SearchFilterable() require the Searchable/SearchFilterable property brands, so the searchable and filterable sets are known at compile time — search in: values, filter keys, and result unions all derive from them. A second @Searchable attribute on one entity is a compile error at the @Entity decorator.
    • Search Return Type Narrowing: Search results are inferred from the searched membership — the union of searched entity types by default, narrowed to a single entity type when in: is present, mirroring query return type narrowing.
    • Search Surface Availability: search is only callable on classes with at least one relationship to a searchable entity; scoped index constructs require the scope id first while global constructs reject one.
    • Define Clear Entity Relationships: Clearly define how your entities relate to each other for easier data retrieval and manipulation.
    • Use Type Aliases for Foreign Keys: Utilize TypeScript's type aliases for foreign keys to enhance code readability and maintainability.
    • Leverage Type Safety: Take advantage of Dyna-Record's type safety features to catch errors early in development.
    • Define Access Patterns: Dynamo is not as flexible as a relational database. Try to define all access patterns up front.

    To enable debug logging set process.env.DYNA_RECORD_LOGGING_ENABLED to "true". When enabled, dyna-record will log to console the dynamo operations it is performing.