Skip to content

@jaypie/dynamodb

Prerequisites: npm install @jaypie/dynamodb

Status: Experimental - APIs may change

@jaypie/dynamodb provides single-table DynamoDB utilities with model-keyed GSIs, hierarchical scoping, and soft delete.

Terminal window
npm install @jaypie/dynamodb
Function Purpose
createEntity Create new entity (returns null if id exists)
getEntity Read entity by ID
updateEntity Replace the whole entity, advance updatedAt, and re-index
patchEntity Set or remove listed fields only, without moving index entries
deleteEntity Soft delete (mark deleted)
archiveEntity Archive (exclude from queries)
destroyEntity Hard delete (permanent)
Function Purpose
queryByScope Query by model, optionally narrowed by scope
queryByAlias Query by alias
queryByCategory Query by category
queryByType Query by type
queryByXid Query by external ID
Function Purpose
seedEntityIfNotExists Seed single entity if not exists
seedEntities Bulk seed with idempotency
exportEntities Export entities by model/scope
exportEntitiesToJson Export as JSON string
Function Purpose
scanTable Async generator over every raw item (schema-agnostic Scan)
countTable Total item count via paginated COUNT scan
createTable Create a table with the registered-model GSI schema
destroyTable Delete a table (tableName required; throws ConfigurationError when missing)

All entities implement this interface:

interface StorableEntity {
id: string; // Primary key (UUID)
model: string; // Entity model name (e.g., "user", "order")
scope: string; // APEX ("@") or "{parent.model}#{parent.id}"
name?: string; // Human-readable name
alias?: string; // Human-friendly slug
category?: string; // Category for filtering
type?: string; // Type for filtering
xid?: string; // External ID
createdAt?: string; // Backfilled by indexEntity
updatedAt?: string; // Managed by indexEntity on every write (kept with preserveTimestamps)
archivedAt?: string; // Set by archiveEntity
deletedAt?: string; // Set by deleteEntity
state?: Record<string, unknown>;
[key: string]: unknown;
}
Attribute Type Description
id PK UUID — sole primary key (no sort key)

GSIs are defined using fabricIndex() from @jaypie/fabric. All GSIs use a composite sort key of scope#updatedAt.

GSI Name PK Pattern SK Attribute Purpose
indexModel {model} indexModelSk = {scope}#{updatedAt} List by model
indexModelAlias {model}#{alias} (sparse) indexModelAliasSk Slug lookup
indexModelCategory {model}#{category} (sparse) indexModelCategorySk Category filter
indexModelType {model}#{type} (sparse) indexModelTypeSk Type filter
indexModelXid {model}#{xid} (sparse) indexModelXidSk External ID lookup
import { APEX, createEntity } from "@jaypie/dynamodb";
// indexEntity auto-populates GSI keys, createdAt, updatedAt
const user = await createEntity({
entity: {
model: "user",
id: crypto.randomUUID(),
scope: APEX,
name: "Alice",
email: "alice@example.com",
},
});
import { getEntity } from "@jaypie/dynamodb";
// Primary key is id only
const user = await getEntity({ id: "user-123" });
import { updateEntity } from "@jaypie/dynamodb";
const updated = await updateEntity({
entity: { ...user, name: "Alice Smith" },
});

updateEntity writes the whole item and advances updatedAt, which moves the entity’s index entries. For fields written often, use patchEntity.

