Skip to content

Complete Guide to Decorators in Dynamite

This guide provides comprehensive documentation for all decorators available in Dynamite ORM, including practical examples, common patterns, and best practices.

Table of Contents

  1. Introduction to Decorators
  2. @PrimaryKey - Primary Keys
  3. @Index - GSI Configuration
  4. @IndexSort - Sort key of the table
  5. @Default - Default Values
  6. @Validate - Validation Functions
  7. @Set - Write Transformation
  8. @Get and @Set - Bidirectional Transformation
  9. @NotNull - Required Fields
  10. @CreatedAt - Creation Timestamp
  11. @UpdatedAt - Update Timestamp
  12. @DeleteAt - Soft Delete
  13. @Name - Custom Names
  14. @HasMany - One to Many Relationships
  15. @HasOne - One to One Relationships
  16. @BelongsTo - Many to One Relationships
  17. @ManyToMany - Many to Many Relationships
  18. Lifecycle Hook Decorators
  19. Combining Multiple Decorators
  20. Custom Decorator Patterns
  21. Best Practices

Introduction to Decorators

Decorators in Dynamite are special functions that add metadata and behavior to classes and properties. They allow you to define database schemas in a declarative and type-safe manner.

Basic Concepts

import { Table, PrimaryKey, Default, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  // Primary key decorator
  @PrimaryKey()
  declare id: CreationOptional<string>;

  // Simple field without decorators
  declare name: string;

  // Field with default value
  @Default(() => "customer")
  declare role: CreationOptional<string>;
}

Types of Decorators

Key Decorators: - @PrimaryKey() - Defines the primary key - @Index() - Defines partition key (GSI) - @IndexSort() - Defines the sort key of the table

Data Decorators: - @Default() - Sets default values - @Set() - Transforms values when writing (before saving) - @Get() - Transforms values when reading (from the database) - @Validate() - Validates values before saving - @NotNull() - Marks fields as required

Timestamp Decorators: - @CreatedAt() - Auto-timestamp on creation - @UpdatedAt() - Auto-timestamp on update - @DeleteAt() - Soft delete with timestamp

Relationship Decorators: - @HasMany() - One to many relationship - @HasOne() - One to one relationship - @BelongsTo() - Many to one relationship - @ManyToMany() - Many to many relationship

Configuration Decorators: - @Name() - Custom names for tables/columns


@PrimaryKey - Primary Keys

The @PrimaryKey decorator defines the table's primary key. It internally applies @Index and @IndexSort automatically.

Syntax

@PrimaryKey(): PropertyDecorator

Simple Primary Key

import { Table, PrimaryKey, CreationOptional, Default } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;
}

// Usage
const user = await User.create({
  name: "John Doe",
  email: "john@example.com"
  // id is optional (CreationOptional) and auto-generated
});

console.log(user.id); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"

Primary Key with Static Value

class Product extends Table<Product> {
  @PrimaryKey()
  declare sku: CreationOptional<string>;

  declare name: string;
  declare price: number;
}

// Usage
const product = await Product.create({
  sku: "PROD-001",
  name: "Widget",
  price: 29.99
});

Composite Primary Key (Partition + Sort)

Although @PrimaryKey applies both decorators, you can define composite keys manually:

class Order extends Table<Order> {
  @Index()
  declare user_id: string;

  @IndexSort()
  declare order_date: string;

  declare total: number;
  declare status: string;
}

// Usage
const order = await Order.create({
  user_id: "user-123",
  order_date: new Date().toISOString(),
  total: 99.99,
  status: "pending"
});

// Queries by partition key
const user_orders = await Order.where({ user_id: "user-123" });

// Queries with sort key
const recent_orders = await Order.where({ user_id: "user-123" }, {
  order: "DESC",
  limit: 10
});

Important Characteristics

class Account extends Table<Account> {
  @PrimaryKey()
  declare account_id: CreationOptional<string>;
  // Automatically:
  // - Marked as @Index: it is the partition key of the table
  // - Registered as the primary key of the schema
  // - Filled in with a ULID when no id is given, and immutable afterwards
  // - Rejects anything that is not a non-empty string
}

@Index - GSI Configuration

The @Index decorator marks a property as Partition Key. It is fundamental for efficient queries in DynamoDB.

Syntax

@Index(): PropertyDecorator

Simple Index

class Customer extends Table<Customer> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Index()
  declare email: string;

  declare name: string;
  declare phone: string;
}

// Queries by email (partition key)
const customers = await Customer.where({ email: "john@example.com" });

Global Secondary Index (GSI)

class Article extends Table<Article> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Index()
  declare category: string;

  @Index()
  declare author_id: string;

  declare title: string;
  declare content: string;
  declare published_at: string;
}

// Queries by category
const tech_articles = await Article.where({ category: "technology" });

// Queries by author
const author_articles = await Article.where({ author_id: "author-123" });

Decorator Validations

class InvalidModel extends Table<InvalidModel> {
  @Index()
  declare field1: string;

  @Index() // Error: Only one @Index per table allowed
  declare field2: string;
  // Throws: "Table invalid_models already has a PartitionKey defined"
}

@IndexSort - Sort key of the table

The @IndexSort decorator marks a property as Sort Key. It requires a Partition Key to be defined.

Syntax

@IndexSort(): PropertyDecorator

Basic Sort Key

class Message extends Table<Message> {
  @Index()
  declare conversation_id: string;

  @IndexSort()
  declare timestamp: string;

  declare sender_id: string;
  declare content: string;
}

// Create messages
await Message.create({
  conversation_id: "conv-123",
  timestamp: "2025-01-15T10:30:00Z",
  sender_id: "user-1",
  content: "Hello!"
});

// Queries ordered by timestamp
const messages = await Message.where({ conversation_id: "conv-123" }, {
  order: "ASC" // Ascending order by timestamp
});

// Most recent messages
const recent = await Message.where({ conversation_id: "conv-123" }, {
  order: "DESC",
  limit: 20
});

