Skip to content
ftschoppPublic

About

A type-safe, functional library for AWS DynamoDB with Single Table Design support.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

110 Commits

Folders and files

Repository files navigation

Dynatable

A type-safe, functional TypeScript library for AWS DynamoDB with first-class Single Table Design support. Designed to make DynamoDB development elegant, productive, and predictable.

✨ Features

  • πŸ” Type-Safe - Full TypeScript support with end-to-end type inference
  • 🎯 Single Table Design - Built-in support for DynamoDB best practices
  • πŸ”„ Functional API - Chainable, composable operations
  • ⚑️ Auto-generated IDs - ULID generation for unique identifiers
  • πŸ•’ Automatic Timestamps - Auto-manage createdAt and updatedAt
  • πŸ”’ Transactions - Atomic operations with TransactWrite support
  • πŸ“¦ Batch Operations - Efficient BatchGet and BatchWrite
  • 🎨 Query Builder - Intuitive API for complex queries
  • βœ… Validation - Built-in Zod schema validation
  • πŸ§ͺ Testable - Easy to mock and test

πŸ“¦ Installation

npm install @ftschopp/dynatable-core
# or
yarn add @ftschopp/dynatable-core
# or
pnpm add @ftschopp/dynatable-core

πŸš€ Quick Start

import { Table } from '@ftschopp/dynatable-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

// Define your schema
const schema = {
  format: 'dynatable:1.0.0',
  version: '1.0.0',

  indexes: {
    primary: { hash: 'PK', sort: 'SK' },
    gsi1: { hash: 'GSI1PK', sort: 'GSI1SK' },
  },

  models: {
    User: {
      key: {
        PK: { type: String, value: 'USER#${username}' },
        SK: { type: String, value: 'USER#${username}' },
      },
      attributes: {
        username: { type: String, required: true },
        name: { type: String, required: true },
        email: { type: String, required: true },
        followerCount: { type: Number, default: 0 },
      },
    },
  },

  params: {
    timestamps: true,
  },
} as const;

// Create table instance
const table = new Table({
  name: 'MyTable',
  client: new DynamoDBClient({ region: 'us-east-1' }),
  schema,
});

// Use it!
async function example() {
  // Create
  const user = await table.entities.User.put({
    username: 'alice',
    name: 'Alice Smith',
    email: 'alice@example.com',
  })
    .ifNotExists()
    .execute();

  // Read
  const retrieved = await table.entities.User.get({
    username: 'alice',
  }).execute();

  // Update
  await table.entities.User.update({
    username: 'alice',
  })
    .set('name', 'Alice Johnson')
    .add('followerCount', 1)
    .returning('ALL_NEW')
    .execute();

  // Query
  const users = await table.entities.User.query()
    .where((attr, op) => op.eq(attr.username, 'alice'))
    .execute();

  // Delete
  await table.entities.User.delete({
    username: 'alice',
  }).execute();
}

πŸ” Type Safety

Dynatable provides full TypeScript support with AWS SDK types for maximum type safety:

Entity Operations

All entity operations (put, update, delete, get) have typed dbParams() methods:

// dbParams() returns PutCommandInput from @aws-sdk/lib-dynamodb
const params = table.entities.User.put({ username: 'alice', name: 'Alice' }).dbParams();
// params: PutCommandInput

// Update operations return UpdateCommandInput
const updateParams = table.entities.User.update({ username: 'alice' })
  .set('name', 'Bob')
  .dbParams();
// updateParams: UpdateCommandInput

Transaction Operations

Transaction builders accept typed parameters from AWS SDK:

import type { TransactPutParams, TransactUpdateParams } from '@ftschopp/dynatable-core';

await table
  .transactWrite()
  .addPut(table.entities.Like.put({ photoId: '123', likingUsername: 'alice' }).dbParams())
  .addUpdate(
    table.entities.Photo.update({ username: 'bob', photoId: '123' }).add('likesCount', 1).dbParams()
  )
  .execute();

TypeScript validates all parameters at compile time, catching errors before runtime.

πŸ“– Documentation

Full documentation is available at the docs site:

Examples

Check out complete examples:

πŸ—οΈ Project Structure

This is a Turborepo monorepo with the following structure:

