Skip to content

@mantlejs/dynamodb

Amazon DynamoDB adapter for Mantle JS. Provides a dynamodb() Mantle plugin and an abstract DynamoDbRepository<T> base class that implements the Repository<T> interface using the AWS SDK v3.


Terminal window
npm install @mantlejs/dynamodb @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb

dynamodb() is a Mantle plugin. It creates an @aws-sdk/client-dynamodb DynamoDBClient instance and stores it on the application at app.get("dynamodb"). When the application tears down, the client is destroyed automatically.

DynamoDbRepository<T> implements the Mantle Repository<T> contract against DynamoDB. Extend it with a concrete class, declare tableName, and the full set of methods (findAll, findById, save, saveAll, updateById, patchById, deleteById, count) work out of the box.

The repository uses:

  • GetItem for findById
  • PutItem for save
  • BatchWriteItem for saveAll (auto-chunked at 25 items per DynamoDB limit)
  • UpdateItem with a condition expression for updateById and patchById
  • DeleteItem with a condition expression for deleteById
  • Scan for findAll (falls back to Query when a sort key is configured and the partition key is in the where clause)
  • Scan with SELECT COUNT for count

dynamodbify() converts a Mantle QueryParams.where clause into a DynamoDB FilterExpression. Supported operators:

Operator DynamoDB expression
{ field: value } #n = :v
{ field: null } (attribute_not_exists(#n) OR #n = :v) — matches a missing attribute or an explicit DynamoDB NULL-type value
{ field: { $gt } } #n > :v
{ field: { $gte } } #n >= :v
{ field: { $lt } } #n < :v
{ field: { $lte } } #n <= :v
{ field: { $ne: value } } #n <> :v
{ field: { $ne: null } } attribute_exists(#n)
{ field: { $in: [...] } } #n IN (...)
{ field: [a, b] } #n IN (...) (shorthand)
{ field: { $nin: [...] } } NOT (#n IN (...))
{ field: { $begins: "prefix" } } begins_with(#n, :v)
{ field: { $contains: "str" } } contains(#n, :v) — scalar: substring/element match
{ field: { $contains: [...] } } (contains(#n,:v0) AND contains(#n,:v1) ...) — one ANDed contains() per element (every element required, matching the memory/supabase/knex-pg reference semantics)
{ field: { $contains: {...} } } flattened into ANDed leaf conditions, one per nested path — DynamoDB has no native nested-object containment function, so a superset check on an object operand is expressed as “every leaf key/value matches at its path”
{ "a.b.c": value } #n0.#n1.#n2 = :v — nested attribute path (each segment gets its own alias)
{ $or: [...] } (expr OR expr)
{ $and: [...] } (expr AND expr)

$like is deliberately unsupported (DynamoDB has no wildcard matching) and throws BadRequest.

Nested dot-path fields ("metadata.owner.name") address real nested attributes — DynamoDB supports this natively via multi-segment ExpressionAttributeNames aliases (#n0.#n1.#n2), and every operator above (including $in/$nin/null-checks) works the same whether the field is top-level or nested — DynamoDB’s expression language treats a nested document path like any other operand, unlike SQL dialects where IN/null-checks against a JSON path need special handling that isn’t always available.


import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { dynamodb, DynamoDbRepository } from "@mantlejs/dynamodb";
// ─── Domain entity ─────────────────────────────────────────────────────────
interface Post extends Record<string, unknown> {
id: string;
title: string;
body: string;
status: "draft" | "published";
}
// ─── Infrastructure ────────────────────────────────────────────────────────
class PostRepository extends DynamoDbRepository<Post> {
readonly tableName = "posts";
// partitionKey defaults to "id"
/** Custom query: find all published posts */
async findPublished(): Promise<Post[]> {
return this.findAll({ where: { status: "published" } });
}
}
// ─── Application ───────────────────────────────────────────────────────────
const app = mantle()
.configure(express())
.configure(dynamodb({ region: "us-east-1" }));
const posts = new PostRepository(app);
app.use("/posts", new PostService(posts), {
methods: ["find", "get", "create", "update", "patch", "remove"],
});
app.listen(3030);
const app = mantle().configure(
dynamodb({
clientConfig: {
region: "us-east-1",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
},
}),
);
interface OrderItem extends Record<string, unknown> {
orderId: string;
itemId: string;
quantity: number;
}
class OrderItemRepository extends DynamoDbRepository<OrderItem> {
readonly tableName = "order-items";
override readonly partitionKey = "orderId";
override readonly sortKey = "itemId";
}
// findById with composite key
const item = await repo.findById({ pk: "ORDER#123", sk: "ITEM#456" });
// findAll — Query is used automatically when partitionKey is in the where clause
const items = await repo.findAll({ where: { orderId: "ORDER#123" } });

Returns a MantlePlugin. Call via app.configure(dynamodb(config)).

app.configure(dynamodb({
region: "us-east-1", // optional — AWS region
clientConfig: { ... }, // optional — full DynamoDBClientConfig (overrides region)
}));

Side effects:

  • Stores the DynamoDBClient at app.get("dynamodb")
  • Wraps app.teardown() to call client.destroy() on shutdown
Field Type Default Description
region string — AWS region. Falls back to AWS_REGION env var when omitted.
clientConfig DynamoDBClientConfig — Full AWS SDK client config. Takes precedence over region.

Abstract base class. Extend it, declare tableName, and override optional properties.

class UserRepository extends DynamoDbRepository<User> {
readonly tableName = "users";
// Optional overrides:
override readonly partitionKey = "userId";
override readonly sortKey = "email";
override readonly timestamps = false;
}
Property Type Default Description
tableName string — DynamoDB table name (required)
partitionKey string "id" Partition key attribute name
sortKey string | undefined undefined Sort key attribute name (composite key tables)
timestamps boolean true Auto-write createdAt / updatedAt ISO-8601 strings
lastKey Record<string, AttributeValue> | undefined undefined Deprecated — use findPage(). LastEvaluatedKey from the most recent paginated findAll() call
Method Description
findAll(params?) Scan (or Query when PK is in where). Supports skip/limit offsets.
findPage(params?) One page via native cursor pagination — see below
findById(id) GetItem by partition key (or composite key)
save(data) PutItem — auto-generates UUID when no partitionKey is set
saveAll(data[]) BatchWriteItem in chunks of 25 — auto-generates UUIDs as needed
updateById(id, data) UpdateItem (full replacement, throws NotFound if missing)
patchById(id, data) UpdateItem (partial, strips undefined, throws NotFound if missing)
deleteById(id) DeleteItem (throws NotFound if missing)
count(params?) Scan SELECT COUNT (paginates automatically)
withTransaction(fn) Execute writes atomically via TransactWriteItems (100-item limit)

DynamoDB is natively cursor-paginated. Use findPage() — each page carries an opaque cursor (the LastEvaluatedKey, base64-JSON encoded); pass it back to fetch the next page:

const repo = new PostRepository(app);
// First page
const page1 = await repo.findPage({ limit: 20 });
// Next page — pass the cursor from the previous page
if (page1.cursor) {
const page2 = await repo.findPage({ limit: 20, cursor: page1.cursor });
}

findPage() is stateless — the cursor lives entirely in the returned page, so concurrent calls on one repository instance never interfere. It uses Query when a sortKey is defined and the where clause pins the partition key, otherwise Scan. skip and sort are rejected with BadRequest (offsets and cross-page ordering don’t compose with cursors — use findAll() for those). Note that with a filtering where, DynamoDB applies limit before filtering, so a page may hold fewer than limit items while cursor is still set.

Deprecated: the previous mechanism — reading repo.lastKey after findAll() and passing it back as _startKey — still works but shares mutable state on the repository instance. It will be removed in the next minor release.

skip on findAll() performs in-memory offset (scans all pages) — use findPage() for large tables.

await repo.withTransaction(async (tx) => {
await tx.save({ id: "order-1", status: "pending", total: 99.99 });
await tx.save({ id: "item-1", orderId: "order-1", qty: 2 });
});

In transaction mode, mutation methods return the input data immediately (DynamoDB TransactWriteItems does not support ReturnValues). All writes commit atomically when the callback resolves.


Converts a Mantle where clause to a DynamoDB FilterExpression. Useful when writing raw SDK commands inside a custom repository method.

import { dynamodbify } from "@mantlejs/dynamodb";
const { expression, names, values } = dynamodbify({
status: "active",
age: { $gt: 18 },
});
// expression: "#n0 = :v0 AND #n1 > :v1"

buildKeyCondition(partitionKey, sortKey, where)

Section titled “buildKeyCondition(partitionKey, sortKey, where)”

Splits a where clause into a KeyConditionExpression and an optional FilterExpression for use in QueryCommand.


AWS SDK errors are translated to typed MantleError subclasses by exception name:

DynamoDB exception name Error thrown
Already a MantleError (e.g. thrown by a hook, or by a custom repository method) passed through unchanged
ResourceNotFoundException NotFound
ConditionalCheckFailedException NotFound
ProvisionedThroughputExceededException, RequestLimitExceeded, ServiceUnavailable Unavailable
AccessDeniedException, UnauthorizedException Forbidden
ValidationException BadRequest
TransactionConflictException, TransactionCanceledException Conflict
other (including a non-Error throw) GeneralError

import type { DynamoDbConfig } from "@mantlejs/dynamodb";
import type { FilterExpression, WhereClause } from "@mantlejs/dynamodb";
Type Description
DynamoDbConfig Options passed to dynamodb()
WhereClause Input type for dynamodbify() and buildKeyCondition()
FilterExpression Return type of dynamodbify() — { expression, names, values }

Terminal window
npx nx build dynamodb # compile
npx nx test dynamodb # run tests
npx nx lint dynamodb # lint

Build before publishing:

Terminal window
npx nx build dynamodb

First publish (scoped packages require --access public):

Terminal window
cd packages/dynamodb
npm publish --access public

Subsequent releases — bump version in packages/dynamodb/package.json, then:

Terminal window
cd packages/dynamodb
npm publish
Terminal window
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/dynamodb
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/dynamodb --registry http://localhost:4873