Range Queries with Sort Key

class Event extends Table<Event> {
  @Index()
  declare venue_id: string;

  @IndexSort()
  declare event_date: string;

  declare name: string;
  declare capacity: number;
}

// Events in a date range
const upcoming = await Event.where("event_date", ">=", "2025-01-01");
const past = await Event.where("event_date", "<", "2025-01-01");

Sort key of the table

class Transaction extends Table<Transaction> {
  @Index()
  declare account_id: string;

  @IndexSort()
  declare transaction_date: string;

  declare amount: number;
  declare type: string;
  declare description: string;
}

// Account transactions ordered by date
const transactions = await Transaction.where({ account_id: "acc-123" }, {
  order: "DESC",
  limit: 50
});

// Last transaction
const last_transaction = await Transaction.last({ account_id: "acc-123" });

Validations

class InvalidSort extends Table<InvalidSort> {
  @IndexSort() // Error: @Index required first
  declare date: string;
  // Throws: "Cannot define a SortKey without a PartitionKey"
}

class DuplicateSort extends Table<DuplicateSort> {
  @Index()
  declare id: string;

  @IndexSort()
  declare date1: string;

  @IndexSort() // Error: Only one @IndexSort allowed
  declare date2: string;
  // Throws: "Table already has a SortKey defined"
}

@Default - Default Values

The @Default decorator sets static or dynamic default values for properties.

Syntax

@Default(value: any | (() => any)): PropertyDecorator

Static Values

class Settings extends Table<Settings> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Default("dark")
  declare theme: CreationOptional<string>;

  @Default(true)
  declare notifications: CreationOptional<boolean>;

  @Default(100)
  declare volume: CreationOptional<number>;

  @Default([])
  declare tags: CreationOptional<string[]>;
}

// Usage
const settings = await Settings.create({}); // All fields optional
console.log(settings.theme); // "dark"
console.log(settings.notifications); // true
console.log(settings.volume); // 100
console.log(settings.tags); // []

Dynamic Values (Functions)

class Document extends Table<Document> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Default(() => new Date().toISOString())
  declare created: CreationOptional<string>;

  @Default(() => `DOC-${Date.now()}`)
  declare code: CreationOptional<string>;

  @Default(() => Math.floor(Math.random() * 1000000))
  declare reference_number: CreationOptional<number>;
}

// Each instance gets unique values
const doc1 = await Document.create({});
const doc2 = await Document.create({});

console.log(doc1.id !== doc2.id); // true
console.log(doc1.code !== doc2.code); // true

Complex Default Values

class UserProfile extends Table<UserProfile> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Default(() => ({
    theme: "light",
    language: "en",
    timezone: "UTC"
  }))
  declare preferences: CreationOptional<Record<string, string>>;

  @Default(() => ({
    email: true,
    sms: false,
    push: true
  }))
  declare notifications: CreationOptional<Record<string, boolean>>;

  @Default(() => [])
  declare recent_searches: CreationOptional<string[]>;
}

// Usage
const profile = await UserProfile.create({});
console.log(profile.preferences); // { theme: "light", language: "en", ... }
console.log(profile.notifications); // { email: true, sms: false, push: true }

Combining with CreationOptional

import { CreationOptional } from "@arcaelas/dynamite";

class Task extends Table<Task> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string; // Required

  @Default(() => "pending")
  declare status: CreationOptional<string>; // Optional

  @Default(() => false)
  declare completed: CreationOptional<boolean>; // Optional

  @Default(() => new Date().toISOString())
  declare due_date: CreationOptional<string>; // Optional
}

// Only title is required
const task = await Task.create({ title: "Complete project" });
console.log(task.status); // "pending"
console.log(task.completed); // false

@Validate - Validation Functions

The @Validate decorator allows defining custom validation functions that run before saving data.

Syntax

@Validate(validator: (value: unknown) => true | string): PropertyDecorator
@Validate(validators: Array<(value: unknown) => true | string>): PropertyDecorator

Simple Validation

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Validate((value) => {
    const email = value as string;
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || "Invalid email";
  })
  declare email: string;

  @Validate((value) => {
    const age = value as number;
    return age >= 18 || "Must be 18 or older";
  })
  declare age: number;
}

// Valid
const user1 = await User.create({
  id: "user-1",
  email: "john@example.com",
  age: 25
});

// Invalid - throws error
try {
  await User.create({
    id: "user-2",
    email: "invalid-email",
    age: 25
  });
} catch (error) {
  console.error(error.message); // "Invalid email"
}

Multiple Validators

class Password extends Table<Password> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  @Validate([
    (v) => (v as string).length >= 8 || "Minimum 8 characters",
    (v) => /[A-Z]/.test(v as string) || "Must contain uppercase",
    (v) => /[a-z]/.test(v as string) || "Must contain lowercase",
    (v) => /[0-9]/.test(v as string) || "Must contain number",
    (v) => /[^A-Za-z0-9]/.test(v as string) || "Must contain symbol"
  ])
  declare password: string;
}

// All validations must pass
try {
  await Password.create({
    user_id: "user-1",
    password: "weak"
  });
} catch (error) {
  console.error(error.message); // "Minimum 8 characters"
}

// Valid
await Password.create({
  user_id: "user-1",
  password: "Str0ng!Pass"
});

Complex Validations

class Product extends Table<Product> {
  @PrimaryKey()
  declare sku: CreationOptional<string>;

  @Validate((value) => {
    const price = value as number;
    if (price < 0) return "Price cannot be negative";
    if (price > 999999.99) return "Price is too high";
    if (!/^\d+(\.\d{1,2})?$/.test(price.toString())) {
      return "Price must have at most 2 decimal places";
    }
    return true;
  })
  declare price: number;

  @Validate((value) => {
    const stock = value as number;
    return Number.isInteger(stock) && stock >= 0 || "Stock must be a positive integer";
  })
  declare stock: number;