dynatable/
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ crud/                  # Example CRUD application
β”‚   β”œβ”€β”€ docs/                  # Docusaurus documentation site
β”‚   └── migrations-playground/ # Sandbox for migration scenarios
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ core/                  # Main Dynatable library
β”‚   β”œβ”€β”€ migrations/            # DynamoDB migration CLI
β”‚   β”œβ”€β”€ eslint-config/         # Shared ESLint configuration
β”‚   └── typescript-config/     # Shared TypeScript configuration

Packages

  • @ftschopp/dynatable-core - The main Dynatable library with full type-safe DynamoDB operations
  • @ftschopp/dynatable-migrations - DynamoDB migration tool with schema versioning and single table design support
  • @repo/eslint-config - Shared ESLint configurations for all packages
  • @repo/typescript-config - Shared TypeScript configurations for all packages

Apps

  • crud - Example CRUD application demonstrating Dynatable usage
  • docs - Docusaurus documentation site with examples and guides
  • migrations-playground - Sandbox app for exercising the migrations CLI

πŸ”§ Development

Prerequisites

  • Node.js >= 22
  • Yarn 1.22.22

Setup

# Clone the repository
git clone <repository-url>
cd dynatable

# Install dependencies
yarn install

# Build all packages
yarn build

Available Scripts

# Development
yarn dev              # Start development for all apps
yarn debug            # Start development for CRUD app only

# Build
yarn build            # Build all packages and apps

# Testing
yarn test             # Run tests across all packages

# Linting & Formatting
yarn lint             # Lint all packages
yarn format           # Format code with Prettier
yarn check-types      # Type check all packages

# Clean
yarn clean            # Remove all node_modules and build artifacts

Running the Documentation Site

cd apps/docs
npm run start         # Start development server
npm run build         # Build for production

Running the CRUD Example

cd apps/crud
npm run dev           # Start with hot reload

🎯 Core Concepts

Single Table Design

Dynatable is built around DynamoDB's Single Table Design pattern, where all entities live in one table:

// Different entities, same table
table.entities.User.put({ username: 'alice', name: 'Alice' });
table.entities.Post.put({ userId: 'alice', postId: 'post1', title: 'Hello' });
table.entities.Comment.put({
  postId: 'post1',
  commentId: 'c1',
  content: 'Great!',
});

Type Safety

Full TypeScript inference throughout:

const user = await table.entities.User.get({ username: 'alice' }).execute();
// user is typed as: User | undefined

user.username; // βœ… Type: string
user.name; // βœ… Type: string
user.email; // βœ… Type: string
user.invalid; // ❌ TypeScript error

Transactions

Atomic operations across multiple items:

await table
  .transactWrite()
  .addPut(
    table.entities.Like.put({
      photoId: 'photo1',
      username: 'alice',
    })
      .ifNotExists()
      .dbParams()
  )
  .addUpdate(
    table.entities.Photo.update({
      photoId: 'photo1',
    })
      .add('likesCount', 1)
      .dbParams()
  )
  .execute();

Query Builder

Fluent, type-safe query API:

const photos = await table.entities.Photo.query()
  .where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.gt(attr.likesCount, 10)))
  .limit(20)
  .scanIndexForward(false)
  .execute();

Pagination

Built-in pagination support:

const page1 = await table.entities.Post.query()
  .where((attr, op) => op.eq(attr.userId, 'alice'))
  .limit(20)
  .executeWithPagination();

// Next page
if (page1.lastEvaluatedKey) {
  const page2 = await table.entities.Post.query()
    .where((attr, op) => op.eq(attr.userId, 'alice'))
    .startFrom(page1.lastEvaluatedKey)
    .limit(20)
    .executeWithPagination();
}

πŸ§ͺ Testing

The library includes comprehensive test coverage:

# Run all tests
yarn test

# Run tests for core package only
cd packages/core
yarn test

Example test:

import { describe, it, expect } from '@jest/globals';

describe('User operations', () => {
  it('should create a user', async () => {
    const user = await table.entities.User.put({
      username: 'testuser',
      name: 'Test User',
      email: 'test@example.com',
    }).execute();

    expect(user.username).toBe('testuser');
    expect(user.createdAt).toBeDefined();
  });
});

πŸ”‘ Key Features Explained

Auto-generated IDs

