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 Update entity fields
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)

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
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" },
});
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",
});
@ # 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.
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
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 }); // re-indexes + re-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 and timestamp on write.

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