@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.
Installation
Section titled “Installation”npm install @mantlejs/dynamodb @aws-sdk/client-dynamodb @aws-sdk/util-dynamodbConcepts
Section titled “Concepts”Plugin
Section titled “Plugin”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.
Repository
Section titled “Repository”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
updateByIdandpatchById - 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
Query translation
Section titled “Query translation”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.
Quick start
Section titled “Quick start”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);Local development (DynamoDB Local)
Section titled “Local development (DynamoDB Local)”const app = mantle().configure( dynamodb({ clientConfig: { region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }, }),);Composite key (partition + sort key)
Section titled “Composite key (partition + sort key)”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 keyconst item = await repo.findById({ pk: "ORDER#123", sk: "ITEM#456" });
// findAll — Query is used automatically when partitionKey is in the where clauseconst items = await repo.findAll({ where: { orderId: "ORDER#123" } });dynamodb(config?)
Section titled “dynamodb(config?)”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
DynamoDBClientatapp.get("dynamodb") - Wraps
app.teardown()to callclient.destroy()on shutdown
DynamoDbConfig
Section titled “DynamoDbConfig”| 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. |
DynamoDbRepository<T, D>
Section titled “DynamoDbRepository<T, D>”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;}Properties
Section titled “Properties”| 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 |
Methods (from Repository<T>)
Section titled “Methods (from Repository<T>)”| 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) |
Cursor pagination
Section titled “Cursor pagination”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 pageconst page1 = await repo.findPage({ limit: 20 });
// Next page — pass the cursor from the previous pageif (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.lastKeyafterfindAll()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.
Transactions
Section titled “Transactions”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.
dynamodbify(where)
Section titled “dynamodbify(where)”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.
Error mapping
Section titled “Error mapping”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 } |
Development
Section titled “Development”npx nx build dynamodb # compilenpx nx test dynamodb # run testsnpx nx lint dynamodb # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build dynamodbFirst publish (scoped packages require --access public):
cd packages/dynamodbnpm publish --access publicSubsequent releases — bump version in packages/dynamodb/package.json, then:
cd packages/dynamodbnpm publishTesting locally with Verdaccio
Section titled “Testing locally with Verdaccio”# Terminal 1 — start the local registrynpx nx run @mantle/source:local-registry
# Terminal 2 — publish to itcd packages/dynamodbnpm publish --registry http://localhost:4873
# Install from it in another projectnpm install @mantlejs/dynamodb --registry http://localhost:4873