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

@lafken/dynamo

v0.16.2

Published

Define DynamoDB tables using TypeScript decorators - type-safe, declarative infrastructure with Lafken

Readme

@lafken/dynamo

Define and manage DynamoDB tables using TypeScript decorators. @lafken/dynamo lets you declare table schemas, indexes, streams, and TTL directly in your classes — and provides a type-safe repository for performing operations at runtime.

Installation

npm install @lafken/dynamo

Getting Started

Define a table class with @Table, register it in the DynamoResolver, and use createRepository to interact with it:

import { createApp } from '@lafken/main';
import { DynamoResolver } from '@lafken/dynamo/resolver';
import { Table, PartitionKey, SortKey, Field, type PrimaryPartition } from '@lafken/dynamo/main';
import { createRepository } from '@lafken/dynamo/service';

// 1. Define the table schema
@Table({ name: 'contacts' })
export class Contact {
  @PartitionKey(String)
  email: PrimaryPartition<string>;

  @SortKey(String)
  company: PrimaryPartition<string>;

  @Field()
  name: string;

  @Field()
  age: number;
}

// 2. Create a repository for runtime operations
export const contactRepository = createRepository(Contact);

// 3. Register the table in the resolver
createApp({
  name: 'my-app',
  resolvers: [
    new DynamoResolver([Contact]),
  ],
});

Features

Defining a Table

Use the @Table decorator on a class to declare a DynamoDB table. Each property decorated with @PartitionKey, @SortKey, or @Field becomes an attribute in the table schema.

Every table requires exactly one @PartitionKey. A @SortKey is optional and creates a composite primary key. Both accept String or Number as the key type.

import { Table, PartitionKey, SortKey, Field, type PrimaryPartition } from '@lafken/dynamo/main';

@Table({ name: 'events' })
export class EventLog {
  @PartitionKey(String)
  source: PrimaryPartition<string>;

  @SortKey(Number)
  timestamp: PrimaryPartition<number>;

  @Field()
  payload: string;

  @Field({ type: Number })
  severity: number;
}

The @Field decorator registers a regular attribute. Its type is inferred automatically, but can be overridden with the type option.

External Tables

Set isExternal: true to reference an existing DynamoDB table instead of creating one. The framework only reads the table by name — it does not manage its schema, indexes, or lifecycle:

@Table({ name: 'legacy-contacts', isExternal: true })
export class LegacyContact {
  @PartitionKey(String)
  id: PrimaryPartition<string>;
}

Register it the same way as any other table — new DynamoResolver([LegacyContact]). stream, replica, and outputs don't apply to external tables, since the framework doesn't own the resource.

Indexes

Secondary indexes enable alternative query patterns. Define them in the indexes option of @Table.

Local Secondary Index

Shares the same partition key as the table but uses a different sort key:

@Table({
  name: 'orders',
  indexes: [
    {
      type: 'local',
      name: 'orders_by_total',
      sortKey: 'total',
    },
  ],
})
export class Order {
  @PartitionKey(String)
  customerId: PrimaryPartition<string>;

  @SortKey(String)
  orderId: PrimaryPartition<string>;

  @Field()
  total: number;

  @Field()
  status: string;
}

Global Secondary Index

Has its own partition key and optional sort key, enabling queries across the entire table:

@Table({
  name: 'orders',
  indexes: [
    {
      type: 'global',
      name: 'orders_by_status',
      partitionKey: 'status',
      sortKey: 'total',
      projection: ['customerId', 'orderId'],
    },
  ],
})
export class Order {
  @PartitionKey(String)
  customerId: PrimaryPartition<string>;

  @SortKey(String)
  orderId: PrimaryPartition<string>;

  @Field()
  total: number;

  @Field()
  status: string;
}

Use projection to control which attributes are included in the index. Pass an array of field names or 'ALL' to project every attribute.

TTL (Time to Live)

Enable automatic item expiration by specifying the ttl option with the name of a numeric field. DynamoDB will delete items whose TTL value (Unix timestamp in seconds) has passed:

@Table({
  name: 'sessions',
  ttl: 'expiresAt',
})
export class Session {
  @PartitionKey(String)
  sessionId: PrimaryPartition<string>;

  @Field()
  userId: string;

  @Field()
  expiresAt: number;
}

Streams