  @Validate((value) => {
    const url = value as string;
    try {
      new URL(url);
      return true;
    } catch {
      return "Invalid URL";
    }
  })
  declare image_url: string;
}

Validations with Context

class DateRange extends Table<DateRange> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare start_date: string;

  @Validate(function(value) {
    const end = new Date(value as string);
    const start = new Date(this.start_date);
    return end > start || "End date must be after start date";
  })
  declare end_date: string;
}

@Set - Write Transformation

The @Set decorator transforms values when writing them, before they are saved to the database.

Syntax

@Set(transformer: (next: any, current: any) => any): PropertyDecorator

The transformer receives the incoming value (next) and the current stored value (current). Most transformations only need next.

Basic Transformations

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Set((v) => (v as string).toLowerCase().trim())
  declare email: string;

  @Set((v) => (v as string).trim())
  @Set((v) => v.charAt(0).toUpperCase() + v.slice(1).toLowerCase())
  declare name: string;

  @Set((v) => (v as string).replace(/\D/g, ""))
  declare phone: string;
}

// Usage
const user = await User.create({
  id: "user-1",
  email: "  JOHN@EXAMPLE.COM  ",
  name: "  jOhN dOe  ",
  phone: "+1 (555) 123-4567"
});

console.log(user.email); // "john@example.com"
console.log(user.name); // "John doe"
console.log(user.phone); // "15551234567"

Multiple Transformations

Mutations are executed in declaration order:

class Article extends Table<Article> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Set((v) => (v as string).trim())
  @Set((v) => (v as string).replace(/\s+/g, " "))
  @Set((v) => (v as string).substring(0, 200))
  declare title: string;

  @Set((v) => (v as string).trim())
  @Set((v) => (v as string).replace(/<[^>]*>/g, ""))
  @Set((v) => (v as string).substring(0, 5000))
  declare content: string;
}

Numeric Transformations

class Financial extends Table<Financial> {
  @PrimaryKey()
  declare transaction_id: CreationOptional<string>;

  @Set((v) => Math.round((v as number) * 100) / 100)
  declare amount: number;

  @Set((v) => Math.max(0, Math.min(100, v as number)))
  declare percentage: number;

  @Set((v) => Math.abs(v as number))
  declare quantity: number;
}

// Usage
const transaction = await Financial.create({
  transaction_id: "txn-1",
  amount: 123.456789,
  percentage: 150,
  quantity: -10
});

console.log(transaction.amount); // 123.46
console.log(transaction.percentage); // 100
console.log(transaction.quantity); // 10

Object Transformations

class Settings extends Table<Settings> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  @Set((v) => {
    const config = v as Record<string, any>;
    return Object.keys(config).reduce((acc, key) => {
      acc[key.toLowerCase()] = config[key];
      return acc;
    }, {} as Record<string, any>);
  })
  declare preferences: Record<string, any>;

  @Set((v) => Array.from(new Set(v as string[])))
  declare tags: string[];
}

@Get and @Set - Bidirectional Transformation

Pairing @Get and @Set on the same property transforms values in both directions: @Get runs when reading from the database and @Set runs when saving. Unlike a lone @Set (write-only), combining both decorators handles the complete data cycle conversion.

Syntax

@Get(fromDB: (value: any) => any): PropertyDecorator
@Set(toDB: (next: any, current: any) => any): PropertyDecorator

Parameters

Decorator Type Description
@Get (value) => any Transforms the value when reading from the database. Omit to skip.
@Set (next, current) => any Transforms the value when saving to the database. Omit to skip.

Bidirectional Transformation

import { Get, Set, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  // Boolean stored as number in DynamoDB
  @Get((from) => from === 1)     // DB: 1 -> App: true
  @Set((to) => to ? 1 : 0)       // App: true -> DB: 1
  declare active: boolean;

  // JSON stored as string
  @Get((from) => JSON.parse(from))           // DB: '{"a":1}' -> App: {a:1}
  @Set((to) => JSON.stringify(to))           // App: {a:1} -> DB: '{"a":1}'
  declare metadata: Record<string, any>;
}

// Usage
const user = await User.create({
  id: "user-1",
  active: true,        // Saved as 1 in DynamoDB
  metadata: { role: "admin" }  // Saved as '{"role":"admin"}'
});

// When reading
const fetched = await User.first({ id: "user-1" });
console.log(fetched.active);   // true (not 1)
console.log(fetched.metadata); // { role: "admin" } (not string)

Transform Only on Save

Use a lone @Set to skip transformation when reading:

class Product extends Table<Product> {
  @PrimaryKey()
  declare sku: CreationOptional<string>;

  // Only normalize when saving, no transformation when reading
  @Set((to) => (to as string).toUpperCase().trim())
  declare code: string;
}

// Code is saved in uppercase
await Product.create({ sku: "prod-1", code: "  abc123  " });
// In DB: code = "ABC123"

Transform Only on Read

Use a lone @Get to only transform when reading:

class Settings extends Table<Settings> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  // Parse JSON only when reading (saved as string directly)
  @Get((from) => JSON.parse(from))
  declare preferences: Record<string, any>;

  // Convert timestamp to Date only when reading
  @Get((from) => new Date(from))
  declare last_login: Date;
}

Common Use Cases

Encrypting Sensitive Data

import { createCipheriv, createDecipheriv, randomBytes } from "crypto";

const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!;
const IV_LENGTH = 16;

function encrypt(text: string): string {
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv("aes-256-cbc", Buffer.from(ENCRYPTION_KEY), iv);
  const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
  return iv.toString("hex") + ":" + encrypted.toString("hex");
}

function decrypt(text: string): string {
  const [ivHex, encryptedHex] = text.split(":");
  const iv = Buffer.from(ivHex, "hex");
  const encrypted = Buffer.from(encryptedHex, "hex");
  const decipher = createDecipheriv("aes-256-cbc", Buffer.from(ENCRYPTION_KEY), iv);
  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
}

class UserSecret extends Table<UserSecret> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  @Get(decrypt)
  @Set(encrypt)
  declare api_key: string;

  @Get(decrypt)
  @Set(encrypt)
  declare secret_token: string;
}

Data Compression

import { gzipSync, gunzipSync } from "zlib";

class Document extends Table<Document> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @Get((from) => gunzipSync(Buffer.from(from, "base64")).toString())
  @Set((to) => gzipSync(to).toString("base64"))
  declare content: string;
}

DynamoDB Type Conversion

class Analytics extends Table<Analytics> {
  @PrimaryKey()
  declare event_id: CreationOptional<string>;

  // DynamoDB Set to JavaScript Array
  @Get((from) => Array.from(from))           // Set -> Array
  @Set((to) => new Set(to))                  // Array -> Set
  declare tags: string[];

  // BigInt for large numbers
  @Get((from) => BigInt(from))
  @Set((to) => to.toString())
  declare large_number: bigint;
}

@Set only vs @Get + @Set

Feature @Set only @Get + @Set
Direction Save only Bidirectional
Decorators One decorator Two decorators (@Get, @Set)
Use case Normalization Type conversion
class Example extends Table<Example> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  // @Set only: Normalizes when saving
  @Set((v) => (v as string).toLowerCase())
  declare email: string;  // "JOHN@EXAMPLE.COM" -> "john@example.com" (write only)

  // @Get + @Set: Transforms in both directions
  @Get((from) => from === 1)
  @Set((to) => to ? 1 : 0)
  declare active: boolean;  // true <-> 1 (read and write)
}

@NotNull - Required Fields

The @NotNull decorator marks fields as required, validating that they are not null, undefined, or empty strings.

Syntax

@NotNull(message?: string): PropertyDecorator

Required Fields

class Customer extends Table<Customer> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  declare name: string;

  @NotNull()
  declare email: string;

  @NotNull()
  declare phone: string;

  declare address: string; // Optional
}

// Valid
const customer1 = await Customer.create({
  name: "John Doe",
  email: "john@example.com",
  phone: "555-1234"
});

// Invalid - throws error
try {
  await Customer.create({
    name: "",
    email: "john@example.com",
    phone: "555-1234"
  });
} catch (error) {
  console.error("Validation failed"); // name is empty
}

Combining with @Validate

class Registration extends Table<Registration> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  @Set((v) => (v as string).toLowerCase().trim())
  @Validate((v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v as string) || "Invalid email")
  declare email: string;

  @NotNull()
  @Validate((v) => (v as string).length >= 8 || "Minimum 8 characters")
  declare password: string;
}

Validation on Arrays and Objects

class Project extends Table<Project> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  declare title: string;

  @NotNull()
  @Validate((v) => Array.isArray(v) && v.length > 0 || "Must have at least one member")
  declare team_members: string[];

  @NotNull()
  @Validate((v) => {
    const config = v as Record<string, any>;
    return Object.keys(config).length > 0 || "Configuration cannot be empty";
  })
  declare config: Record<string, any>;
}

@CreatedAt - Creation Timestamp

The @CreatedAt decorator automatically sets the creation date and time in ISO 8601 format.

Syntax

@CreatedAt(): PropertyDecorator

Basic Usage

class Post extends Table<Post> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;

  @CreatedAt()
  declare created_at: CreationOptional<string>;
}

// Date is set automatically
const post = await Post.create({
  title: "My first post",
  content: "Post content"
});

console.log(post.created_at); // "2025-01-15T10:30:00.123Z"

Complete Auditing

class AuditLog extends Table<AuditLog> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare user_id: string;
  declare action: string;
  declare resource: string;

  @CreatedAt()
  declare timestamp: CreationOptional<string>;

  declare ip_address: string;
  declare user_agent: string;
}

// Audit log with automatic timestamp
const log = await AuditLog.create({
  user_id: "user-123",
  action: "DELETE",
  resource: "document-456",
  ip_address: "192.168.1.1",
  user_agent: "Mozilla/5.0..."
});

Queries by Date

class Event extends Table<Event> {
  @Index()
  declare category: string;

  @IndexSort()
  @CreatedAt()
  declare created_at: CreationOptional<string>;

  declare name: string;
  declare description: string;
}

// Recent events by category
const recent_events = await Event.where({ category: "news" }, {
  order: "DESC",
  limit: 20
});

// Events in a date range
const events = await Event.where("created_at", ">=", "2025-01-01T00:00:00Z");

@UpdatedAt - Update Timestamp

The @UpdatedAt decorator automatically updates the date and time each time the record is saved.

Syntax

@UpdatedAt(): PropertyDecorator

Basic Usage

class Document extends Table<Document> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @UpdatedAt()
  declare updated_at: CreationOptional<string>;
}

// Creation
const doc = await Document.create({
  title: "Document",
  content: "Initial content"
});

console.log(doc.created_at); // "2025-01-15T10:00:00Z"
console.log(doc.updated_at); // "2025-01-15T10:00:00Z"

// Update
doc.content = "Updated content";
await doc.save();

console.log(doc.created_at); // "2025-01-15T10:00:00Z" (unchanged)
console.log(doc.updated_at); // "2025-01-15T10:15:00Z" (updated)

Version System

class Article extends Table<Article> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;
  declare author_id: string;

  @Default(() => 1)
  declare version: CreationOptional<number>;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @UpdatedAt()
  declare updated_at: CreationOptional<string>;

  declare last_edited_by: string;
}

// Update with version
const article = await Article.first({ id: "article-123" });
if (article) {
  article.content = "New content";
  article.version = article.version + 1;
  article.last_edited_by = "user-456";
  await article.save();
  // updated_at is automatically updated
}

Change Tracking

class UserProfile extends Table<UserProfile> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  declare name: string;
  declare email: string;
  declare phone: string;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @UpdatedAt()
  declare last_modified: CreationOptional<string>;

  declare modification_count: number;
}

// Increment counter on each modification
const profile = await UserProfile.first({ user_id: "user-123" });
if (profile) {
  profile.name = "New Name";
  profile.modification_count = (profile.modification_count || 0) + 1;
  await profile.save();
  // last_modified is automatically updated
}

@DeleteAt - Soft Delete

The @DeleteAt decorator marks a property as a soft delete column. When destroy() is called, instead of physically deleting the record, this column is set with an ISO 8601 timestamp.

Syntax

@DeleteAt(): PropertyDecorator

Basic Usage

import { DeleteAt, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;

  @DeleteAt()
  declare deleted_at?: string;
}

// Create user
const user = await User.create({
  name: "John Doe",
  email: "john@example.com"
});

// Soft delete - does NOT delete the record, marks deleted_at
await user.destroy();

console.log(user.deleted_at); // "2025-01-15T10:30:00.123Z"
// The record remains in the database with deleted_at set

Query Behavior

With @DeleteAt, normal queries automatically exclude soft-deleted records:

class Article extends Table<Article> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;

  @DeleteAt()
  declare deleted_at?: string;
}

// Create articles
await Article.create({ id: "1", title: "Article 1", content: "..." });
await Article.create({ id: "2", title: "Article 2", content: "..." });
await Article.create({ id: "3", title: "Article 3", content: "..." });

// Soft delete one
const article = await Article.first({ id: "2" });
await article.destroy();

// Normal query - excludes soft-deleted automatically
const active = await Article.where({});
console.log(active.length); // 2 (articles 1 and 3)

// Include soft-deleted records
const all = await Article.where({}, { deleted: true });
console.log(all.length); // 3 (all)

// Only soft-deleted records
const deleted = await Article.where({ deleted_at: { $ne: null } }, { deleted: true });
console.log(deleted.length); // 1 (article 2)

Restore Records

class Document extends Table<Document> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;

  @DeleteAt()
  declare deleted_at?: string;
}

// Soft delete
const doc = await Document.first({ id: "doc-1" });
await doc.destroy();

// Restore
const deleted_doc = await Document.where({ id: "doc-1" }, { deleted: true });
if (deleted_doc[0]) {
  deleted_doc[0].deleted_at = undefined;
  await deleted_doc[0].save();
  // Document appears again in normal queries
}

Trash System

class File extends Table<File> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare path: string;
  declare owner_id: string;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @DeleteAt()
  declare deleted_at?: string;
}

// Move to trash
async function move_to_trash(file_id: string): Promise<void> {
  const file = await File.first({ id: file_id });
  if (file) await file.destroy();
}

// Empty trash (permanently delete)
async function empty_trash(owner_id: string): Promise<void> {
  const trashed = await File.where({ deleted_at: { $ne: null } }, { deleted: true });
  const user_trashed = trashed.filter(f => f.owner_id === owner_id);

  for (const file of user_trashed) {
    // Force permanent deletion
    await File.delete({ id: file.id });
  }
}

// Restore from trash
async function restore_from_trash(file_id: string): Promise<void> {
  const files = await File.where({ id: file_id }, { deleted: true });
  if (files[0]?.deleted_at) {
    files[0].deleted_at = undefined;
    await files[0].save();
  }
}

// List trash
async function list_trash(owner_id: string): Promise<File[]> {
  const trashed = await File.where({ deleted_at: { $ne: null } }, { deleted: true });
  return trashed.filter(f => f.owner_id === owner_id);
}

Combining with Timestamps

class Post extends Table<Post> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @UpdatedAt()
  declare updated_at: CreationOptional<string>;

  @DeleteAt()
  declare deleted_at?: string;
}

// Complete lifecycle
const post = await Post.create({
  title: "My Post",
  content: "Content"
});
// created_at = "2025-01-15T10:00:00Z"
// updated_at = "2025-01-15T10:00:00Z"
// deleted_at = undefined

post.title = "Updated Title";
await post.save();
// updated_at = "2025-01-15T11:00:00Z"

await post.destroy();
// deleted_at = "2025-01-15T12:00:00Z"

Soft Delete in Transactions

const dynamite = new Dynamite({
  region: "us-east-1",
  tables: [User, Order]
});

await dynamite.connect();

// Atomic soft delete of user and their orders
await dynamite.tx(async (tx) => {
  const user = await User.first({ id: "user-123" });
  const orders = await Order.where({ user_id: "user-123" });

  // Soft delete all orders
  for (const order of orders) {
    await order.destroy({ tx });
  }

  // Soft delete user
  await user.destroy({ tx });
});

Automatic Characteristics

When applying @DeleteAt:

  1. softDelete = true: destroy() writes the timestamp instead of removing the record
  2. Automatic filtering: where() and first() exclude the records that carry it
  3. Opt-in: { deleted: true } in the query options brings them back
  4. Static delete() is unaffected: it always removes the record, soft delete lives in the instance

@Name - Custom Names

The @Name decorator allows customizing table and column names in the database.

Syntax

@Name(name: string): ClassDecorator & PropertyDecorator

Custom Table Name

@Name("custom_users_table")
class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;
}

// Table is created with the name "custom_users_table"

Custom Column Names

class Customer extends Table<Customer> {
  @PrimaryKey()
  @Name("customer_id")
  declare id: string;

  @Name("full_name")
  declare name: string;

  @Name("email_address")
  declare email: string;

  @Name("phone_number")
  declare phone: string;
}

// In DynamoDB: { customer_id, full_name, email_address, phone_number }

Legacy System Compatibility

@Name("legacy_orders")
class Order extends Table<Order> {
  @PrimaryKey()
  @Name("ORDER_ID")
  declare id: string;

  @Name("CUSTOMER_ID")
  declare customer_id: string;

  @Name("ORDER_DATE")
  declare order_date: string;

  @Name("TOTAL_AMOUNT")
  declare total: number;

  @Name("ORDER_STATUS")
  declare status: string;
}

@HasMany - One to Many Relationships

The @HasMany decorator defines relationships where one model has multiple instances of another model.

Syntax

@HasMany(model: () => Model, foreignKey: string, localKey?: string): PropertyDecorator

Parameters

Parameter Type Description
model () => Model Function that returns the related model class
foreignKey string Foreign key in the related model
localKey string Local key (default: 'id')

Basic Relationship

import { HasMany, NonAttribute, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;

  @HasMany(() => Order, "user_id", "id")
  declare orders: NonAttribute<Order[]>;
}

class Order extends Table<Order> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  declare user_id: string;

  declare total: number;
  declare status: string;
}

// Load user with orders
const users = await User.where({ id: "user-123" }, {
  include: {
    orders: {}
  }
});

console.log(users[0].orders); // Order[]

Filtered Relationships

// Get user with completed orders
const users = await User.where({ id: "user-123" }, {
  include: {
    orders: {
      where: { status: "completed" },
      limit: 10,
      order: "DESC"
    }
  }
});

Nested Relationships

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;

  @HasMany(() => Order, "user_id", "id")
  declare orders: NonAttribute<Order[]>;
}

class Order extends Table<Order> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare user_id: string;
  declare total: number;

  @HasMany(() => OrderItem, "order_id", "id")
  declare items: NonAttribute<OrderItem[]>;
}

class OrderItem extends Table<OrderItem> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare order_id: string;
  declare product_id: string;
  declare quantity: number;
  declare price: number;
}

// Load users with orders and items
const users = await User.where({}, {
  include: {
    orders: {
      include: {
        items: {}
      }
    }
  }
});

@HasOne - One to One Relationships

The @HasOne decorator defines relationships where one model has exactly one instance of another model.

Syntax

@HasOne(model: () => Model, foreignKey: string, localKey?: string): PropertyDecorator

Parameters

Parameter Type Description
model () => Model Function that returns the related model class
foreignKey string Foreign key in the related model
localKey string Local key (default: 'id')

Basic Relationship

import { HasOne, NonAttribute, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;

  @HasOne(() => Profile, "user_id", "id")
  declare profile: NonAttribute<Profile | null>;
}

class Profile extends Table<Profile> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  declare user_id: string;

  declare bio: string;
  declare avatar_url: string;
  declare website: string;
}

// Load user with profile
const users = await User.where({ id: "user-123" }, {
  include: {
    profile: {}
  }
});

console.log(users[0].profile?.bio); // "Software developer..."

User with Settings

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;

  @HasOne(() => UserSettings, "user_id", "id")
  declare settings: NonAttribute<UserSettings | null>;
}

class UserSettings extends Table<UserSettings> {
  @PrimaryKey()
  declare user_id: CreationOptional<string>;

  declare theme: string;
  declare language: string;
  declare notifications_enabled: boolean;
}

// Load user with settings
const user = await User.first({ id: "user-123" }, {
  include: {
    settings: {}
  }
});

if (user?.settings) {
  console.log(user.settings.theme); // "dark"
}

@BelongsTo - Many to One Relationships

The @BelongsTo decorator defines relationships where a model belongs to another model.

Syntax

@BelongsTo(model: () => Model, related_key: string, local_key: string): PropertyDecorator

Parameters

Parameter Type Description
model () => Model Function that returns the related model class
localKey string Local key that references the parent model
foreignKey string Key in the parent model (default: 'id')

Basic Relationship

import { BelongsTo, NonAttribute, CreationOptional } from "@arcaelas/dynamite";

class Order extends Table<Order> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  declare user_id: string;

  declare total: number;
  declare status: string;

  @BelongsTo(() => User, "id", "user_id")
  declare user: NonAttribute<User | null>;
}

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;
}

// Load order with user
const orders = await Order.where({ id: "order-123" }, {
  include: {
    user: {}
  }
});

console.log(orders[0].user?.name); // "John Doe"

Multiple Relationships

class OrderItem extends Table<OrderItem> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare order_id: string;
  declare product_id: string;
  declare quantity: number;

  @BelongsTo(() => Order, "id", "order_id")
  declare order: NonAttribute<Order | null>;

  @BelongsTo(() => Product, "id", "product_id")
  declare product: NonAttribute<Product | null>;
}

// Load item with order and product
const items = await OrderItem.where({ id: "item-123" }, {
  include: {
    order: {},
    product: {}
  }
});

@ManyToMany - Many to Many Relationships

The @ManyToMany decorator defines relationships where multiple instances of one model can be associated with multiple instances of another model through a pivot table.

Syntax

@ManyToMany(
  model: () => Model,
  pivotTable: string,
  foreignKey: string,
  relatedKey: string,
  localKey?: string,
  relatedPK?: string
): PropertyDecorator

Parameters

Parameter Type Description
model () => Model Function that returns the related model class
pivotTable string Name of the pivot table
foreignKey string Foreign key in pivot table pointing to current model
relatedKey string Foreign key in pivot table pointing to related model
localKey string Local key (default: 'id')
relatedPK string Related model's primary key (default: 'id')

Basic Relationship

import { ManyToMany, NonAttribute, CreationOptional } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;

  @ManyToMany(() => Role, "users_roles", "user_id", "role_id", "id", "id")
  declare roles: NonAttribute<Role[]>;
}

class Role extends Table<Role> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare permissions: string[];

  @ManyToMany(() => User, "users_roles", "role_id", "user_id", "id", "id")
  declare users: NonAttribute<User[]>;
}

// Load user with roles
const users = await User.where({ id: "user-123" }, {
  include: {
    roles: {}
  }
});

console.log(users[0].roles); // Role[]

Tags System

class Article extends Table<Article> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare content: string;

  @ManyToMany(() => Tag, "articles_tags", "article_id", "tag_id", "id", "id")
  declare tags: NonAttribute<Tag[]>;
}

class Tag extends Table<Tag> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare slug: string;

  @ManyToMany(() => Article, "articles_tags", "tag_id", "article_id", "id", "id")
  declare articles: NonAttribute<Article[]>;
}

// Load articles with tags
const articles = await Article.where({}, {
  include: {
    tags: {}
  }
});