updateEntity writes the whole item with PutItem and sets updatedAt to the write time. For entities that are written often (counters, heartbeats, “last X” fields), that has two consequences:

  1. The index entry moves. updatedAt is part of every GSI sort key (scope#updatedAt), so each updateEntity moves the entity’s indexModel entry (and every other index entry). DynamoDB moves a GSI entry by deleting it and inserting it again, asynchronously, and GSI reads are always eventually consistent. A queryByScope (or any other index query) that runs between the delete and the insert does not return the entity.
  2. Stale fields overwrite newer ones. The Put writes every attribute as the caller read it. When two read-modify-write calls overlap, the later one undoes the earlier one (for example, a status change or a credential rotation).

patchEntity sends a targeted UpdateItem that sets and removes only the listed attributes. It does not change updatedAt or any index key, so the entity stays in place in every index, and it never writes attributes the caller did not list.

import { patchEntity } from "@jaypie/dynamodb";
// Record a heartbeat without moving the entity in queryByScope
await patchEntity({
id: endpoint.id,
set: { lastDeliveryAt: new Date() },
});
// Set and remove in one write, guarded by a condition
const patched = await patchEntity({
id: endpoint.id,
set: { lastDeliveryAt: new Date(), lastStatus: 200 },
remove: ["lastError"],
condition: "#status = :active",
names: { "#status": "status" },
values: { ":active": "active" },
});
// patched: the full entity as stored after the write
Use When
patchEntity Counters, heartbeats, “last X” timestamps, and any field that is not an index key and should not reorder listings
updateEntity Changes to index keys (scope, alias, category, type, xid, custom index fields) or changes that should advance updatedAt
  • Protected attributes. set and remove cannot name id, model, updatedAt, archivedAt, deletedAt, any index* attribute, or any pk/sk field of a registered index (scope, alias, category, type, xid, and custom index fields). These throw BadRequestError (400) before any write, because changing them requires re-indexing through updateEntity. An empty patch also throws BadRequestError.
  • Existence. Every patch is guarded by attribute_exists(id), so it never creates an item. A missing entity throws NotFoundError (404).
  • Conditions. condition is combined with the existence guard as attribute_exists(id) AND (condition). When it fails, the item is left unchanged and ConflictError (409) is thrown. Placeholders #s<n>, :s<n>, and #r<n> are reserved for the generated expression; passing them in names/values throws BadRequestError.
  • Ordering. updatedAt does not change, so listings sorted by updatedAt do not reflect a patch. Use updateEntity when a change should move the entity to the top of a listing.
  • Values. Date values are stored as ISO 8601 strings. TTL is not changed.
import { deleteEntity } from "@jaypie/dynamodb";
await deleteEntity({ id: "user-123" });
// Sets deletedAt, re-indexes with #deleted suffix on GSI pk
import { destroyEntity } from "@jaypie/dynamodb";
await destroyEntity({ id: "user-123" });
// Permanently removes entity
import { APEX, queryByScope } from "@jaypie/dynamodb";
// Get all entities of a model at root scope
const { items } = await queryByScope({ model: "user", scope: APEX });
// Scope is optional — omit to query across all scopes
const { items: allUsers } = await queryByScope({ model: "user" });
// Get entities under a parent
const { items: childEntities } = await queryByScope({
model: "order",
scope: "user#user-123",
});

Each updateEntity moves the entity’s indexModel entry, and a queryByScope that runs during the move can miss it. Write frequently changing fields with patchEntity to keep listings stable.

@ # Root scope (APEX)
├── user#alice-123 # User's items
│ ├── order#order-1
│ └── order#order-2
└── user#bob-456 # Another user's items
└── order#order-3
import { queryByType } from "@jaypie/dynamodb";
// Requires fabricIndex("type") registered for the model
const { items } = await queryByType({
model: "user",
type: "premium",
});
const { items, lastEvaluatedKey } = await queryByScope({
model: "user",
scope: APEX,
limit: 10,
});
// Next page
const nextPage = await queryByScope({
model: "user",
scope: APEX,
limit: 10,
startKey: lastEvaluatedKey,
});

Auto-populate GSI attributes and timestamps:

import { indexEntity } from "@jaypie/dynamodb";
const entity = {
model: "user",
id: "user-123",
scope: "@",
email: "alice@example.com",
type: "premium",
};
const indexed = indexEntity(entity);
// Sets updatedAt, backfills createdAt, populates indexModel, indexModelSk, etc.
indexEntity(entity, { preserveTimestamps: true }); // keep provided createdAt/updatedAt
indexEntity(entity, { suffix: "#deleted" }); // override the pk suffix

Every write stamps updatedAt with the write time, and every scope#updatedAt sort key follows it. Migrations, restores, and replays pass preserveTimestamps: true so imported history sorts by when it happened. Missing timestamps fall back to now.

await createEntity({ entity: historical, preserveTimestamps: true });
await updateEntity({ entity: historical, preserveTimestamps: true });
await transactWriteEntities({ entities, preserveTimestamps: true });
await seedEntities(entities, { preserveTimestamps: true });
import { JaypieDynamoDb } from "@jaypie/constructs";
import { fabricIndex } from "@jaypie/fabric";
// Basic table (no GSIs by default)
new JaypieDynamoDb(this, "myApp");
// With indexes
new JaypieDynamoDb(this, "myApp", {
indexes: [
fabricIndex(), // indexModel
fabricIndex("alias"), // indexModelAlias (sparse)
],
});

Utilities for bootstrapping and migrating data.

import { APEX, seedEntities, seedEntityIfNotExists } from "@jaypie/dynamodb";
// Seed single entity if not exists
const created = await seedEntityIfNotExists({
alias: "config-main",
model: "config",
scope: APEX,
});
// Returns true if created, false if exists
// Bulk seed with idempotency
const result = await seedEntities([
{ alias: "en", model: "lang", scope: APEX },
{ alias: "es", model: "lang", scope: APEX },
]);
// result.created: ["en", "es"]
// result.skipped: [] (existing entities)
// result.errors: []
// Options
await seedEntities(entities, { dryRun: true }); // Preview
await seedEntities(entities, { replace: true }); // Overwrite
await seedEntities(entities, { preserveTimestamps: true }); // Keep original timestamps
import { APEX, exportEntities, exportEntitiesToJson } from "@jaypie/dynamodb";
// Export entities
const { entities, count } = await exportEntities("lang", APEX);
// With limit
const { entities: limited } = await exportEntities("lang", APEX, 10);
// Export as JSON
const json = await exportEntitiesToJson("lang", APEX); // Pretty
const compact = await exportEntitiesToJson("lang", APEX, false); // Compact

The 0.5.0 primary-key change cannot be applied in place — DynamoDB cannot alter a primary key. Moving a populated 0.4.x table forward is a one-off, validated cutover to a new physical table. Run it once per environment (not on every deploy; that is JaypieMigration).

With a CDK-managed table name and a maintenance window:

  1. Deploy the new table alongside the old (CDK).
  2. Pause writes so the copy cannot go stale.
  3. Scan the old table, transform each record, write to the new table.
  4. Validate: countTable matches on both; spot-check records.
  5. Flip DYNAMODB_TABLE_NAME to the new table via CDK deploy; resume.
  6. destroyTable the old table later, once confident — it is your rollback until then.
import {
countTable,
createTable,
initClient,
scanTable,
updateEntity,
} from "@jaypie/dynamodb";
initClient({ tableName: NEW }); // singleton → destination
await createTable({ tableName: NEW }); // new schema, waits until ACTIVE
for await (const item of scanTable({ tableName: OLD })) {
const { sequence, pk, sk, ...rest } = item; // 0.4 → 0.6 transform
await updateEntity({ entity: rest, preserveTimestamps: true }); // re-indexes, keeps timestamps
}
if ((await countTable({ tableName: OLD })) !== (await countTable())) {
throw new Error("Count mismatch — do NOT destroy the old table");
}
// Validated. Flip CDK_ENV to NEW, deploy, then later: destroyTable({ tableName: OLD })

scanTable issues a raw Scan, so it reads the old table despite its mismatched GSIs. The transform is application-specific; updateEntity recomputes every GSI key on write, and preserveTimestamps: true keeps each item’s original createdAt/updatedAt so scope#updatedAt ordering survives the copy.

Local development tools via @jaypie/mcp:

  • dynamodb_query - Query entities
  • dynamodb_get - Get single entity
  • dynamodb_create - Create entity
  • dynamodb_update - Update entity
  • dynamodb_delete - Delete entity