Enable a DynamoDB Stream to capture item-level changes. When enabled, the stream is automatically connected to EventBridge via EventBridge Pipes, allowing other services to react to table changes in near real-time.

Use the @lafken/event package to consume and process these stream events.

@Table({
  name: 'notifications',
  stream: {
    enabled: true,
    type: 'NEW_AND_OLD_IMAGES',
    batchSize: 10,
    maximumBatchingWindowInSeconds: 5,
  },
})
export class Notification {
  @PartitionKey(String)
  id: PrimaryPartition<string>;

  @Field()
  channel: string;

  @Field()
  message: string;
}

Stream Filters

Apply filters to process only specific change events, reducing unnecessary invocations:

@Table({
  name: 'notifications',
  stream: {
    enabled: true,
    type: 'NEW_IMAGE',
    filters: {
      eventName: ['INSERT'],
      newImage: {
        channel: ['email', 'sms'],
      },
    },
  },
})
export class Notification {
  @PartitionKey(String)
  id: PrimaryPartition<string>;

  @Field()
  channel: string;

  @Field()
  message: string;
}

Available filter criteria:

| Filter | Description | | ----------- | ------------------------------------------------------ | | eventName | Event types: 'INSERT', 'MODIFY', 'REMOVE' | | keys | Filter by partition/sort key values | | newImage | Conditions on the new item (after INSERT or MODIFY) | | oldImage | Conditions on the old item (before MODIFY or REMOVE) |

Billing Mode

Tables default to pay_per_request (on-demand). For provisioned throughput, set billingMode and specify capacity units:

@Table({
  name: 'high-throughput',
  billingMode: 'provisioned',
  readCapacity: 100,
  writeCapacity: 50,
})
export class HighThroughputTable {
  @PartitionKey(String)
  id: PrimaryPartition<string>;

  @Field()
  data: string;
}

With billingMode: 'provisioned', every global secondary index must also declare its own readCapacity/writeCapacity — local indexes share the table's capacity and don't need it:

@Table({
  name: 'orders',
  billingMode: 'provisioned',
  readCapacity: 100,
  writeCapacity: 50,
  indexes: [
    {
      type: 'global',
      name: 'orders_by_status',
      partitionKey: 'status',
      readCapacity: 20,
      writeCapacity: 10,
    },
  ],
})
export class Order { ... }

Global Tables (Replicas)

Create multi-region replicas for global applications using the replica option:

@Table({
  name: 'global-config',
  replica: [
    { regionName: 'eu-west-1', consistenceMode: 'EVENTUAL' },
    { regionName: 'ap-southeast-1' },
  ],
})
export class GlobalConfig {
  @PartitionKey(String)
  key: PrimaryPartition<string>;

  @Field()
  value: string;
}

Outputs

Export table attributes to SSM Parameter Store or as Terraform outputs via outputs:

@Table({
  name: 'contacts',
  outputs: [
    { type: 'ssm', name: '/contacts/arn', value: 'arn' },
    { type: 'output', name: 'contacts_stream_arn', value: 'streamArn' },
  ],
})
export class Contact { ... }

| Attribute | Description | | ------------- | -------------------------------------------------------------------- | | arn | ARN of the table | | id | Name of the table | | streamArn | ARN of the table stream — only available when stream is enabled | | streamLabel | Timestamp of the table stream — only available when stream is enabled |

Global References

Set ref to register the table under a name, so other resources can read its attributes (e.g. an ARN dropped into a Lambda's env) without importing the table class directly:

@Table({ name: 'contacts', ref: 'contacts' })
export class Contact { ... }
import { Refs } from '@lafken/common';

lambda: {
  env: {
    CONTACTS_TABLE_ARN: Refs.resourceValue('dynamo::contacts', 'arn'),
  },
}

Repository

createRepository provides a type-safe API for DynamoDB operations at runtime. All methods return a builder that is executed by calling .exec().

import { createRepository } from '@lafken/dynamo/service';

export const contactRepository = createRepository(Contact);

Create

Insert a new item into the table:

await contactRepository
  .create({
    email: '[email protected]',
    company: 'Acme',
    name: 'Jane Doe',
    age: 30,
  })
  .exec();

Find All

Query items using a key condition. Supports filtering, projections, pagination, and sort direction:

const result = await contactRepository
  .findAll({
    keyCondition: {
      partition: { email: '[email protected]' },
    },
    filter: {
      age: { greaterThan: 25 },
    },
    projection: ['name', 'company'],
    sortDirection: 'desc',
    limit: 10,
  })
  .exec();

// result.data    → matched items
// result.cursor  → pagination cursor for the next page

findAll/findOne/scan accept a second cacheTtl argument (seconds) to cache the result in memory, keyed by table name and query — see Caching.

Filter Operators

Beyond a plain equality value, filter accepts one operator per field:

| Operator | Description | | -------------------- | --------------------------------------------- | | lessThan | Value is less than | | lessOrEqualThan | Value is less than or equal to | | greaterThan | Value is greater than | | greaterOrEqualThan | Value is greater than or equal to | | between | Value is between [min, max] | | in | Value is one of a list | | beginsWith | String starts with (string fields only) | | contains | String/array contains a value | | notContains | String/array does not contain a value | | notEqual | Value is not equal to | | exist | Attribute exists | | notExist | Attribute does not exist |

filter: {
  age: { between: [18, 65] },
  email: { beginsWith: 'jane' },
  archivedAt: { notExist: true },
}

Combine conditions across fields with OR/AND:

filter: {
  OR: [{ age: { lessThan: 18 } }, { age: { greaterThan: 65 } }],
}

Find One

Retrieve a single item matching a key condition:

const item = await contactRepository
  .findOne({
    keyCondition: {
      partition: { email: '[email protected]' },
      sort: { company: 'Acme' },
    },
  })
  .exec();

Get Item

Fetch a single item by its exact primary key using GetItemCommand. Unlike findOne (which runs a QueryCommand and supports partial/operator-based key conditions), getItem requires the full primary key and is the more efficient option for exact-key lookups:

const item = await contactRepository
  .getItem(
    { email: '[email protected]', company: 'Acme' },
    { consistentRead: true, projection: ['name', 'age'] }
  )
  .exec();

| Option | Type | Description | | ---------------- | --------- | ------------------------------------------------- | | consistentRead | boolean | Perform a strongly consistent read | | projection | string[] \| 'ALL' | Attributes to return | | cacheTtl | number | Cache the result in memory for this many seconds — see Caching |

Scan

Scan the entire table with optional filters:

const all = await contactRepository
  .scan({
    filter: {
      age: { greaterThan: 18 },
    },
    limit: 50,
  })
  .exec();

Update

Update specific attributes of an existing item:

await contactRepository
  .update({
    keyCondition: {
      email: '[email protected]',
      company: 'Acme',
    },
    setValues: {
      age: 31,
    },
    replaceValues: {
      name: 'Jane Smith',
    },
  })
  .exec();

Update supports three operation types:

| Operation | Description | | --------------- | ---------------------------------------------------------------- | | setValues | Update specific nested fields without overwriting the object | | replaceValues | Replace entire attribute values | | removeValues | Remove attributes from the item |

Numeric fields support incrementValue and decrementValue, and any field supports ifNotExistValue for conditional defaults:

await contactRepository
  .update({
    keyCondition: { email: '[email protected]', company: 'Acme' },
    setValues: {
      age: { incrementValue: 1 },
    },
  })
  .exec();

Upsert

Insert an item or update it if it already exists:

await contactRepository
  .upsert({
    email: '[email protected]',
    company: 'Acme',
    name: 'Jane Doe',
    age: 30,
  })
  .exec();

Delete

Remove an item by its primary key:

await contactRepository
  .delete({
    email: '[email protected]',
    company: 'Acme',
  })
  .exec();

Bulk Operations

Create or delete multiple items at once:

// Bulk create
await contactRepository
  .bulkCreate([
    { email: '[email protected]', company: 'X', name: 'Alice', age: 28 },
    { email: '[email protected]', company: 'Y', name: 'Bob', age: 35 },
  ])
  .exec();

// Bulk delete
await contactRepository
  .bulkDelete([
    { email: '[email protected]', company: 'X' },
    { email: '[email protected]', company: 'Y' },
  ])
  .exec();

Batch Get

Fetch multiple items by their primary keys in one call using BatchGetItemCommand:

const contacts = await contactRepository
  .batchGet([
    { email: '[email protected]', company: 'X' },
    { email: '[email protected]', company: 'Y' },
  ])
  .exec();

Keys are automatically split into batches of up to 100 (the DynamoDB limit) and sent in parallel; any UnprocessedKeys DynamoDB returns are automatically retried, up to maxAttempt times.

| Option | Type | Default | Description | | ----------------- | --------------------- | ------- | -------------------------------------- | | consistentRead | boolean | false | Perform a strongly consistent read | | projection | string[] \| 'ALL' | — | Attributes to return | | maxAttempt | number | 5 | Retries for unprocessed keys |

Querying an Index

Specify indexName in your query to use a secondary index:

const result = await orderRepository
  .findAll({
    keyCondition: {
      partition: { status: 'pending' },
    },
    indexName: 'orders_by_status',
  })
  .exec();

If indexName is omitted, the repository automatically selects the best matching index based on the key condition attributes.

Custom Client

By default every repository shares a DynamoDBClient built from the ambient AWS SDK configuration (the region, credentials and endpoint the SDK resolves from the environment). Pass a client to reach a table on a different region, account or endpoint, such as a local DynamoDB instance during development:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { createRepository } from '@lafken/dynamo/service';

const client = new DynamoDBClient({
  endpoint: 'http://localhost:8000',
  region: 'us-east-1',
});

export const contactRepository = createRepository(Contact, { client });

Reuse the same instance across your models instead of creating one per repository, so they share a single connection pool.

Caching

Pass cacheTtl (seconds) to findOne, findAll, or getItem to store the result in an in-memory cache shared by that repository, keyed by table name and query:

// Cached for 60 seconds
const item = await contactRepository
  .findOne({ keyCondition: { partition: { email: '[email protected]' } } }, 60)
  .exec();

Call clearCache() to invalidate every cached entry for the repository — useful right after a write that may have left stale data behind:

await contactRepository.create({ ... }).exec();
contactRepository.clearCache();

The cache is in-memory only (per Lambda instance/process) and is never used by scan, batchGet, or transactional reads.

Send Raw Command

Escape hatch for SDK commands not covered by the repository methods. Runs against the same client the repository uses:

import { QueryCommand } from '@aws-sdk/client-dynamodb';

const response = await contactRepository.sendRawCommand(
  new QueryCommand({ TableName: 'contacts', /* ... */ })
);

Transactions

transactionWrite groups multiple write operations (create, update, upsert, delete) into an atomic transaction. All operations succeed or fail together:

import { transactionWrite } from '@lafken/dynamo/service';

await transactionWrite([
  contactRepository.create({
    email: '[email protected]',
    company: 'Acme',
    name: 'New Contact',
    age: 25,
  }),
  orderRepository.update({
    keyCondition: { customerId: 'cust-1', orderId: 'ord-1' },
    setValues: { status: 'confirmed' },
  }),
  contactRepository.delete({
    email: '[email protected]',
    company: 'Acme',
  }),
]);

[!NOTE] Transaction builders are passed without calling .exec() — the transactionWrite function handles execution internally.

The transaction is sent with the client of the repositories that created the builders, so every repository taking part in it must share the same client instance. A transaction is a single request and cannot be split across connections: mixing clients throws before anything is sent.

Transactional Reads

transactionGet reads several items atomically, from any number of tables, so all of them come from the same consistent snapshot. It accepts only getItem queries — any other builder throws — and resolves the items in the same order they were requested, typed by position:

import { transactionGet } from '@lafken/dynamo/service';

const [contact, order] = await transactionGet([
  contactRepository.getItem({ email: '[email protected]', company: 'Acme' }),
  orderRepository.getItem({ customerId: 'cust-1', orderId: 'ord-1' }),
]);

contact is Contact | undefined and order is Order | undefined: an item that does not exist resolves as undefined in its position.

Unlike batchGet, which splits its keys into several requests over a single table, this is one request and cannot be chunked — DynamoDB limits it to 100 items.

[!NOTE] consistentRead and cacheTtl do not apply to the queries of a read transaction: it is already strongly consistent, and the in-memory cache is never read nor populated.

Extending the Table

The DynamoResolver supports an extends function for applying advanced CDKTN configuration to the generated table resource:

new DynamoResolver([
  {
    table: Contact,
    extends: ({ table, scope }) => {
      // Add alarms, policies, or any CDKTN construct
    },
  },
]);