dyno-table
v3.0.0
Published
A TypeScript library to simplify working with DynamoDB
Maintainers
Readme
dyno-table
Stop hand-writing
pk/skstrings and rawKeyConditionExpressionsyntax. Use typed query and update builders with compile-time checks on your item shapes instead.
Why dyno-table?
Without dyno-table, querying a GSI means remembering which index holds what and hand-building the expression:
await client.send(new QueryCommand({
TableName: "dinosaurs",
IndexName: "gsi1",
KeyConditionExpression: "gsi1pk = :pk",
ExpressionAttributeValues: { ":pk": `DIET#${diet}` },
}));With dyno-table, that index is a named method:
await dinoRepo.query.getDinosaursByDiet({ diet: "carnivore" }).execute();getDinosaursByDiet()replaces thegsi1/KeyConditionExpressionyou'd otherwise have to look up- Zod, ArkType, Valibot, or any Standard Schema library validates every write before it hits DynamoDB
- Item shapes, keys, and query inputs get checked at compile time instead of failing at runtime
Quick start
npm install dyno-table @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodbimport { z } from "zod";
import { createIndex, createQueries, defineCollection, defineEntity } from "dyno-table/entity";
// Define your dinosaur schema
const dinosaurSchema = z.object({
id: z.string(),
species: z.string(),
period: z.enum(["triassic", "jurassic", "cretaceous"]),
diet: z.enum(["herbivore", "carnivore", "omnivore"]),
discoveryYear: z.number(),
weight: z.number(),
});
const createQuery = createQueries<z.infer<typeof dinosaurSchema>>();
// Create your entity with indexes for efficient queries
const DinosaurEntity = defineEntity({
name: "Dinosaur",
schema: dinosaurSchema,
primaryKey: createIndex()
.input(z.object({ id: z.string() }))
.partitionKey(({ id }) => `DINO#${id}`)
.sortKey(() => "PROFILE"),
indexes: {
byDiet: createIndex()
.input(dinosaurSchema)
.partitionKey(({ diet }) => `DIET#${diet}`)
.sortKey(({ species }) => species),
},
queries: {
getDinosaursByDiet: createQuery
.input<{ diet: "herbivore" | "carnivore" | "omnivore" }>()
.query(({ input, entity }) =>
entity.query({ pk: `DIET#${input.diet}` }).useIndex("byDiet")
),
},
});
// Create the repository
const dinoRepo = DinosaurEntity.createRepository(table);
// Create a T-Rex
const tRex = await dinoRepo.create({
id: "t-rex-1",
species: "Tyrannosaurus Rex",
period: "cretaceous",
diet: "carnivore",
discoveryYear: 1905,
weight: 8000,
}).execute();
// Find all carnivores (query via index)
const carnivores = await dinoRepo.query
.getDinosaursByDiet({ diet: "carnivore" })
.execute();You now have a type-safe, validated database with semantic queries.
Feature overview
Entity pattern (recommended)
Use for most application code: schema validation, generated keys, and semantic query names instead of hand-written pk/sk strings. dyno-table validates every write against your schema before it hits DynamoDB.
// Get specific dinosaur
const { item: tRex } = await dinoRepo.get({ id: "t-rex-1" }).execute();
// Semantic queries
const cretaceousDinos = await dinoRepo.query
.getDinosaursByPeriod({ period: "cretaceous" })
.execute();Entity collections
Use when several entity types share the same GSI and you want to query it in one shot (e.g. "everything at this location"). Results come back grouped by entity type, so downstream code reads page.Dinosaur / page.Warehouse rather than one mixed array.
const pages = defineCollection({
entities: { Dinosaur: DinosaurEntity, Warehouse: WarehouseEntity },
indexName: "GSI1",
})
.createReader(table)
.query({ pk: "LOCATION#WELLINGTON" })
.paginate();
for await (const page of pages) {
console.log(page.Dinosaur, page.Warehouse);
}paginate() streams grouped pages. execute() streams individual configured items; its toArray() returns one grouped result.
Direct table operations
Use when you need raw pk/sk control or something the entity layer doesn't model. You own key construction yourself and skip schema validation.
// Direct DynamoDB access with query
const carnivoresInCretaceous = await table
.query({ pk: "PERIOD#cretaceous" })
.filter(op => op.eq("diet", "carnivore"))
.execute();Vector search
Use a configured DynamoDB vector index without dropping to raw SDK expressions. Results retain service rank and score; entity and collection searches scope candidates before TopK.
const result = await table
.searchVectors<Product>("ProductEmbedding", { vector: embedding, topK: 10, partition: "Electronics" })
.filter(op => op.eq("status", "ACTIVE"))
.select(["productId", "title"])
.execute();Advanced querying & filtering
Use .filter() for business logic DynamoDB's key conditions can't express. It's applied after the read, so it narrows what's returned, not what's read. It doesn't reduce RCU cost the way a tighter key condition or index would.
// Find large herbivores from Jurassic period using query + filter
const conditions = await dinoRepo.query
.getDinosaursByDiet({ diet: "herbivore" })
.filter(op => op.and(
op.eq("period", "jurassic"),
op.gt("weight", 3000)
))
.execute();Batch operations
Use when you're reading or writing many known keys at once (up to 100 reads / 25 writes per batch). Batches don't support conditions and aren't atomic. Reach for .transaction() instead when operations must all succeed or all fail together.
const batch = table.batchBuilder();
// Queue reads
[{ id: "t-rex-1" }, { id: "triceratops-1" }, { id: "stegosaurus-1" }]
.forEach(key => dinoRepo.get(key).withBatch(batch));
// Queue writes; reads and writes can share one batch
carnivores.forEach(dino => dinoRepo.create(dino).withBatch(batch));
const { reads } = await batch.execute();
const dinos = reads.itemsByType.Dinosaur;Transactions
Use when multiple writes must succeed or fail together (ACID). dyno-table caps transactions at 25 operations. For bulk work that doesn't need atomicity, batch operations are cheaper.
// Atomic dinosaur discovery
await table.transaction(async (tx) => {
dinoRepo.create(newDinosaur).withTransaction(tx);
researchRepo.update({ id: "paleontologist-1" }, {})
.add("discoveriesCount", 1)
.withTransaction(tx);
});Pagination & memory management
Stream (for await) when you just need to process results one at a time and want flat memory usage. Use .paginate(pageSize) when you control page boundaries yourself, e.g. returning one page per API response. Only call .toArray() when you already know the result set is small.
// Stream large datasets (memory efficient)
const allCarnivores = await dinoRepo.query
.getDinosaursByDiet({ diet: "carnivore" })
.execute();
for await (const dino of allCarnivores) {
await processDiscovery(dino); // Process one at a time
}
// Paginated results
const paginator = dinoRepo.query
.getDinosaursByDiet({ diet: "herbivore" })
.paginate(50);
while (paginator.hasNextPage()) {
const page = await paginator.getNextPage();
console.log(`Processing ${page.items.length} herbivores...`);
}Schema validation
Use whichever validation library your project already has. dyno-table works with anything implementing the Standard Schema interface, not just Zod.
// Zod (included)
const dinoSchema = z.object({
species: z.string().min(3),
weight: z.number().positive(),
});
// ArkType
const dinoSchema = type({
species: "string>2",
weight: "number>0",
});
// Valibot
const dinoSchema = v.object({
species: v.pipe(v.string(), v.minLength(3)),
weight: v.pipe(v.number(), v.minValue(1)),
});Migrations
Use for backfills or data-movement scripts that run against a live table. Every run is a dry run until you pass { apply: true }, and an applied run resumes from its last completed page instead of restarting.
import { MigrationManager } from "dyno-table/migration";
const manager = new MigrationManager({
repos: { orders: orderRepo },
migrationRepo: migrationCheckpointRepo,
});
manager.createMigration("backfill-order-totals", async ({ repos, cursor }) => {
for await (const order of cursor(repos.orders.scan(), { pageSize: 100 })) {
await repos.orders.update({ id: order.id }, { total: computeTotal(order) }).execute();
}
});
// Dry run by default, no writes happen until you opt in
await manager.run("backfill-order-totals");
await manager.run("backfill-order-totals", { apply: true });
// Run all pending migrations
await manager.runAll({ apply: true })Choose a page size that bounds how much work an interrupted page may repeat.
Performance optimization
Reach for an index whenever you know the access pattern in advance. It's always cheaper than a scan. Reach for .segments(n) only when .query() isn't an option (no known partition key) and a sequential scan is the actual bottleneck. It parallelizes the scan across a table, but each segment issues its own concurrent request, so a high segment count can throttle a provisioned table.
// Use indexes for fast lookups
const jurassicCarnivores = await dinoRepo.query
.getDinosaursByPeriodAndDiet({
period: "jurassic",
diet: "carnivore"
})
.useIndex("period-diet-index")
.execute();
// Split a full-table scan across parallel segments
const allDinos = await table.scan().segments(4).toArray();Observability
Pass plugins to Table to get an onRequestStart/onRequestEnd callback around every physical DynamoDB request — for logging, APM spans, or counting request volume, the same way SQL libraries expose query logging. Each plugin is independent, so logging, tracing, and metrics can be registered side by side.
const table = new Table({
client, tableName: "Dinosaurs", indexes: { partitionKey: "pk", sortKey: "sk" },
plugins: [{
name: "logger",
onRequestStart: (e) => console.log(`→ ${e.operation} [${e.entityNames.join(", ") || "table"}]`),
onRequestEnd: (e) => console.log(`← ${e.operation} in ${e.durationMs}ms`),
}],
});Hooks may be async and are awaited. Hook failures are isolated from the DynamoDB operation and can be reported through the plugin's onError callback. For request-local tracing state, snapshots, and full lifecycle behavior, see the Observability Guide →.
Documentation
Getting started
- Quick Start Tutorial → - Install, define an entity, run your first query
- Installation Guide → - Setup and configuration
- Your First Entity → - Define fields, keys, and indexes step by step
Core concepts
- Entity vs Table → - When to use the entity layer vs. raw table operations
- Key Design Patterns → - Partition and sort keys, single-table design
Features
- Query Building → - Complex queries and filtering
- Standard Schema Support → - Zod, ArkType, Valibot validation
- Transactions → - ACID operations
- Batch Operations → - Read or write up to 100/25 items per call
- Pagination → - Streaming, page-based, and cursor-based result handling
- Entity Collections → - Group shared-index query pages by entity type
- Table Operations → - Direct table access, scans, parallel scan segments
- Observability → - Hook every request for logging, tracing, and APM spans
Advanced topics
- Error Handling → - Error classes, type guards, and AWS error unwrapping
- Migrations → - Backfill scripts with dry-run safety and resumability
Examples
- Entity Example App → - Full runnable project with entities and repositories
- Entity Collections → - Grouping shared-index query results by entity type
- Single-Table GSI Design → - Multiple entity types on shared indexes
- Multiple GSIs → - Working with more than one global secondary index
Links
- Documentation - Complete guides and references
- Issues - Report bugs or request features
- Discussions - Ask questions and share ideas
- NPM - Package information
