npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@asabytes/dynamodb

v1.0.1

Published

DynamoDB data mapper for Node.js, in TypeScript, with Zod schemas and the AWS SDK v3

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 Readable streams

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 attribute

Note: date attributes (z.coerce.date()) infer as Date, 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, and User.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 with Entity.is.
  • Create the table with app.createTable(). Don't use the global dynamo.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-model

Migration 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 suite

License

MIT