DynamoDB deserves a real ORM
A strongly typed data modeler and active-record style ORM for DynamoDB — relationship-aware, schema-enforced, and fully transactional.
DynamoDB with real relationships
DynamoDB offers unmatched scalability and performance. But relational modeling has traditionally required manual partition key design and hand-rolled adjacency lists — dyna-record manages both for you.
dyna-record combines single-table design with the adjacency list pattern for true relational modeling. Your data is pre-joined across partitions, giving you single-query retrieval without expensive joins.
@Entity
class Teacher extends MyTable {
@StringAttribute()
public name: string;
@HasMany(() => Course, { foreignKey: "teacherId" })
public readonly courses: Course[];
}
// Fetch with related data
const teacher = await Teacher.findById(id, {
include: [{ association: "courses" }]
});A data modeler & ORM
Define your schema, enforce it at runtime, and model relationships — all with a familiar TypeScript interface.
Decorator-based models
Define entities with TypeScript decorators. String, number, boolean, date, and object attributes.
@Entity
class User extends MyTable {
@IdAttribute // Customizable unique ID field
@StringAttribute()
public email: string;
@StringAttribute({ alias: "Username" })
public username: string;
@NumberAttribute({ nullable: true })
public age?: number;
@BooleanAttribute()
public isActive: boolean;
}Foreign key constraints
Referential integrity enforced through DynamoDB condition checks. Invalid references fail before the write.
// Fails: User with ID '123' does not exist
await Order.create({ userId: "123" });
// TransactionWriteFailedError
// @IdAttribute enforces uniqueness
await User.create({ email: "alice@co.com" }); // OK
await User.create({ email: "alice@co.com" }); // FailsExpressive queries
Filter with $beginsWith, $contains, $or, and IN arrays — every condition validated against your entity definition.
const orders = await Customer.query(customerId, {
skCondition: { $beginsWith: "Order" },
filter: {
status: ["PENDING", "SHIPPED"],
$or: [{ city: "NYC" }, { priority: true }]
}
});Compile + runtime safety
The type system rejects invalid input at compile time; runtime schema validation enforces the same contract on live data.
// Compile error — the field does not exist
await User.create({ invalidField: "value" });
// Plain JavaScript callers hit the same contract
await User.create(untypedInput);
// ValidationError from runtime schema validation12345678910111213141516171819202122@Entity
class Order extends MyTable {
@ForeignKeyAttribute(() => User)
public readonly userId: ForeignKey<User>;
@BelongsTo(() => User, { foreignKey: "userId" })
public readonly user: User;
@HasAndBelongsToMany(() => Product, {
targetKey: "orders",
through: () => ({ joinTable: OrderProduct, foreignKey: "orderId" })
})
public readonly products: Product[];
}
// Eager loading in one query
const order = await Order.findById(id, {
include: [
{ association: "user" },
{ association: "products" }
]
});Single-table design, without the complexity
dyna-record automatically manages partitions for each entity and handles denormalization across related partitions. Think of it as pre-joining your data.
All operations are ACID-compliant transactions. Updates propagate atomically to every denormalized copy, ensuring consistency.
Foreign key constraints enforced. Invalid references fail the transaction atomically with TransactionWriteFailedError— no partial writes.
Dive deeper into how dyna-record handles relational modeling on the blog →
Type-aware queries
Filter fields, SK conditions, and include options are all validated at compile time. Results narrow automatically based on your query.
Type-aware SK conditions
SK conditions are validated at compile time. Results automatically narrow to the matching entity type.
// Result type narrows to Order[]
const orders = await Customer.query(customerId, {
skCondition: { $beginsWith: "Order" }
});
// Exact match — narrows to Invoice[]
const invoices = await Customer.query(customerId, {
skCondition: "Invoice"
});Type-checked filter building
Filter field names and value types are validated against your entity definition. Typos and type mismatches are compile errors.
// Field names autocomplete in your IDE
const results = await Student.query(pk, {
filter: {
isActive: true, // boolean field
name: { $contains: "Alice" },// string operation
status: ["ENROLLED", "GRADUATED"] // IN array
}
});
// filter: { invalid: true } -> TS Error!FindById with includes
Load relationships in a single query. The include option is type-checked against defined associations.
const course = await Course.findById(id, {
include: [
{ association: "teacher" }, // HasOne
{ association: "assignments" } // HasMany
]
});
// All loaded in one DynamoDB query
// { association: "typo" } -> TS Error!Narrowed return types
Return types narrow based on your includes. Without includes, relationship fields are not accessible.
// Without includes
const course = await Course.findById(id);
course?.teacher; // TS Error — not included!
// With includes — the type narrows
const full = await Course.findById(id, {
include: [{ association: "teacher" }]
});
full?.teacher.name; // OK — teacher is guaranteedNative vector search, on your data model
Mark an attribute as searchable, point a vector index at your data model, and your entities become semantically searchable — no separate vector database, no sync pipeline. Every create and update embeds the value inside the same ACID transaction, so rows are searchable sub-second after the write.
Scoped indexes build on the foreign keys your relationships already maintain: point one at an Organization, tenant, or workspace entity and its foreign key becomes the index partition.
Bring your own embeddings. The provider is a function you own — dyna-record ships no embedding SDK, no credentials, no lock-in.
See the full guide — provisioning, filters, and the cost model — in the README →
1234567891011121314151617181920212223242526272829303132@Entity
class Organization extends MyTable {
// Searchable relationships define the search contract:
// index membership, legal in: values, and result types
@HasMany(() => Product, { foreignKey: "organizationId" })
public readonly products: Product[];
@HasMany(() => Review, { foreignKey: "organizationId" })
public readonly reviews: Review[];
}
@Entity
class Product extends MyTable {
@Searchable()
@StringAttribute({ alias: "Description" })
public readonly description: Searchable;
@SearchFilterable()
@StringAttribute({ alias: "Category" })
public readonly category: SearchFilterable;
@ForeignKeyAttribute(() => Organization)
public readonly organizationId: ForeignKey<Organization>;
}
// One vector index over your data model
const productSearch = MyTable.vectorIndex({
name: "product-search-index",
model: TitanTextEmbedV2,
provider: embed, // your embedding function
scopedBy: () => Organization
});Strongly typed, request to response
Search inputs are validated against your entity definitions at compile time, and result types widen or narrow to match the request.
Typed requests, inferred responses
Search returns complete, hydrated entity instances with a similarity score. Without in:, results widen to the union of searchable relationship targets — discriminate on entity.type.
const results = await Organization.search(
orgId,
"waterproof hiking boots"
);
// Array<SearchResult<Product> | SearchResult<Review>>
results.forEach(({ entity, similarity }) => {
if (entity.type === "Product") {
entity.description; // narrowed to Product
}
});Results narrow with in:
in: only accepts your searchable relationship names, and the response type narrows to match. Both sides of the contract are checked at compile time.
const products = await Organization.search(
orgId,
"hiking boots",
{ in: "products" }
);
// Array<SearchResult<Product>> — inferred
// { in: "typo" } -> TS Error!Compile-checked filters
Filter keys narrow to exactly the @SearchFilterable attributes of the searched entities — applied in the same single operation, no post-fetch filtering.
await Organization.search(orgId, "hiking boots", {
in: "products",
filter: { category: "Footwear" },
topK: 25
});
// filter: { description: "x" } -> TS Error!
// filter: { category: { $beginsWith: "F" } } -> TS Error!Scoping enforced by the index
Scoped indexes take the scope value first — a tenant-scoped index physically cannot search across tenants. Global indexes reject a scope id entirely.
// Scoped: the scope id comes first
await productSearch.search(orgId, "hiking boots", {
in: "Product"
});
// productSearch.search("hiking boots") -> TS Error!
// Global indexes take no scope id
await globalSearch.search("fresh articles");CRUD, simplified
Every operation is transactional, type-safe, and automatically manages timestamps and relationships.
Insert with validation and foreign key checks
const student = await Student.create({
username: "alice",
email: "alice@school.edu"
});
// Returns Student with id, createdAt, updatedAtRetrieve by id or query, fully typed
// By ID
const student = await Student.findById(id);
// Query with filter
const students = await Student.query("123", {
filter: { isActive: true }
});Partial updates with automatic updatedAt
await Student.update(id, {
email: "newemail@school.edu"
});
// Only the specified fields are updated
// updatedAt set automaticallyRemove with relationship cleanup
await Student.delete(id);
// Removes the entity
// Cleans up denormalized records
// Transactional and atomicRich attribute types
Define your schema with decorators. Each type is validated at runtime and enforced at compile time — including discriminated unions with full type narrowing.
@StringAttribute()string@NumberAttribute()number@BooleanAttribute()boolean@DateAttribute()Date@ObjectAttribute()typed object / Map / discriminated union@IdAttributeunique ID fieldMost attributes support nullable: true for optional fields. Nullable attributes are typed as T | undefined.
const paymentSchema = {
method: {
type: "discriminatedUnion",
discriminator: "type",
variants: {
creditCard: {
cardNumber: { type: "string" },
expiry: { type: "string" }
},
bankTransfer: {
bankName: { type: "string" },
accountNumber: { type: "string" }
},
crypto: {
walletAddress: { type: "string" },
network: {
type: "enum",
values: ["ethereum", "bitcoin", "solana"]
}
}
}
},
amount: { type: "number" }
} as const satisfies ObjectSchema;
@Entity
class Payment extends MyTable {
@ObjectAttribute(paymentSchema)
public payment: InferObjectSchema<typeof paymentSchema>;
}
// Full type narrowing from your schema
const result = await Payment.findById("123");
if (result?.payment.method.type === "crypto") {
result.payment.method.walletAddress; // string
result.payment.method.network; // "ethereum" | "bitcoin" | "solana"
}Get started in minutes
One base table class, one entity, and you're ready.
1234567891011121314151617181920212223242526272829303132333435363738394041import DynaRecord, {
Table, Entity, PartitionKeyAttribute, SortKeyAttribute,
StringAttribute, HasMany, BelongsTo, ForeignKeyAttribute,
type PartitionKey, type SortKey, type ForeignKey
} 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;
}
@Entity
class User extends MyTable {
@StringAttribute()
public name: string;
@HasMany(() => Order, { foreignKey: "userId" })
public readonly orders: Order[];
}
@Entity
class Order extends MyTable {
@ForeignKeyAttribute(() => User)
public readonly userId: ForeignKey<User>;
@BelongsTo(() => User, { foreignKey: "userId" })
public readonly user: User;
}
// Create with enforced foreign key constraints
const user = await User.create({ name: "Alice" });
const order = await Order.create({ userId: user.id });
// Fetch with relationships in a single query
const alice = await User.findById(user.id, {
include: [{ association: "orders" }]
});