dynamodb-zod
v0.1.8
Published
Entity mapping and query library for DynamoDB
Maintainers
Readme
dynamodb-zod
This is a fork of https://github.com/tschoffelen/turbine and changes will likely be upstreamed.
Entity mapping and query helpers for DynamoDB using Zod schemas and the AWS SDK v3. Define your table and entities once, then put, get, update, and query with type-safe objects.
- Small, direct, and type-friendly
- Derive composite keys, defaults, and computed fields from your data
- Type-safe expression objects for filters, conditions, and updates
- Works with your existing DynamoDB DocumentClient
Getting started
npm install dynamodb-turbine zodimport z from "zod";
import { defineTable, defineEntity } from "dynamodb-turbine";
// 1) Define your table
const table = defineTable({
name: "my-dynamodb-table",
indexes: {
table: { hashKey: "pk", rangeKey: "sk" },
type_sk: { hashKey: "type", rangeKey: "sk" },
},
});
// 2) Define an entity
const users = defineEntity({
table,
schema: z.object({
id: z.string(),
email: z.string().email(),
name: z.string().optional(),
pk: z.string(),
sk: z.string(),
type: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
}),
computed: {
pk: (u) => ["user", u.id],
sk: (u) => u.email,
type: () => "user",
createdAt: (u) => u.createdAt || new Date().toISOString(),
updatedAt: () => new Date().toISOString(),
},
immutable: ["createdAt"],
});
// 3) Use it
const user = await users.put({
id: "user-001",
email: "[email protected]",
});
await user.update({ name: "Alice" });
const found = await users.queryOne({
pk: ["user", user.id],
sk: { $beginsWith: "user@" },
});Defining tables
const table = defineTable({
name: "my-table",
indexes: {
table: { hashKey: "pk", rangeKey: "sk" },
gsi1: { hashKey: "type", rangeKey: "createdAt" },
},
// Optional: set defaults applied to all operations
options: {
dynamodb: {
consistentRead: true,
returnConsumedCapacity: "TOTAL",
returnItemCollectionMetrics: "SIZE",
},
},
// Optional: pass your own client
documentClient: myClient,
// Optional: log all DynamoDB calls
debug: true,
});| Option | Description |
| ---------------------------------------------- | ------------------------------------------------------------ |
| name | DynamoDB table name |
| indexes | Must include table (primary). Additional entries are GSIs. |
| options.dynamodb.consistentRead | Applied to get and query |
| options.dynamodb.returnConsumedCapacity | Applied to all operations |
| options.dynamodb.returnItemCollectionMetrics | Applied to put, update, delete |
| documentClient | Custom DynamoDBDocumentClient instance |
| debug | Log all operations to console |
Defining entities
The schema defines the full stored item shape. Fields listed in computed are automatically excluded from put input requirements.
const posts = defineEntity({
table,
schema: z.object({
id: z.string(),
title: z.string(),
content: z.string(),
pk: z.string(),
sk: z.string(),
type: z.string(),
createdAt: z.string(),
}),
computed: {
pk: (p) => ["post", p.id],
sk: (p) => ["post", p.createdAt, p.id],
type: () => "post",
createdAt: (p) => p.createdAt || new Date().toISOString(),
},
// Fields that use if_not_exists on update (never overwritten)
immutable: ["createdAt"],
});| Option | Description |
| ----------- | ------------------------------------------------------------------------- |
| schema | Zod object defining the full stored DynamoDB item shape |
| computed | Functions that derive field values; these fields become optional in put |
| immutable | Fields that are set on first write but never overwritten on update |
Operations
put
const post = await posts.put({ id: "123", title: "Hello", content: "World" });get
const post = await posts.get({
pk: ["post", "123"],
sk: ["post", "2024-01-01", "123"],
});update
await posts.update(
{ pk: ["post", "123"], sk: ["post", "2024-01-01", "123"] },
{ title: "Updated" },
);query / queryOne / queryAll
const page = await posts.query({ pk: ["post", "123"] }, { Limit: 10 });
const one = await posts.queryOne({
pk: ["post", "123"],
sk: { $beginsWith: "post#2024" },
});
const all = await posts.queryAll({ pk: ["post", "123"] });Query a GSI by specifying the index name — TypeScript constrains the key shape to match that index:
// Query by type (GSI with hashKey: "type", rangeKey: "sk")
const allUsers = await users.queryAll({
index: "type_sk",
type: "user",
});
// Query by type with range condition
const recentPosts = await posts.query(
{ index: "type_sk", type: "post", sk: { $beginsWith: "post#2024" } },
{ Limit: 20, ScanIndexForward: false },
);delete
await posts.delete({ pk: ["post", "123"], sk: ["post", "2024-01-01", "123"] });Expression objects
All expressions are plain $-prefixed objects — no function imports needed. They are fully type-safe.
Filter & condition expressions
Used in query filters and put/update/delete conditions.
// Filters on query
const results = await posts.queryAll(
{ pk: ["user", userId] },
{ filters: { title: { $beginsWith: "Hello" }, type: { $equals: "post" } } },
);
// Logical groups
const results = await posts.queryAll(
{ pk: ["user", userId] },
{
filters: {
$or: [
{ title: { $beginsWith: "Hello" } },
{ title: { $beginsWith: "World" } },
],
},
},
);
// Conditions on writes
await posts.put(data, { conditions: { pk: { $notExists: true } } });
await posts.delete(key, {
conditions: {
$and: [{ type: { $equals: "post" } }, { title: { $exists: true } }],
},
});| Expression | DynamoDB expression | Value type |
| ------------------------------- | ---------------------------- | -------------- |
| { $equals: val } | = :val | any |
| { $notEquals: val } | <> :val | any |
| { $greaterThan: val } | > :val | string, number |
| { $lessThan: val } | < :val | string, number |
| { $greaterThanOrEquals: val } | >= :val | string, number |
| { $lessThanOrEquals: val } | <= :val | string, number |
| { $between: [lo, hi] } | BETWEEN :lo AND :hi | string, number |
| { $beginsWith: prefix } | begins_with(path, :val) | string |
| { $contains: val } | contains(path, :val) | string, number |
| { $notContains: val } | NOT contains(path, :val) | string, number |
| { $exists: true } | attribute_exists(path) | — |
| { $notExists: true } | attribute_not_exists(path) | — |
| { $and: [...conditions] } | cond AND cond | filter groups |
| { $or: [...conditions] } | cond OR cond | filter groups |
Update expressions
Used in update patches alongside plain values.
await users.update(
{ pk: ["user", id], sk: email },
{
name: "Alice", // plain SET
loginCount: { $increment: 1 }, // SET path = path + :val
credits: { $decrement: 5 }, // SET path = path - :val
tags: { $append: ["new-tag"] }, // SET path = list_append(path, :val)
history: { $prepend: ["latest"] }, // SET path = list_append(:val, path)
bio: { $ifNotExists: "No bio yet" }, // SET path = if_not_exists(path, :val)
roles: { $addToSet: new Set(["admin"]) }, // ADD path :val
oldRoles: { $deleteFromSet: new Set(["guest"]) }, // DELETE path :val
temp: { $remove: true }, // REMOVE path
},
);| Expression | DynamoDB action | Value type |
| ------------------------- | -------------------------------------- | ---------- |
| { $increment: n } | SET path = path + :val | number |
| { $decrement: n } | SET path = path - :val | number |
| { $append: list } | SET path = list_append(path, :val) | array |
| { $prepend: list } | SET path = list_append(:val, path) | array |
| { $ifNotExists: val } | SET path = if_not_exists(path, :val) | any |
| { $addToSet: set } | ADD path :val | Set |
| { $deleteFromSet: set } | DELETE path :val | Set |
| { $remove: true } | REMOVE path | — |
| null | REMOVE path | — |
Dot-notation is supported for nested fields: { "stats.views": { $increment: 1 } }.
Types and validation
- Inputs are validated by your Zod schema.
- Returned instances are typed as the full schema and include an
update(patch)helper. - Expression objects are type-restricted to their valid DynamoDB operand types.
Error handling
Invalid input or unresolved computed fields throw an error. DynamoDB errors (e.g. ConditionalCheckFailedException) are passed through.
