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 Store extends StoreTable {
declare readonly type: "Store";
@StringAttribute({ alias: "Name" })
public readonly name: string;
@HasMany(() => Product, { foreignKey: "storeId" })
public readonly products: Product[];
}
// Fetch with related data
const store = await Store.findById(id, {
include: [{ association: "products" }]
});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 Product extends StoreTable {
declare readonly type: "Product";
@IdAttribute // Customizable unique ID field
@StringAttribute({ alias: "Sku" })
public readonly sku: string;
@StringAttribute({ alias: "Category" })
public readonly category: string;
@NumberAttribute({ alias: "Price", nullable: true })
public readonly price?: number;
@BooleanAttribute({ alias: "InStock" })
public readonly inStock: boolean;
}Foreign key constraints
Referential integrity enforced through DynamoDB condition checks. Invalid references fail before the write.
// Fails: Store with ID '123' does not exist
await Product.create({ sku: "TRK-880", storeId: "123" });
// TransactionWriteFailedError
// @IdAttribute enforces uniqueness
await Product.create({ sku: "TRK-881", storeId }); // OK
await Product.create({ sku: "TRK-881", storeId }); // FailsExpressive queries
Filter with $beginsWith, $contains, $or, and IN arrays — every condition validated against your entity definition.
const orders = await Store.query(storeId, {
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 Product.create({ invalidField: "value" });
// Plain JavaScript callers hit the same contract
await Product.create(untypedInput);
// ValidationError from runtime schema validation123456789101112131415161718192021222324@Entity
class Order extends StoreTable {
declare readonly type: "Order";
@ForeignKeyAttribute(() => Store, { alias: "StoreId" })
public readonly storeId: ForeignKey<Store>;
@BelongsTo(() => Store, { foreignKey: "storeId" })
public readonly store: Store;
@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: "store" },
{ 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 Store.query(storeId, {
skCondition: { $beginsWith: "Order" }
});
// Exact match — narrows to Address[]
const address = await Store.query(storeId, {
skCondition: "Address"
});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 Store.query(storeId, {
filter: {
inStock: true, // boolean field
category: { $contains: "Trail" },// string operation
status: ["PENDING", "SHIPPED"] // 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 store = await Store.findById(id, {
include: [
{ association: "address" }, // HasOne
{ association: "products" } // 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 store = await Store.findById(id);
store?.address; // TS Error — not included!
// With includes — the type narrows
const full = await Store.findById(id, {
include: [{ association: "address" }]
});
full?.address.street; // OK — 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 a Store, tenant, or workspace entity and its foreign key becomes the index partition — a boundary the service enforces, not your query code.
Each index declares its own vector attribute and its complete membership, so a table can carry several independent corpora that are separately ranked and separately billed. An entity belongs to exactly one of them, and the compiler rejects a declaration that claims it twice.
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 →
123456789101112131415161718192021222324252627282930313233343536@Entity
class Product extends StoreTable {
declare readonly type: "Product";
@Searchable()
@StringAttribute({ alias: "Description" })
public readonly description: Searchable;
@SearchFilterable()
@StringAttribute({ alias: "Category" })
public readonly category: SearchFilterable;
@ForeignKeyAttribute(() => Store, { alias: "StoreId" })
public readonly storeId: ForeignKey<Store>;
}
// One declaration per table. members IS the membership —
// nothing is inferred from relationships
const { productSearch, helpSearch } =
StoreTable.vectorIndexes({
productSearch: {
name: "product-search-index",
vectorAttribute: "__dyna_vector",
model: TitanTextEmbedV2,
provider: embed, // your embedding function
scopedBy: () => Store,
members: [() => Product, () => Review]
},
helpSearch: {
name: "help-search-index",
vectorAttribute: "__dyna_vector_help",
model: TitanTextEmbedV2,
provider: embed,
members: [() => HelpArticle]
}
});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 the index's declared members — discriminate on entity.type.
const results = await productSearch.search(
storeId,
"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 the index's own member entity names, and the response type narrows to match. Both sides of the contract are checked at compile time.
const products = await productSearch.search(
storeId,
"hiking boots",
{ in: "Product" }
);
// Array<SearchResult<Product>> — inferred
// { in: "HelpArticle" } -> TS Error!
// It belongs to the other indexCompile-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 productSearch.search(storeId, "hiking boots", {
in: "Product",
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. Unscoped indexes reject a scope id entirely.
// Scoped: the scope id comes first
await productSearch.search(storeId, "hiking boots");
// productSearch.search("hiking boots") -> TS Error!
// Unscoped indexes take no scope id
await helpSearch.search("how do refunds work");Indexes never bleed into each other
Each entity carries exactly one index's vector attribute on its row, so membership is physical rather than bookkeeping. A help article cannot surface in a product search even when it is the nearest match.
// Product rows carry __dyna_vector
// HelpArticle rows carry __dyna_vector_help
// Separately ranked, separately billed
await productSearch.search(storeId, "returns");
// -> only Product | Review
await helpSearch.search("returns");
// -> only HelpArticleProvisioning comes from the model
metadata() emits the exact provisioning contract for every index you declared — no second source of truth to keep in sync. Note that each index's HASH and inline filters need their own AttributeDefinitions, and vector indexes require on-demand billing.
const { vectorIndexes = [] } = StoreTable.metadata();
const schemaAttrs = [...new Set(vectorIndexes.flatMap(i => [
...(i.searchSchema.hash ? [i.searchSchema.hash] : []),
...i.searchSchema.inlineFilters
]))];
await client.send(new CreateTableCommand({
TableName: "online-store",
BillingMode: "PAY_PER_REQUEST", // vector indexes require this
KeySchema: [
{ AttributeName: "PK", KeyType: "HASH" },
{ AttributeName: "SK", KeyType: "RANGE" }
],
AttributeDefinitions: [
{ AttributeName: "PK", AttributeType: "S" },
{ AttributeName: "SK", AttributeType: "S" },
// every HASH and inline filter needs one too
...schemaAttrs.map(name => ({
AttributeName: name, AttributeType: "S" as const
}))
],
VectorIndexes: vectorIndexes.map(index => ({
IndexName: index.name,
VectorAttribute: { AttributeName: index.vectorAttribute },
Dimensions: index.dimensions,
DistanceFunction: index.distanceFunction,
Projection: { ProjectionType: index.projection },
SearchSchema: [
...(index.searchSchema.hash ? [{
AttributeName: index.searchSchema.hash,
SearchSchemaElementType: "HASH" as const
}] : []),
...index.searchSchema.inlineFilters.map(name => ({
AttributeName: name,
SearchSchemaElementType: "INLINE_FILTER" as const
}))
]
}))
}));CRUD, simplified
Every operation is transactional, type-safe, and automatically manages timestamps and relationships.
Insert with validation and foreign key checks
const product = await Product.create({
sku: "TRK-880",
description: "Waterproof hiking boots",
category: "Footwear",
storeId
});
// Returns Product with id, createdAt, updatedAtRetrieve by id or query, fully typed
// By ID
const product = await Product.findById(id);
// Query a partition with a filter
const products = await Store.query(storeId, {
skCondition: { $beginsWith: "Product" },
filter: { inStock: true }
});Partial updates with automatic updatedAt
await Product.update(id, {
category: "Trail Running"
});
// Only the specified fields are updated
// updatedAt set automatically
// Searchable values re-embed in the same transactionRemove with relationship cleanup
await Product.delete(id);
// Removes the entity
// Cleans up denormalized records
// Drops it from its vector index
// 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 Order extends StoreTable {
declare readonly type: "Order";
@ObjectAttribute({ schema: paymentSchema, alias: "Payment" })
public readonly payment: InferObjectSchema<typeof paymentSchema>;
}
// Full type narrowing from your schema
const result = await Order.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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445import DynaRecord, {
Table, Entity, PartitionKeyAttribute, SortKeyAttribute,
StringAttribute, HasMany, BelongsTo, ForeignKeyAttribute,
type PartitionKey, type SortKey, type ForeignKey
} from "dyna-record";
@Table({ name: "online-store" })
abstract class StoreTable extends DynaRecord {
@PartitionKeyAttribute({ alias: "PK" })
public readonly pk: PartitionKey;
@SortKeyAttribute({ alias: "SK" })
public readonly sk: SortKey;
}
@Entity
class Store extends StoreTable {
declare readonly type: "Store";
@StringAttribute({ alias: "Name" })
public readonly name: string;
@HasMany(() => Order, { foreignKey: "storeId" })
public readonly orders: Order[];
}
@Entity
class Order extends StoreTable {
declare readonly type: "Order";
@ForeignKeyAttribute(() => Store, { alias: "StoreId" })
public readonly storeId: ForeignKey<Store>;
@BelongsTo(() => Store, { foreignKey: "storeId" })
public readonly store: Store;
}
// Create with enforced foreign key constraints
const store = await Store.create({ name: "Trailhead" });
const order = await Order.create({ storeId: store.id });
// Fetch with relationships in a single query
const withOrders = await Store.findById(store.id, {
include: [{ association: "orders" }]
});