const photo = await table.entities.Photo.put({
  username: 'alice',
  url: 'https://example.com/photo.jpg',
}).execute();

console.log(photo.photoId); // Auto-generated ULID: 01HZXY9K2M3N4P5Q6R7S8T9V0W

Automatic Timestamps

const user = await table.entities.User.put({
  username: 'alice',
  name: 'Alice',
}).execute();

console.log(user.createdAt); // ISO 8601 string: '2025-01-15T10:30:00.000Z'
console.log(user.updatedAt); // ISO 8601 string: '2025-01-15T10:30:00.000Z'

// Update automatically refreshes updatedAt
await table.entities.User.update({ username: 'alice' }).set('name', 'Alice Smith').execute();
// updatedAt is now: '2025-01-15T10:35:00.000Z'

Conditional Operations

// Only create if doesn't exist
await table.entities.User.put({
  username: 'alice',
  name: 'Alice',
})
  .ifNotExists()
  .execute();

// Only update if condition is met
await table.entities.Post.update({
  postId: 'post1',
})
  .set('published', true)
  .where((attr, op) => op.eq(attr.status, 'draft'))
  .execute();

Batch Operations

// Batch Get
const users = await table.entities.User.batchGet([
  { username: 'alice' },
  { username: 'bob' },
  { username: 'charlie' },
]).execute();

// Batch Write
await table.entities.User.batchWrite([
  { username: 'alice', name: 'Alice', email: 'alice@example.com' },
  { username: 'bob', name: 'Bob', email: 'bob@example.com' },
]).execute();

GSI (Global Secondary Index) Support

When your model defines GSI key templates, query a GSI by referencing your business attributes β€” Dynatable resolves them to the GSI keys automatically:

// Schema:
//   models.Post.index = {
//     GSI1PK: { type: String, value: 'STATUS#${status}' },
//     GSI1SK: { type: String, value: 'POST#${postId}' },
//   }

const publishedPosts = await table.entities.Post.query()
  .where((attr, op) => op.eq(attr.status, 'published'))
  .useIndex('gsi1')
  .execute();

Scan Operations

// Scan entire table with filter
const activeUsers = await table.entities.User.scan()
  .filter((attr, op) => op.gt(attr.followerCount, 1000))
  .limit(50)
  .execute();

// Scan with pagination
const page = await table.entities.User.scan().limit(20).executeWithPagination();

Advanced Operators

Dynatable provides a comprehensive set of operators for building complex conditions:

// Exists/Not Exists
await table.entities.User.update({ username: 'alice' })
  .set('email', 'alice@example.com')
  .where((attr, op) => op.notExists(attr.email))
  .execute();

// Contains (for strings, sets, lists)
const users = await table.entities.User.query()
  .where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.contains(attr.tags, 'premium')))
  .execute();

// IN operator
const users = await table.entities.User.scan()
  .filter((attr, op) => op.in(attr.status, ['active', 'pending', 'verified']))
  .execute();

// Size function (for strings, sets, lists, maps)
const posts = await table.entities.Post.query()
  .where((attr, op) =>
    op.and(
      op.eq(attr.userId, 'alice'),
      op.size(attr.tags).gte(3) // Posts with 3+ tags
    )
  )
  .execute();

// Attribute type checking
await table.entities.User.scan()
  .filter((attr, op) => op.attributeType(attr.metadata, 'M')) // Type is Map
  .execute();

DynamoDB Logger

Track and debug your DynamoDB operations with the built-in logger:

import { createDynamoDBLogger } from '@ftschopp/dynatable-core';

// Create a logger
const logger = createDynamoDBLogger({
  enabled: true, // Enable/disable logging
  logParams: true, // Log request parameters
  logResponse: false, // Log responses (can be verbose)
});

// Use with Table
const table = new Table({
  name: 'MyTable',
  client,
  schema,
  logger, // Attach the logger
});

// Now all operations are logged to console
await table.entities.User.get({ username: 'alice' }).execute();
// Logs: [DynamoDB] GetCommand { TableName: 'MyTable', Key: { PK: 'USER#alice', SK: 'USER#alice' } }

Middleware System

Extend Dynatable with custom middleware for request/response transformation:

