home / skills / gilbertopsantosjr / fullstacknextjs / dynamodb-onetable

dynamodb-onetable skill

/skills/dynamodb-onetable

This skill helps you design DynamoDB single-table schemas with OneTable, generate DAL functions, and evolve schemas across services.

npx playbooks add skill gilbertopsantosjr/fullstacknextjs --skill dynamodb-onetable

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

Files (1)
SKILL.md
4.5 KB
---
name: dynamodb-onetable
description: Guide for DynamoDB single-table design using OneTable ORM. Use when designing database schemas, writing DAL functions, creating queries, or handling schema evolution in DynamoDB.
---

# DynamoDB Single-Table Design with OneTable

## Key Design Patterns

| Access Pattern | pk | sk | Index |
|---------------|----|----|-------|
| User's items | `USER#${userId}` | `ITEM#${id}` | primary |
| Item by ID | `ITEM#${id}` | `USER#${userId}` | gsi1 |
| Hierarchical | `USER#${userId}` | `PARENT#${parentId}#${id}` | primary |
| By date | `USER#${userId}` | `DATE#${date}#${id}` | primary |

## Schema Definition

```typescript
// db-schema.ts
export const Schema = {
  format: 'onetable:1.1.0',
  version: '0.0.1',
  indexes: {
    primary: { hash: 'pk', sort: 'sk' },
    gsi1: { hash: 'gsi1pk', sort: 'gsi1sk', project: 'all' },
  },
  models: {
    Account: {
      pk: { type: String, value: 'USER#${userId}' },
      sk: { type: String, value: 'ACCOUNT#${id}' },
      gsi1pk: { type: String, value: 'ACCOUNT#${id}' },
      gsi1sk: { type: String, value: 'USER#${userId}' },
      id: { type: String, required: true, generate: 'ulid' },
      userId: { type: String, required: true },
      name: { type: String, required: true },
      balance: { type: Number, default: 0 },
      deleted: { type: Boolean, default: false },
    },
  },
}
```

## DAL Functions

**File naming:** `snake_case` (e.g., `create_account.ts`, `find_by_id.ts`)

**Location:** `features/<feature>/dal/`

**No 'server-only'** - DAL must work in Lambda and Next.js

### Create

```typescript
// dal/create_account.ts
import { ulid } from 'ulid'
import { log } from '@saas4dev/core'

export async function create_account(
  userId: string,
  input: { name: string }
): Promise<Result<Account>> {
  try {
    const entity = await AccountEntity.create({
      id: ulid(),
      userId,
      ...input,
    })
    return { success: true, data: entityToAccount(entity) }
  } catch (error) {
    log.error('[create_account]', { error, userId })
    return { success: false, error: 'Failed to create' }
  }
}
```

### Read

```typescript
// dal/find_account_by_id.ts
export async function find_account_by_id(id: string): Promise<Result<Account | null>> {
  try {
    const entity = await AccountEntity.get(
      { gsi1pk: `ACCOUNT#${id}` },
      { index: 'gsi1' }
    )
    if (!entity || entity.deleted) return { success: true, data: null }
    return { success: true, data: entityToAccount(entity) }
  } catch (error) {
    log.error('[find_account_by_id]', { error, id })
    return { success: false, error: 'Failed to find' }
  }
}
```

### List with Query

```typescript
// dal/list_accounts_by_user.ts
export async function list_accounts_by_user(userId: string): Promise<Result<Account[]>> {
  try {
    const entities = await AccountEntity.find(
      { pk: `USER#${userId}`, sk: { begins: 'ACCOUNT#' } },
      { where: '${deleted} <> {true}' }
    )
    return { success: true, data: entities.map(entityToAccount) }
  } catch (error) {
    log.error('[list_accounts_by_user]', { error, userId })
    return { success: false, error: 'Failed to list' }
  }
}
```

### Soft Delete

```typescript
// dal/delete_account.ts
export async function delete_account(id: string): Promise<Result<void>> {
  try {
    await AccountEntity.update(
      { gsi1pk: `ACCOUNT#${id}` },
      { set: { deleted: true, updatedAt: new Date() }, index: 'gsi1' }
    )
    return { success: true }
  } catch (error) {
    log.error('[delete_account]', { error, id })
    return { success: false, error: 'Failed to delete' }
  }
}
```

## Entity-to-Model Converter

Always convert entities to domain models:

```typescript
function entityToAccount(entity: any): Account {
  return {
    id: entity.id,
    userId: entity.userId,
    name: entity.name,
    balance: entity.balance ?? 0,        // Handle missing fields
    deleted: entity.deleted ?? false,
    createdAt: entity.createdAt ?? new Date(),
    updatedAt: entity.updatedAt ?? new Date(),
  }
}
```

## Schema Evolution

**Adding fields:** Always optional with defaults
```typescript
// GOOD
newField: { type: String, default: '' }

// BAD - breaks existing records
newField: { type: String, required: true }
```

**Process:**
1. Add field as optional with default
2. Update entity-to-model converter
3. Deploy
4. Backfill if needed

## Rules

- Use ULID for IDs (time-sortable)
- Always soft delete (`deleted: true`)
- Log errors with context
- Return `{ success, data?, error? }` format
- Handle missing fields in converters

Overview

This skill is a practical guide for designing DynamoDB single-table schemas using the OneTable ORM. It shows model definitions, access pattern mappings, DAL function patterns, entity-to-model conversion, and a safe schema-evolution process. Use it to standardize data access, queries, deletes, and migrations across serverless and frontend environments.

How this skill works

The guide maps common access patterns to pk/sk and GSIs and provides a canonical Schema object for OneTable. It demonstrates DAL functions (create, read, list, soft-delete) using OneTable entities and ULIDs, and prescribes an entity-to-model converter to handle missing fields. It also defines a safe rollout process for schema changes and operational rules like soft deletes and structured error logging.

When to use it

  • Designing a single-table schema for DynamoDB with OneTable
  • Writing DAL functions that must run in Lambda and Next.js
  • Modeling access patterns and GSI usage for queries
  • Implementing soft delete and consistent ID generation
  • Planning safe schema changes and backfills

Best practices

  • Define indexes (primary, GSI) up front and map access patterns to pk/sk values
  • Use ULID for IDs to keep time-sortable keys
  • Keep DAL functions portable — no server-only dependencies
  • Always soft-delete (deleted: true) rather than hard delete
  • Add new fields as optional with sensible defaults and update converters before deploying
  • Log errors with context and return a consistent { success, data?, error? } shape

Example use cases

  • Create an Account via create_account DAL that generates a ULID and persists user-scoped pk/sk
  • Find an item by ID using a GSI lookup (gsi1) and return null when soft-deleted
  • List a user’s items by querying pk='USER#{userId}' and sk begins with a prefix
  • Mark records deleted with a soft-delete update to preserve history and enable backfills
  • Add a new optional field by deploying schema+converter, then backfill values later

FAQ

How do I avoid breaking existing records when adding fields?

Add the new field as optional with a default, update the entity-to-model converter to handle missing values, deploy, then backfill if needed.

Why use ULID instead of UUID?

ULIDs are time-sortable, which improves write/read patterns and makes chronological scans and indices more predictable.