// Load tag with articles
const tags = await Tag.where({ slug: "javascript" }, {
  include: {
    articles: {
      limit: 10,
      order: "DESC"
    }
  }
});

Course Enrollment System

class Student extends Table<Student> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;

  @ManyToMany(() => Course, "enrollments", "student_id", "course_id", "id", "id")
  declare courses: NonAttribute<Course[]>;
}

class Course extends Table<Course> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare title: string;
  declare instructor_id: string;

  @ManyToMany(() => Student, "enrollments", "course_id", "student_id", "id", "id")
  declare students: NonAttribute<Student[]>;
}

// Load student with enrolled courses
const student = await Student.first({ id: "student-123" }, {
  include: {
    courses: {}
  }
});

console.log(student?.courses); // Course[]

Lifecycle Hook Decorators

Method decorators that run automatically around persistence operations. They are opt-in: they only run when the operation receives { hook: true }. Inside a hook, this is the entity instance.

Decorator Runs Argument
@BeforeCreate() Before insert. May mutate this. —
@AfterCreate() After insert (this already persisted). —
@BeforeUpdate() Before update. changes (delta)
@AfterUpdate() After update. changes (delta)
@BeforeDestroy() Before delete. —
@AfterDestroy() After delete. —

Example

import { Table, PrimaryKey, CreationOptional, BeforeCreate, AfterCreate, BeforeUpdate } from "@arcaelas/dynamite";

class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  declare name: string;
  declare email: string;
  declare slug: string;

  @BeforeCreate()
  normalize() {
    // `this` is the entity; mutations are persisted
    this.email = this.email.toLowerCase().trim();
    this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
  }

  @AfterCreate()
  async notify() {
    // Runs after the record has been persisted
    await sendWelcomeEmail(this.email);
  }

  @BeforeUpdate()
  syncEmail(changes: Partial<User>) {
    // `changes` holds the delta about to be written
    if (changes.email) {
      this.email = changes.email.toLowerCase().trim();
    }
  }
}

// Hooks only run when explicitly enabled
const user = await User.create(
  { name: "John Doe", email: "  JOHN@EXAMPLE.COM  " },
  { hook: true }
);

console.log(user.email); // "john@example.com"
console.log(user.slug);  // "john-doe"
  • Multiple hooks of the same type run in declaration order; async hooks are awaited.
  • Activate per operation: User.create(data, { hook: true }), user.update(data, { hook: true }), user.destroy({ hook: true }).
  • In mass update/delete, hooks run once per affected entity.
  • Inside a transaction ({ hook: true, tx }), before* run when queued and after* after commit.
  • increment()/decrement() accept { tx } but do not trigger hooks.

Combining Multiple Decorators

Complete Model with All Decorators

import { Table, PrimaryKey, Index, IndexSort, Default, Validate, Get, Set, NotNull, CreatedAt, UpdatedAt, DeleteAt, Name, HasMany, HasOne, BelongsTo, ManyToMany, CreationOptional, NonAttribute } from "@arcaelas/dynamite";

@Name("users")
class User extends Table<User> {
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  @Set((v) => (v as string).toLowerCase().trim())
  @Validate((v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v as string) || "Invalid email")
  @Name("email_address")
  declare email: string;

  @NotNull()
  @Set((v) => (v as string).trim())
  @Validate([
    (v) => (v as string).length >= 2 || "Name too short",
    (v) => (v as string).length <= 50 || "Name too long"
  ])
  declare name: string;

  @Default(() => 18)
  @Validate((v) => (v as number) >= 0 && (v as number) <= 150 || "Invalid age")
  declare age: CreationOptional<number>;

  @Default(() => "customer")
  @Validate((v) => ["customer", "admin", "moderator"].includes(v as string) || "Invalid role")
  declare role: CreationOptional<string>;

  @Default(() => true)
  @Get((from) => from === 1)
  @Set((to) => to ? 1 : 0)
  declare active: CreationOptional<boolean>;

  @Get((from) => JSON.parse(from))
  @Set((to) => JSON.stringify(to))
  declare preferences: CreationOptional<Record<string, any>>;

  @CreatedAt()
  declare created_at: CreationOptional<string>;

  @UpdatedAt()
  declare updated_at: CreationOptional<string>;

  @DeleteAt()
  declare deleted_at?: string;

  @HasOne(() => Profile, "user_id", "id")
  declare profile: NonAttribute<Profile | null>;

  @HasMany(() => Order, "user_id", "id")
  declare orders: NonAttribute<Order[]>;

  @HasMany(() => Review, "user_id", "id")
  declare reviews: NonAttribute<Review[]>;

  @ManyToMany(() => Role, "users_roles", "user_id", "role_id", "id", "id")
  declare roles: NonAttribute<Role[]>;

  // Computed property
  declare display_name: NonAttribute<string>;

  constructor(data?: any) {
    super(data);
    Object.defineProperty(this, 'display_name', {
      get: () => `${this.name} (${this.role})`,
      enumerable: true
    });
  }
}

Custom Decorator Patterns

Every decorator in Dynamite is built with the same factory the library exports, decorator(). There is no privileged API: @PrimaryKey, @CreatedAt and @HasMany are written exactly like the ones you write yourself.

API of decorator()

import { decorator } from "@arcaelas/dynamite";

function decorator(
  callback: (table_class: any, col: Column, params: any[]) => void
): (...params: any[]) => PropertyDecorator;
  • table_class is the constructor of the model the property belongs to
  • col is the column being decorated
  • params are the arguments the decorator was called with

The Column object

