Skip to content
asa75Public

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@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('a@b.com'); // 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: 'foo@example.com', name: 'Foo', age: 21 });
await Account.create([{ email: 'a@x.com' }, { email: 'b@x.com' }]);

// conditional create
await Account.create({ email: 'foo@example.com' }, { overwrite: false });

// get by key (scalar, hash+range, or key object)
const a = await Account.get('foo@example.com');
const p = await BlogPost.get('foo@example.com', 'Hello World');
const p2 = await BlogPost.get({ email: 'foo@example.com', title: 'Hello World' });
const consistent = await Account.get('foo@example.com', { ConsistentRead: true });

// update (null removes an attribute; $add / $del mutate numbers and sets)
await Account.update({ email: 'foo@example.com', name: 'Bar' });
await Account.update({ email: 'foo@example.com', age: { $add: 1 } });
await BlogPost.update({ email: 'foo@example.com', title: 'Hello World', tags: { $del: 'cloud' } });

// conditional update
await Account.update({ email: 'foo@example.com', name: 'Bar' }, { expected: { age: 22 } });

// destroy
await Account.destroy('foo@example.com');
await BlogPost.destroy({ email: 'foo@example.com', title: 'Hello World' });

Instances expose save(), update(), destroy(), get(key), set(attrs), and toJSON():

const acc = new Account({ email: 'test@example.com', name: 'Test' });
await acc.save();
acc.set({ age: 22 });
await acc.update();

Query

const result = await BlogPost.query('werner@example.com')
  .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('werner@example.com').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(['a@x.com', 'b@x.com', 'c@x.com']);

const posts = await BlogPost.getItems([
  { email: 'a@x.com', title: 'Hello' },
  { email: 'a@x.com', 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: 'a@b.com', 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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages