home / skills / martinffx / claude-code-atelier / atelier-typescript-dynamodb-toolbox

This skill helps you design and query DynamoDB single-table schemas with dynamodb-toolbox v2 in TypeScript, boosting type safety and performance.

npx playbooks add skill martinffx/claude-code-atelier --skill atelier-typescript-dynamodb-toolbox

Review the files below or copy the command above to add this skill to your agents.

Files (7)
SKILL.md
10.6 KB
---
name: atelier-typescript-dynamodb-toolbox
description: DynamoDB single-table design using dynamodb-toolbox v2. Use when creating entities, defining key patterns, designing GSIs, writing queries, implementing pagination, or working with any DynamoDB data layer in TypeScript projects.
user-invocable: false
---

# DynamoDB with dynamodb-toolbox v2

Type-safe DynamoDB interactions with Entity and Table abstractions for single-table design.

## When to Use DynamoDB

✓ **Use when:**
- Access patterns are known upfront and stable
- Need predictable sub-10ms performance at scale
- Microservice with clear data boundaries
- Willing to commit to single-table design

✗ **Avoid when:**
- Prototyping with fluid requirements
- Need ad-hoc analytical queries
- Team lacks DynamoDB expertise
- GraphQL resolvers drive access patterns

> DynamoDB inverts the relational paradigm: design for known access patterns, not flexible querying.

## Modeling Checklist

Before implementing:
1. **Define Entity Relationships** - Create ERD with all entities and relationships
2. **Create Entity Chart** - Map each entity to PK/SK patterns
3. **Design GSI Strategy** - Plan secondary access patterns
4. **Document Access Patterns** - List every query the application needs

See [references/modeling.md](references/modeling.md) for detailed methodology.

## Table Configuration

```typescript
import { Table } from 'dynamodb-toolbox/table'

const AppTable = new Table({
  name: process.env.TABLE_NAME || "AppTable",
  partitionKey: { name: "PK", type: "string" },
  sortKey: { name: "SK", type: "string" },
  indexes: {
    GSI1: {
      type: "global",
      partitionKey: { name: "GSI1PK", type: "string" },
      sortKey: { name: "GSI1SK", type: "string" },
    },
    GSI2: {
      type: "global",
      partitionKey: { name: "GSI2PK", type: "string" },
      sortKey: { name: "GSI2SK", type: "string" },
    },
    GSI3: {
      type: "global",
      partitionKey: { name: "GSI3PK", type: "string" },
      sortKey: { name: "GSI3SK", type: "string" },
    },
  },
  entityAttributeSavedAs: "_et", // default, customize if needed
});
```

### Index Purpose

| Index | Purpose |
|-------|---------|
| Main Table (PK/SK) | Primary entity access |
| GSI1 | Collection queries (issues by repo, members by org) |
| GSI2 | Entity-specific queries and relationships (forks) |
| GSI3 | Hierarchical queries with temporal sorting (repos by owner) |

## Entity Definition (v2 syntax)

### Basic Pattern with Linked Keys

```typescript
import { Entity } from 'dynamodb-toolbox/entity'
import { item } from 'dynamodb-toolbox/schema/item'
import { string } from 'dynamodb-toolbox/schema/string'

const UserEntity = new Entity({
  name: "USER",
  table: AppTable,
  schema: item({
    // Business attributes
    username: string().required().key(),
    email: string().required(),
    bio: string().optional(),
  }).and(_schema => ({
    // Computed keys (PK/SK/GSI keys derived from business attributes)
    PK: string().key().link<typeof _schema>(
      ({ username }) => `ACCOUNT#${username}`
    ),
    SK: string().key().link<typeof _schema>(
      ({ username }) => `ACCOUNT#${username}`
    ),
    GSI1PK: string().link<typeof _schema>(
      ({ username }) => `ACCOUNT#${username}`
    ),
    GSI1SK: string().link<typeof _schema>(
      ({ username }) => `ACCOUNT#${username}`
    ),
  })),
});
```

### With Validation

```typescript
const RepoEntity = new Entity({
  name: "REPO",
  table: AppTable,
  schema: item({
    owner: string()
      .required()
      .validate((value: string) => /^[a-zA-Z0-9_-]+$/.test(value))
      .key(),
    repo_name: string()
      .required()
      .validate((value: string) => /^[a-zA-Z0-9_-]+$/.test(value))
      .key(),
    description: string().optional(),
    is_private: boolean().default(false),
  }).and(_schema => ({
    PK: string().key().link<typeof _schema>(
      ({ owner, repo_name }) => `REPO#${owner}#${repo_name}`
    ),
    SK: string().key().link<typeof _schema>(
      ({ owner, repo_name }) => `REPO#${owner}#${repo_name}`
    ),
    // GSI3 for temporal sorting (repos by owner, newest first)
    GSI3PK: string().link<typeof _schema>(
      ({ owner }) => `ACCOUNT#${owner}`
    ),
    GSI3SK: string()
      .default(() => `#${new Date().toISOString()}`)
      .savedAs("GSI3SK"),
  })),
});
```

## Entity Chart (Key Patterns)

| Entity | PK | SK | Purpose |
|--------|----|----|---------|
| User | `ACCOUNT#{username}` | `ACCOUNT#{username}` | Direct access |
| Repository | `REPO#{owner}#{name}` | `REPO#{owner}#{name}` | Direct access |
| Issue | `ISSUE#{owner}#{repo}#{padded_num}` | Same as PK | Direct access + enumeration |
| Comment | `REPO#{owner}#{repo}` | `ISSUE#{padded_num}#COMMENT#{id}` | Comments under issue |
| Star | `ACCOUNT#{username}` | `STAR#{owner}#{repo}#{timestamp}` | Adjacency list pattern |

**Key Pattern Rules:**
- `ENTITY#{id}` - Simple identifier
- `PARENT#{id}#CHILD#{id}` - Hierarchy
- `TYPE#{category}#{identifier}` - Categorization
- `#{timestamp}` - Temporal sorting (# prefix ensures ordering)

## Type Safety

```typescript
import { type InputItem, type FormattedItem } from 'dynamodb-toolbox/entity'

// Type exports
type UserRecord = typeof UserEntity
type UserInput = InputItem<typeof UserEntity>      // For writes
type UserFormatted = FormattedItem<typeof UserEntity> // For reads

// Usage in entities
class User {
  static fromRecord(record: UserFormatted): User { /* ... */ }
  toRecord(): UserInput { /* ... */ }
}
```

See [references/entity-layer.md](references/entity-layer.md) for transformation patterns.

## Repository Pattern

```typescript
import { PutItemCommand, GetItemCommand, DeleteItemCommand } from 'dynamodb-toolbox'

class UserRepository {
  constructor(private entity: UserRecord) {}

  // CREATE with duplicate check
  async create(user: User): Promise<User> {
    try {
      const result = await this.entity
        .build(PutItemCommand)
        .item(user.toRecord())
        .options({
          condition: { attr: "PK", exists: false }, // Prevent duplicates
        })
        .send()

      return User.fromRecord(result.ToolboxItem)
    } catch (error) {
      if (error instanceof ConditionalCheckFailedException) {
        throw new DuplicateEntityError("User", user.username)
      }
      throw error
    }
  }

  // GET by key
  async get(username: string): Promise<User | undefined> {
    const result = await this.entity
      .build(GetItemCommand)
      .key({ username })
      .send()

    return result.Item ? User.fromRecord(result.Item) : undefined
  }

  // UPDATE with existence check
  async update(user: User): Promise<User> {
    try {
      const result = await this.entity
        .build(PutItemCommand)
        .item(user.toRecord())
        .options({
          condition: { attr: "PK", exists: true }, // Must exist
        })
        .send()

      return User.fromRecord(result.ToolboxItem)
    } catch (error) {
      if (error instanceof ConditionalCheckFailedException) {
        throw new EntityNotFoundError("User", user.username)
      }
      throw error
    }
  }

  // DELETE
  async delete(username: string): Promise<void> {
    await this.entity.build(DeleteItemCommand).key({ username }).send()
  }
}
```

See [references/error-handling.md](references/error-handling.md) for error patterns.

## Query Patterns

### Query GSI

```typescript
import { QueryCommand } from 'dynamodb-toolbox/table/actions/query'

// List issues for a repository using GSI1
async listIssues(owner: string, repoName: string): Promise<Issue[]> {
  const result = await this.table
    .build(QueryCommand)
    .entities(this.issueEntity)
    .query({
      partition: `ISSUE#${owner}#${repoName}`,
      index: "GSI1",
    })
    .send()

  return result.Items?.map(item => Issue.fromRecord(item)) || []
}
```

### Query with Range Filter

```typescript
// List by status using beginsWith on SK
async listOpenIssues(owner: string, repoName: string): Promise<Issue[]> {
  const result = await this.table
    .build(QueryCommand)
    .entities(this.issueEntity)
    .query({
      partition: `ISSUE#${owner}#${repoName}`,
      index: "GSI4",
      range: {
        beginsWith: "ISSUE#OPEN#", // Filter to open issues only
      },
    })
    .send()

  return result.Items?.map(item => Issue.fromRecord(item)) || []
}
```

### Pagination

```typescript
// Encode/decode pagination tokens
function encodePageToken(lastEvaluated?: Record<string, unknown>): string | undefined {
  return lastEvaluated
    ? Buffer.from(JSON.stringify(lastEvaluated)).toString("base64")
    : undefined
}

function decodePageToken(token?: string): Record<string, unknown> | undefined {
  return token ? JSON.parse(Buffer.from(token, "base64").toString()) : undefined
}

// Query with pagination
async listReposByOwner(owner: string, limit = 50, offset?: string) {
  const result = await this.table
    .build(QueryCommand)
    .entities(this.repoEntity)
    .query({
      partition: `ACCOUNT#${owner}`,
      index: "GSI3",
      range: { lt: "ACCOUNT#" }, // Filter to only repos (not account itself)
    })
    .options({
      reverse: true,                              // Newest first
      exclusiveStartKey: decodePageToken(offset), // Continue from cursor
      limit,
    })
    .send()

  return {
    items: result.Items?.map(item => Repo.fromRecord(item)) || [],
    nextOffset: encodePageToken(result.LastEvaluatedKey),
  }
}
```

## Transactions

See [references/transactions.md](references/transactions.md) for:
- Multi-entity transactions (PutTransaction + ConditionCheck)
- Atomic counters with `$add(1)`
- TransactionCanceledException handling

## Testing

See [references/testing.md](references/testing.md) for:
- DynamoDB Local setup
- Test fixtures and factories
- Concurrency and temporal sorting tests

## Quick Reference

**Schema:**
- Use `item({})` for schema definition
- Mark key attributes with `.key()`
- Separate business attributes from computed keys using `.and()`
- Use `.link<typeof _schema>()` to compute PK/SK/GSI keys
- Use `.validate()` for field validation
- Use `.savedAs()` when DynamoDB name differs from schema name

**Types:**
- `InputItem<T>` for writes (excludes computed attributes)
- `FormattedItem<T>` for reads (includes all attributes)

**Repository:**
- Use `PutItemCommand` with `{ attr: "PK", exists: false }` for creates
- Use `PutItemCommand` with `{ attr: "PK", exists: true }` for updates
- Use `GetItemCommand` with `.key()` for reads
- Use `QueryCommand` with `.entities()` for type-safe queries

**Errors:**
- `ConditionalCheckFailedException` → DuplicateEntityError (create) or EntityNotFoundError (update)
- Always catch and convert to domain errors

**Testing:**
- Use unique IDs per test run (timestamp-based)
- Clean up test data after each test
- Use DynamoDB Local for development

Overview

This skill provides a TypeScript-focused atelier for designing single-table DynamoDB schemas using dynamodb-toolbox v2. It packages patterns for entity definitions, key design, GSIs, queries, pagination, transactions, and type-safe repository layers. The goal is predictable, high-performance data access with clear modeling and robust error handling.

How this skill works

You define a single Table and multiple typed Entity objects using dynamodb-toolbox v2 schemas that separate business attributes from computed keys. Keys (PK/SK/GSI) are computed via linked schema functions and validated with the schema API, enabling compile-time types for inputs and formatted reads. The skill includes repository patterns that build Put/Get/Delete/Query/Transaction commands, plus utilities for cursor pagination and error translation to domain errors.

When to use it

  • Building a microservice with known, stable access patterns that require sub-10ms reads and writes
  • Implementing single-table design where multiple entity types share one DynamoDB table and GSIs
  • Needing TypeScript type safety for entity input (writes) and formatted items (reads)
  • Designing predictable, index-driven queries, pagination, and transactional operations
  • Enforcing domain validation and converting DynamoDB condition failures into domain errors

Best practices

  • Document all access patterns first: create an ERD, entity chart, and required queries before coding
  • Compute PK/SK and GSI keys from business attributes using linked schema functions to keep reads/writes consistent
  • Plan GSI roles (collection, relationship, temporal sorting) and avoid ad-hoc indexes
  • Use conditional Put/Update for create/update semantics and map ConditionalCheckFailedException to domain errors
  • Encode pagination cursors as base64-encoded LastEvaluatedKey and decode them on subsequent queries
  • Write tests against DynamoDB Local, use unique IDs per test, and clean up fixtures after each run

Example use cases

  • Define User, Repository, Issue, Comment, and Star entities that share a single table and common GSIs
  • List repository issues with GSI queries and filter by status using beginsWith on the sort key
  • Implement paginated repo listings by owner with reverse chronological ordering using a GSI3 SK timestamp
  • Build a repository layer that enforces no-duplicate creates and converts DynamoDB errors to domain errors
  • Run multi-entity transactions for operations that must be atomic (e.g., create issue + increment counter)

FAQ

When should I avoid single-table design with dynamodb-toolbox?

Avoid it for early-stage prototypes with changing access patterns, ad-hoc analytics, or when the team lacks DynamoDB experience.

How do I implement pagination safely?

Encode LastEvaluatedKey as a base64 token for clients, pass it back as exclusiveStartKey, and respect limit and reverse options for consistent ordering.