interface Column {
  name: string;                                  // column name in DynamoDB
  get: Array<(current: any) => any>;             // read pipeline
  set: Array<(next: any, current: any) => any>;  // write pipeline
  store: {
    index?: boolean;         // partition key of the <field>_index GSI
    indexSort?: boolean;     // sort key of the table
    primaryKey?: boolean;    // primary key
    softDelete?: boolean;    // soft delete flag
    createdAt?: boolean;     // creation timestamp
    updatedAt?: boolean;     // update timestamp
    readsCurrent?: boolean;  // the pipeline needs the stored value
    relation?: {             // relation metadata
      type: 'HasMany' | 'HasOne' | 'BelongsTo' | 'ManyToMany';
      model: () => any;
      foreignKey: string;
      localKey: string;
      relatedKey?: string;
      pivotTable?: string;
      relatedPK?: string;
    };
  };
}

You extend the pipelines by pushing onto the arrays: col.get.push(fn) and col.set.push(fn). Anything you drop into col.store travels with the schema and is readable through the SCHEMA symbol.

Decorator without parameters

import { decorator } from "@arcaelas/dynamite";

export const Uppercase = decorator((_model, col) => {
  col.set.push((next) => typeof next === "string" ? next.toUpperCase() : next);
});

class User extends Table<User> {
  @Uppercase()
  declare country_code: string;
}

await User.create({ country_code: "us" }); // stored as "US"

Decorator with parameters

export const Length = decorator((_model, col, params) => {
  const [min, max] = params;

  col.set.push((next) => {
    if (typeof next !== "string") throw new TypeError(`${col.name} must be a string`);
    if (next.length < min) throw new Error(`${col.name} needs at least ${min} characters`);
    if (next.length > max) throw new Error(`${col.name} cannot exceed ${max} characters`);
    return next;
  });
});

class User extends Table<User> {
  @Length(3, 50)
  declare username: string;
}

Decorator with both pipelines

export const Json = decorator((_model, col) => {
  col.set.push((next) => typeof next === "object" && next !== null ? JSON.stringify(next) : next);
  col.get.push((current) => {
    if (typeof current !== "string") return current;
    try { return JSON.parse(current); } catch { return current; }
  });
});

class Settings extends Table<Settings> {
  @Json()
  declare preferences: Record<string, unknown>;
}

The write pipeline runs on assignment and the read pipeline on every access, which is what makes @Get a transformation of the value you read and not of the value you store.

Reading the stored value

A function in col.set receives (next, current): the incoming value and the one the instance already held. That is how @CreatedAt stays immutable:

export const Immutable = decorator((_model, col) => {
  col.store.readsCurrent = true;
  col.set.push((next, current) => current ?? next);
});

Declaring the second argument has a cost. update() by primary key resolves in a single write precisely because it does not need to read the record first; a column whose pipeline asks for current forces that read. Mark it with col.store.readsCurrent = true so the library knows, and only declare current when you truly use it.

@Set and @Validate set that flag on their own by inspecting the function you pass them.

Decorator that writes metadata

import { decorator, SCHEMA } from "@arcaelas/dynamite";

export const Searchable = decorator((model, col) => {
  col.store.index = true;                 // <field>_index GSI
  (model as any)[SCHEMA].gsis.add(col.name);
});

The schema behind SCHEMA holds the table name, the primary key, the expected GSIs, the hooks and every column. @Name writes to it to rename a table, and @PrimaryKey to record which column is the key.

Composing decorators

A decorator that is just another one with a fixed argument is written as a plain function, the way @Default and @NotNull are:

import { Set, Validate } from "@arcaelas/dynamite";

export const Slug = () => Set((next: any) =>
  typeof next === "string" ? next.toLowerCase().replace(/\W+/g, "-") : next
);

export const Email = (message = "Invalid email") => Validate((next: any) =>
  /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(next)) || message
);

class User extends Table<User> {
  @Slug() declare handle: string;
  @Email() declare email: string;
}

Order of execution

TypeScript applies decorators bottom to top, so the one closest to the property is pushed into the pipeline first and therefore runs first on write:

class Example extends Table<Example> {
  @Validate((v) => v <= 100 || "Max 100")  // runs third
  @Set((v) => Math.abs(v))                 // runs second
  @NotNull("Required")                     // runs first
  declare value: number;
}

// -50 -> NotNull passes -> Set turns it into 50 -> Validate passes

Best Practices

1. Use CreationOptional Appropriately

class User extends Table<User> {
  // Always CreationOptional with @Default
  @Default(() => crypto.randomUUID())
  declare id: CreationOptional<string>;

  // Always CreationOptional with @CreatedAt/@UpdatedAt
  @CreatedAt()
  declare created_at: CreationOptional<string>;

  // Required fields without CreationOptional
  @NotNull()
  declare email: string;
}

2. Decorator Order

class User extends Table<User> {
  // Recommended order: Key -> Validation -> Transformation -> Defaults -> Timestamps
  @PrimaryKey()
  declare id: CreationOptional<string>;

  @NotNull()
  @Validate((v) => /^[^\s@]+@/.test(v as string) || "Invalid")
  @Set((v) => (v as string).toLowerCase())
  @Name("email_address")
  declare email: string;
}

3. Descriptive Validations

// Bad
@Validate((v) => (v as number) > 0)
declare price: number;

// Good
@Validate((v) => (v as number) > 0 || "Price must be greater than 0")
declare price: number;

4. Relationships with NonAttribute

class User extends Table<User> {
  // Always mark relationships as NonAttribute
  @HasMany(() => Order, "user_id", "id")
  declare orders: NonAttribute<Order[]>;

  @HasOne(() => Profile, "user_id", "id")
  declare profile: NonAttribute<Profile | null>;

  @BelongsTo(() => Company, "id", "company_id")
  declare company: NonAttribute<Company | null>;

  @ManyToMany(() => Role, "users_roles", "user_id", "role_id", "id", "id")
  declare roles: NonAttribute<Role[]>;
}

5. Use snake_case for Field Names

class User extends Table<User> {
  // Good: snake_case
  declare user_id: string;
  declare created_at: string;
  declare first_name: string;

  // Bad: camelCase
  // declare userId: string;
  // declare createdAt: string;
  // declare firstName: string;
}

This guide covers all decorators available in Dynamite with practical examples and recommended patterns for building robust and type-safe models.