// Middleware to clean internal keys from responses
const cleanKeysMiddleware = {
  response: (item: any) => {
    const { PK, SK, GSI1PK, GSI1SK, ...cleaned } = item;
    return cleaned;
  },
};

// Middleware is automatically applied based on schema params
// Set cleanInternalKeys: true in schema params to enable built-in middleware

TransactGet Operations

Atomic reads across multiple items:

// Read multiple items atomically β€” execute() resolves to a flat array
// of items in the same order as the addGet() calls.
const [user, photo, like] = await table
  .transactGet()
  .addGet(table.entities.User.get({ username: 'alice' }).dbParams())
  .addGet(table.entities.Photo.get({ username: 'alice', photoId: 'photo1' }).dbParams())
  .addGet(table.entities.Like.get({ photoId: 'photo1', likingUsername: 'bob' }).dbParams())
  .execute();

πŸ“š Complete API Reference

Core Exports

import {
  Table, // Main Table class
  type SchemaDefinition, // Schema type definition
  type ModelDefinition, // Model type definition
  type InferModel, // Infer model type from definition
  type InferInput, // Infer input type from definition
  type InferKeyInput, // Infer key input type
  type InferModelFromSchema, // Infer model type from full schema
  type InferInputFromSchema, // Infer input type from full schema
  createDynamoDBLogger, // Create logger instance
  type DynamoDBLogger, // Logger type
  type DynamoDBLoggerConfig, // Logger config type
} from '@ftschopp/dynatable-core';

Available Builders

Each entity provides the following builder methods:

  • get(key) β†’ GetBuilder - Retrieve a single item by key
  • put(item) β†’ PutBuilder - Insert or replace an item
  • update(key) β†’ UpdateBuilder - Update specific attributes
  • delete(key) β†’ DeleteBuilder - Remove an item
  • query() β†’ QueryBuilder - Query items with key conditions
  • scan() β†’ ScanBuilder - Scan table/index with filters
  • batchGet(keys[]) β†’ BatchGetBuilder - Retrieve up to 100 items
  • batchWrite(items[]) β†’ BatchWriteBuilder - Write up to 25 items

Table-level operations:

  • table.transactWrite() β†’ TransactWriteBuilder - Atomic write operations
  • table.transactGet() β†’ TransactGetBuilder - Atomic read operations

All Available Operators

Comparison Operators

  • eq(attr, value) - Equals (=)
  • ne(attr, value) - Not equals (<>)
  • lt(attr, value) - Less than (<)
  • lte(attr, value) - Less than or equal (<=)
  • gt(attr, value) - Greater than (>)
  • gte(attr, value) - Greater than or equal (>=)
  • between(attr, low, high) - Between two values

String Operators

  • beginsWith(attr, prefix) - Begins with a string prefix
  • contains(attr, value) - Contains a substring/value (works with strings, sets, lists)

Existence Operators

  • exists(attr) - Attribute exists
  • notExists(attr) - Attribute does not exist

Type Checking

  • attributeType(attr, type) - Check attribute type
    • Types: 'S' (String), 'N' (Number), 'B' (Binary), 'SS' (String Set), 'NS' (Number Set), 'BS' (Binary Set), 'M' (Map), 'L' (List), 'NULL', 'BOOL'

Advanced Operators

  • in(attr, values[]) - Value is in array
  • size(attr) - Get size of attribute, returns SizeRef with comparison methods:
    • .eq(n), .ne(n), .lt(n), .lte(n), .gt(n), .gte(n)

Logical Operators

  • and(...conditions) - Combine conditions with AND
  • or(...conditions) - Combine conditions with OR
  • not(condition) - Negate a condition

Builder Methods Reference

GetBuilder

.select(attributes?: string[])      // Project specific attributes
.consistentRead(enabled?: boolean)  // Enable consistent read
.dbParams()                         // Get raw DynamoDB parameters
.execute()                          // Execute and return item

PutBuilder

.ifNotExists()                      // Only put if item doesn't exist
.returning(value: 'NONE' | 'ALL_OLD') // Return values
.where(condition)                   // Conditional expression
.dbParams()                         // Get raw parameters
.execute()                          // Execute operation

UpdateBuilder

