@asabytes/dynamodb
v1.0.1
Published
DynamoDB data mapper for Node.js, in TypeScript, with Zod schemas and the AWS SDK v3
Maintainers
Readme
@asabytes/dynamodb
A DynamoDB data mapper for Node.js — inspired by and a TypeScript port of the
dynamodb package
(baseprime/dynamodb), modernized to use:
- Zod 4 for schemas instead of Joi
- the AWS SDK v3
(
@aws-sdk/client-dynamodb+@aws-sdk/lib-dynamodb) - Promises / async-await everywhere instead of callbacks
- async iterators for streaming instead of Node
Readablestreams
The data-modeling logic — schemas, serialization, query/scan builders, update expressions, secondary indexes, batch get, parallel scan, hooks — is a faithful port of the original library.
Installation
npm install @asabytes/dynamodb zod@aws-sdk/client-dynamodb, @aws-sdk/lib-dynamodb, and uuid are runtime
dependencies and are installed automatically. zod is a peer dependency —
install it alongside (npm 7+ adds it for you) so your app and this library share
a single Zod instance.
Getting started
The SDK v3 reads credentials and region from the standard provider chain (environment, shared config, IAM role, etc.). To use a custom client:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import dynamo from '@asabytes/dynamodb';
dynamo.dynamoDriver(new DynamoDBClient({ region: 'us-east-1' }));Define a model
Use z (re-exported from this package) for ordinary attributes and
dynamo.types for the DynamoDB-specific helpers (sets, uuid, binary).
import dynamo, { z } from '@asabytes/dynamodb';
const Account = dynamo.define('Account', {
hashKey: 'email',
timestamps: true, // adds createdAt / updatedAt
schema: {
email: z.string().email(),
name: z.string(),
age: z.number().optional(),
roles: dynamo.types.stringSet(),
settings: z.object({
nickname: z.string().optional(),
acceptedTerms: z.boolean().default(false),
}),
},
});
const BlogPost = dynamo.define('BlogPost', {
hashKey: 'email',
rangeKey: 'title',
schema: {
email: z.string().email(),
title: z.string(),
content: dynamo.types.binary(),
tags: dynamo.types.stringSet(),
},
});Type inference
Models are generic over their schema: define infers the item type (via
z.infer) and the hash/range key value types, so reads, writes, queries, and
key arguments are all statically typed — no manual type parameters needed.
const Account = dynamo.define('Account', {
hashKey: 'email',
schema: { email: z.string(), age: z.number().optional() },
});
const acc = await Account.get('[email protected]'); // hash key must be a string
acc?.get('age'); // number | undefined
await Account.get(123); // ✖ compile error: email is a string
for await (const a of Account.scan().items()) {
a.get('email'); // string
}
Account.scan().where('age').gte(21); // ✔
Account.scan().where('nope'); // ✖ compile error: not an attributeNote: date attributes (
z.coerce.date()) infer asDate, but DynamoDB stores and returns them as ISO strings — values read back are strings at runtime.
Schema types
dynamo.types provides the helpers that have no direct Zod primitive:
| Helper | DynamoDB type |
| --- | --- |
| dynamo.types.stringSet() | String Set (SS) |
| dynamo.types.numberSet() | Number Set (NS) |
| dynamo.types.binarySet() | Binary Set (BS) |
| dynamo.types.binary() | Binary (B) |
| dynamo.types.uuid() | String, defaults to a generated UUID v4 |
| dynamo.types.timeUUID() | String, defaults to a generated UUID v1 |
Everything else is a plain Zod schema: z.string(), z.number(),
z.boolean(), z.coerce.date(), z.object({...}), z.array(...), etc.
Unknown keys. A plain
schema: { ... }record compiles to a strict object — unknown attributes are rejected, matching Joi's default. To allow dynamic attributes, pass a loose Zod object instead:schema: z.looseObject({ id: z.string() })(the equivalent of Joi.unknown()).
How keys are stored
A hash key, range key, or secondary-index key is not stored specially — it is
just a normal attribute stored under the exact name you declare. What makes it a
"key" is the table's KeySchema, which references those attribute names. A
secondary index adds no new attributes; it designates existing ones as that
index's keys (so an item only appears in a sparse GSI when it has those attrs).
const GameScore = dynamo.define('GameScore', {
hashKey: 'userId', // partition key attribute name
rangeKey: 'gameTitle', // sort key attribute name
schema: { userId: z.string(), gameTitle: z.string(), topScore: z.number() },
indexes: [{ hashKey: 'gameTitle', rangeKey: 'topScore', name: 'GameTitleIndex', type: 'global' }],
});Inspect the key names at runtime from the compiled schema, or the live table:
GameScore.table.schema.hashKey // 'userId'
GameScore.table.schema.rangeKey // 'gameTitle'
GameScore.table.schema.globalIndexes // { GameTitleIndex: { hashKey: 'gameTitle', rangeKey: 'topScore', ... } }
GameScore.table.schema.secondaryIndexes // local (LSI) indexes, keyed by name
const { Table } = await GameScore.describeTable();
Table.KeySchema // [{ AttributeName: 'userId', KeyType: 'HASH' }, { AttributeName: 'gameTitle', KeyType: 'RANGE' }]
Table.AttributeDefinitions // only key attributes are declared, with their type
Table.GlobalSecondaryIndexes // [{ IndexName: 'GameTitleIndex', KeySchema: [...] }]Key values are ordinary attribute values. This library hands you native JS (the DocumentClient marshals to/from the low-level form), but on the wire they are:
| Schema type | Key AttributeType | Low-level value | Read back as |
| --- | --- | --- | --- |
| z.string() | S | { "S": "u1" } | string |
| z.number() | N | { "N": "4200" } | number |
| dynamo.types.binary() | B | { "B": <bytes> } | Uint8Array |
| z.coerce.date() | S | { "S": "2026-06-23T…Z" } | ISO string |
Key attributes (including GSI/LSI keys) must be S, N, or B — DynamoDB does
not allow boolean/object/set key types.
Create tables
await dynamo.createTables({
BlogPost: { readCapacity: 5, writeCapacity: 10 },
Account: { readCapacity: 20, writeCapacity: 4 },
});
await BlogPost.deleteTable();CRUD
// create (single or array)
const acc = await Account.create({ email: '[email protected]', name: 'Foo', age: 21 });
await Account.create([{ email: '[email protected]' }, { email: '[email protected]' }]);
// conditional create
await Account.create({ email: '[email protected]' }, { overwrite: false });
// get by key (scalar, hash+range, or key object)
const a = await Account.get('[email protected]');
const p = await BlogPost.get('[email protected]', 'Hello World');
const p2 = await BlogPost.get({ email: '[email protected]', title: 'Hello World' });
const consistent = await Account.get('[email protected]', { ConsistentRead: true });
// update (null removes an attribute; $add / $del mutate numbers and sets)
await Account.update({ email: '[email protected]', name: 'Bar' });
await Account.update({ email: '[email protected]', age: { $add: 1 } });
await BlogPost.update({ email: '[email protected]', title: 'Hello World', tags: { $del: 'cloud' } });
// conditional update
await Account.update({ email: '[email protected]', name: 'Bar' }, { expected: { age: 22 } });
// destroy
await Account.destroy('[email protected]');
await BlogPost.destroy({ email: '[email protected]', title: 'Hello World' });Instances expose save(), update(), destroy(), get(key), set(attrs),
and toJSON():
const acc = new Account({ email: '[email protected]', name: 'Test' });
await acc.save();
acc.set({ age: 22 });
await acc.update();Query
const result = await BlogPost.query('[email protected]')
.where('title').beginsWith('Expanding')
.filter('tags').contains('cloud')
.attributes(['title', 'content'])
.limit(10)
.descending()
.exec();
console.log(result.Items, result.Count);
// load every page
const all = await BlogPost.query('[email protected]').loadAll().exec();
// against a global secondary index
await GameScore.query('Galaxy Invaders').usingIndex('GameTitleIndex').descending().exec();Key conditions: equals/eq, lt, lte, gt, gte, beginsWith,
between. Filters add ne, null, exists, contains, notContains, in.
Scan
await Account.scan().where('age').gte(21).exec();
await Account.scan().loadAll().exec();
await Account.scan().where('age').gte(21).select('COUNT').exec();Streaming with async iterators
query, scan, and parallelScan are async-iterable. Pagination past the
first page is gated on loadAll() (matching the original streaming behaviour).
// iterate page by page
for await (const page of Account.scan().loadAll()) {
console.log(page.Items.length);
}
// iterate item by item
for await (const acc of Account.scan().loadAll().items()) {
console.log(acc.get('email'));
}Parallel scan
const result = await Account.parallelScan(8).where('age').gte(18).exec();
for await (const page of Account.parallelScan(4)) {
console.log('segment page', page.Items.length);
}Batch get
const accounts = await Account.getItems(['[email protected]', '[email protected]', '[email protected]']);
const posts = await BlogPost.getItems([
{ email: '[email protected]', title: 'Hello' },
{ email: '[email protected]', title: 'World' },
], { ConsistentRead: true });Single-table design
defineSingleTable is a thin layer over dynamo.define for single-table
design: many entity types in one physical table, with key templates, an
entityType discriminator, and splitters for heterogeneous result sets. It
composes the normal Model API (it doesn't replace it).
import dynamo, { z, defineSingleTable } from '@asabytes/dynamodb';
const app = defineSingleTable({
tableName: 'AppTable',
// defaults: hashKey 'PK', rangeKey 'SK', entityTypeAttr 'entityType'
indexes: [{ hashKey: 'GSI1PK', rangeKey: 'GSI1SK', name: 'GSI1', type: 'global' }],
});
const User = app.entity('User', {
// entityType defaults to the name uppercased ('User' -> 'USER'); override if needed
schema: { userId: z.string(), email: z.string(), name: z.string().optional() },
keys: { // key templates, run on create/update
PK: (u) => `USER#${u.userId}`,
SK: () => 'PROFILE',
GSI1PK: (u) => `EMAIL#${u.email}`,
GSI1SK: (u) => `USER#${u.userId}`,
},
});
const Order = app.entity('Order', {
schema: { userId: z.string(), orderId: z.string(), total: z.number() },
keys: {
PK: (o) => `USER#${o.userId}`,
SK: (o) => `ORDER#${o.orderId}`,
},
});
// Create the shared table once (not via dynamo.createTables()).
await app.createTable();
// Writes auto-compute PK/SK/GSI keys + entityType — you pass only domain attrs:
await User.create({ userId: '1', email: '[email protected]', name: 'Foo' });
await Order.create({ userId: '1', orderId: '9', total: 42 });
// Read by key components (builds the key for you):
const user = await User.lookup({ userId: '1' });
await Order.remove({ userId: '1', orderId: '9' });
// Item-collection query, then split the heterogeneous result by entity:
const res = await app.query('USER#1').where('SK').beginsWith('ORDER#').exec();
const orders = res.Items.filter(Order.is); // typed Item<Order attrs>[]
const grouped = app.group(res.Items); // { USER: [...], ORDER: [...] }Notes:
- Each entity is a normal model;
User.create/update/get/...all work, andUser.before('create', …)runs after the key templates. app.query(pk)/app.scan()go through a shared loose-schema base model, so results are untyped (Item<Record<string, any>>) — narrow withEntity.is.- Create the table with
app.createTable(). Don't use the globaldynamo.createTables()here — every entity model points at the same physical table, so it would issue redundant create/update calls. - For updates, pass the attributes the key templates need (e.g.
orderId) so the key can be recomputed.
Hooks
before hooks receive the data and return the (optionally transformed) data;
after hooks receive the resulting item.
Account.before('create', async (data) => ({ ...data, name: data.name?.trim() }));
Account.after('create', (item) => console.log('created', item?.get('email')));Logging
dynamo.log.level('info'); // global
Account.log.level('warn'); // per-modelMigration notes (vs. baseprime/dynamodb)
| Original | This port |
| --- | --- |
| Joi schemas (Joi.string()) | Zod schemas (z.string()), via import { z } |
| dynamo.types.* (Joi-backed) | dynamo.types.* (Zod-backed) — same names |
| aws-sdk v2 + DocumentClient | @aws-sdk/client-dynamodb + @aws-sdk/lib-dynamodb |
| dynamo.AWS.config.update(...) | dynamo.dynamoDriver(new DynamoDBClient({...})) |
| callbacks or promises | promises / async-await only |
| Node Readable streams | async iterators (for await...of, .pages(), .items()) |
| dynamo.Set(values, 'S') | dynamo.Set(values) — a native Set (SDK v3 marshals it) |
| validation errors = Joi error | validation errors = ZodError |
The model-definition, serialization, expression-building, indexing, batch, and parallel-scan logic is otherwise a 1:1 port.
Scripts
npm run build # bundle to dist/ (ESM + CJS + .d.ts) with tsup
npm run typecheck # tsc --noEmit
npm test # run the Jest suiteLicense
MIT
