@jaypie/dynamodb
Prerequisites: npm install @jaypie/dynamodb
Status: Experimental - APIs may change
Overview
Section titled “Overview”@jaypie/dynamodb provides single-table DynamoDB utilities with model-keyed GSIs, hierarchical scoping, and soft delete.
Installation
Section titled “Installation”npm install @jaypie/dynamodbQuick Reference
Section titled “Quick Reference”Entity Operations
Section titled “Entity Operations”| 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) |
Query Functions
Section titled “Query Functions”| 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 |
Seed and Export Functions
Section titled “Seed and Export Functions”| 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 |
Scan and Table Administration
Section titled “Scan and Table Administration”| 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) |
StorableEntity Interface
Section titled “StorableEntity Interface”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;}Table Structure
Section titled “Table Structure”Primary Key
Section titled “Primary Key”| Attribute | Type | Description |
|---|---|---|
id |
PK | UUID — sole primary key (no sort key) |
GSI Pattern
Section titled “GSI Pattern”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 |
Entity Operations
Section titled “Entity Operations”Create Entity
Section titled “Create Entity”import { APEX, createEntity } from "@jaypie/dynamodb";
// indexEntity auto-populates GSI keys, createdAt, updatedAtconst user = await createEntity({ entity: { model: "user", id: crypto.randomUUID(), scope: APEX, name: "Alice", email: "alice@example.com", },});Read Entity
Section titled “Read Entity”import { getEntity } from "@jaypie/dynamodb";
// Primary key is id onlyconst user = await getEntity({ id: "user-123" });Update Entity
Section titled “Update Entity”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.
Patching Fields Without Re-indexing
Section titled “Patching Fields Without Re-indexing”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:
- The index entry moves.
updatedAtis part of every GSI sort key (scope#updatedAt), so eachupdateEntitymoves the entity’sindexModelentry (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. AqueryByScope(or any other index query) that runs between the delete and the insert does not return the entity. - Stale fields overwrite newer ones. The
Putwrites 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 queryByScopeawait patchEntity({ id: endpoint.id, set: { lastDeliveryAt: new Date() },});
// Set and remove in one write, guarded by a conditionconst 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.
setandremovecannot nameid,model,updatedAt,archivedAt,deletedAt, anyindex*attribute, or anypk/skfield of a registered index (scope,alias,category,type,xid, and custom index fields). These throwBadRequestError(400) before any write, because changing them requires re-indexing throughupdateEntity. An empty patch also throwsBadRequestError. - Existence. Every patch is guarded by
attribute_exists(id), so it never creates an item. A missing entity throwsNotFoundError(404). - Conditions.
conditionis combined with the existence guard asattribute_exists(id) AND (condition). When it fails, the item is left unchanged andConflictError(409) is thrown. Placeholders#s<n>,:s<n>, and#r<n>are reserved for the generated expression; passing them innames/valuesthrowsBadRequestError. - Ordering.
updatedAtdoes not change, so listings sorted byupdatedAtdo not reflect a patch. UseupdateEntitywhen a change should move the entity to the top of a listing. - Values.
Datevalues are stored as ISO 8601 strings. TTL is not changed.
Soft Delete
Section titled “Soft Delete”import { deleteEntity } from "@jaypie/dynamodb";
await deleteEntity({ id: "user-123" });// Sets deletedAt, re-indexes with #deleted suffix on GSI pkHard Delete
Section titled “Hard Delete”import { destroyEntity } from "@jaypie/dynamodb";
await destroyEntity({ id: "user-123" });// Permanently removes entityQuery Patterns
Section titled “Query Patterns”Query by Model and Scope
Section titled “Query by Model and Scope”import { APEX, queryByScope } from "@jaypie/dynamodb";
// Get all entities of a model at root scopeconst { items } = await queryByScope({ model: "user", scope: APEX });
// Scope is optional — omit to query across all scopesconst { items: allUsers } = await queryByScope({ model: "user" });
// Get entities under a parentconst { 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.
Hierarchical Structure
Section titled “Hierarchical Structure”@ # Root scope (APEX)├── user#alice-123 # User's items│ ├── order#order-1│ └── order#order-2└── user#bob-456 # Another user's items └── order#order-3Query by Type
Section titled “Query by Type”import { queryByType } from "@jaypie/dynamodb";
// Requires fabricIndex("type") registered for the modelconst { items } = await queryByType({ model: "user", type: "premium",});Pagination
Section titled “Pagination”const { items, lastEvaluatedKey } = await queryByScope({ model: "user", scope: APEX, limit: 10,});
// Next pageconst nextPage = await queryByScope({ model: "user", scope: APEX, limit: 10, startKey: lastEvaluatedKey,});Index Entity
Section titled “Index Entity”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/updatedAtindexEntity(entity, { suffix: "#deleted" }); // override the pk suffixPreserving Timestamps
Section titled “Preserving Timestamps”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 });CDK Integration
Section titled “CDK Integration”import { JaypieDynamoDb } from "@jaypie/constructs";import { fabricIndex } from "@jaypie/fabric";
// Basic table (no GSIs by default)new JaypieDynamoDb(this, "myApp");
// With indexesnew JaypieDynamoDb(this, "myApp", { indexes: [ fabricIndex(), // indexModel fabricIndex("alias"), // indexModelAlias (sparse) ],});Seed and Export
Section titled “Seed and Export”Utilities for bootstrapping and migrating data.
Seed Entities
Section titled “Seed Entities”import { APEX, seedEntities, seedEntityIfNotExists } from "@jaypie/dynamodb";
// Seed single entity if not existsconst created = await seedEntityIfNotExists({ alias: "config-main", model: "config", scope: APEX,});// Returns true if created, false if exists
// Bulk seed with idempotencyconst 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: []
// Optionsawait seedEntities(entities, { dryRun: true }); // Previewawait seedEntities(entities, { replace: true }); // Overwriteawait seedEntities(entities, { preserveTimestamps: true }); // Keep original timestampsExport Entities
Section titled “Export Entities”import { APEX, exportEntities, exportEntitiesToJson } from "@jaypie/dynamodb";
// Export entitiesconst { entities, count } = await exportEntities("lang", APEX);
// With limitconst { entities: limited } = await exportEntities("lang", APEX, 10);
// Export as JSONconst json = await exportEntitiesToJson("lang", APEX); // Prettyconst compact = await exportEntitiesToJson("lang", APEX, false); // CompactRebuilding a Table
Section titled “Rebuilding a Table”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:
- Deploy the new table alongside the old (CDK).
- Pause writes so the copy cannot go stale.
- Scan the old table, transform each record, write to the new table.
- Validate:
countTablematches on both; spot-check records. - Flip
DYNAMODB_TABLE_NAMEto the new table via CDK deploy; resume. destroyTablethe old table later, once confident — it is your rollback until then.
import { countTable, createTable, initClient, scanTable, updateEntity,} from "@jaypie/dynamodb";
initClient({ tableName: NEW }); // singleton → destinationawait 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.
MCP Tools
Section titled “MCP Tools”Local development tools via @jaypie/mcp:
dynamodb_query- Query entitiesdynamodb_get- Get single entitydynamodb_create- Create entitydynamodb_update- Update entitydynamodb_delete- Delete entity
Related
Section titled “Related”- CDK Infrastructure - DynamoDB construct
- @jaypie/constructs - JaypieDynamoDb