.set(attr, value)                   // Set attribute value
.remove(attr)                       // Remove attribute
.add(attr, value)                   // Add to number/set
.delete(attr, value)                // Delete from set
.returning('NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW')
.where(condition)                   // Conditional expression
.dbParams()                         // Get raw parameters
.execute()                          // Execute operation

DeleteBuilder

.returning(value: 'NONE' | 'ALL_OLD') // Return deleted item
.where(condition)                   // Conditional expression
.dbParams()                         // Get raw parameters
.execute()                          // Execute operation

QueryBuilder

.where(condition)                   // Key condition + filter expression
.limit(count)                       // Max items to return
.scanIndexForward(forward: boolean) // Sort order (default: true)
.useIndex(indexName)                // Use GSI/LSI
.select(attributes?: string[])      // Project attributes
.consistentRead(enabled?: boolean)  // Consistent read
.startFrom(lastKey)                 // Pagination cursor
.execute()                          // Execute and return items
.executeWithPagination()            // Execute and return { items, lastEvaluatedKey }

ScanBuilder

.where(condition)                   // Filter expression
.limit(count)                       // Max items to return
.select(attributes?: string[])      // Project attributes
.consistentRead(enabled?: boolean)  // Consistent read
.startFrom(lastKey)                 // Pagination cursor
.execute()                          // Execute and return items
.executeWithPagination()            // Execute and return { items, lastEvaluatedKey }

BatchGetBuilder

.select(attributes?: string[])      // Project attributes
.consistentRead(enabled?: boolean)  // Consistent read
.execute()                          // Execute and return items array

BatchWriteBuilder

.execute()                          // Execute batch write

TransactWriteBuilder

.addPut(params)                     // Add put operation
.addUpdate(params)                  // Add update operation
.addDelete(params)                  // Add delete operation
.addConditionCheck(params)          // Add condition check
.withClientRequestToken(token)      // Idempotency token
.execute()                          // Execute transaction

TransactGetBuilder

.addGet(params)                     // Add get operation
.execute()                          // Execute transaction, returns { items }

Schema Definition

const schema = {
  format: 'dynatable:1.0.0', // Schema format version
  version: '1.0.0', // Your schema version

  indexes: {
    primary: {
      hash: 'PK', // Partition key name
      sort: 'SK', // Sort key name (optional)
    },
    gsi1: {
      // Global Secondary Index
      hash: 'GSI1PK',
      sort: 'GSI1SK',
    },
  },

  models: {
    User: {
      key: {
        // Primary key definition
        PK: {
          type: String,
          value: 'USER#${username}', // Template with variables
        },
        SK: {
          type: String,
          value: 'USER#${username}',
        },
      },
      index: {
        // GSI key definition (optional)
        GSI1PK: { type: String, value: 'USER' },
        GSI1SK: { type: String, value: 'USER#${username}' },
      },
      attributes: {
        username: {
          type: String,
          required: true,
        },
        name: {
          type: String,
          required: true,
        },
        email: {
          type: String,
        },
        age: {
          type: Number,
          default: 0,
        },
        userId: {
          type: String,
          generate: 'ulid', // Auto-generate ULID
        },
        tags: {
          type: Array,
        },
      },
    },
  },

  params: {
    timestamps: true, // Auto createdAt/updatedAt as ISO 8601 strings
    cleanInternalKeys: false, // Hide PK/SK in results
  },
} as const;

Type Inference

// Preferred: schema-driven inference (honors params.timestamps β€”
// includes createdAt/updatedAt automatically when enabled)
type User = InferModelFromSchema<typeof schema, 'User'>;
type UserInput = InferInputFromSchema<typeof schema, 'User'>;

// Low-level alternatives that take a ModelDefinition directly.
// Use these only when you don't have the surrounding schema in scope
// (e.g. utilities that consume models). They do NOT see params.timestamps.
type UserModel = InferModel<typeof schema.models.User>;
type UserInputRaw = InferInput<typeof schema.models.User>;

// Infer key input type (only key template variables)
type UserKey = InferKeyInput<typeof schema.models.User>;

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

πŸ“„ License

This project is licensed under the MIT License.

πŸ™ Acknowledgments

πŸ“š Additional Resources

πŸ”— Links


Made with ❀️ for the DynamoDB community

About

A type-safe, functional library for AWS DynamoDB with Single Table Design support.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages