A modern, decorator-first ORM for DynamoDB with TypeScript support Type-safe models | Relationships | Lifecycle hooks | Transactions | Query-first reads
import { Dynamite, Table, PrimaryKey, Default, CreatedAt, UpdatedAt, CreationOptional } from "@arcaelas/dynamite";
// Define your model
class User extends Table<User> {
@PrimaryKey()
declare id: CreationOptional<string>;
declare name: string;
declare email: string;
@Default(() => "customer")
declare role: CreationOptional<string>;
@CreatedAt()
declare created_at: CreationOptional<string>;
@UpdatedAt()
declare updated_at: CreationOptional<string>;
}
// Connect to DynamoDB
const dynamite = new Dynamite({
region: "us-east-1",
endpoint: "http://localhost:8000", // DynamoDB Local
credentials: { accessKeyId: "test", secretAccessKey: "test" },
tables: [User]
});
await dynamite.connect();
await dynamite.sync(); // development only: creates missing tables and GSIs
// Use it!
const user = await User.create({ name: "John Doe", email: "john@example.com" });
console.log(user.id); // "01JBQ8..." (ULID by default)
console.log(user.role); // "customer"
console.log(user.created_at); // "2025-01-15T10:30:00.000Z"connect() only configures the client and works out which GSIs the models expect; it never
calls the AWS API. sync() is the one that creates tables, pivot tables and missing indexes,
and it is meant for development. In production the infrastructure declares them with the same
names — <field>_index, partition key on <field>, projection ALL — and sync() is not called.
npm install @arcaelas/dynamiteDecorators need experimentalDecorators in your tsconfig.json. Under Serverless Framework,
esbuild does not honour decorators on its own: register the decorator plugin.
| Decorator | Description |
|---|---|
@PrimaryKey() |
Partition key. Fills in a ULID when no id is given; any non-empty string is accepted, so existing UUID keys keep working. Immutable once assigned |
@Index() |
Partition key of the <field>_index GSI: = and in on that field run a Query instead of a Scan |
@IndexSort() |
Sort key of the table |
| Decorator | Description |
|---|---|
@Default(value | fn) |
Value applied on write when the field is nullish |
@Set(fn) |
Write pipeline: (next, current) => value |
@Get(fn) |
Read pipeline: (current) => value |
@Validate(fn | fn[]) |
Rejects on write. Each validator returns true or the error message |
@NotNull(message?) |
Rejects null, undefined and the empty string. Composed from @Validate |
@Name("custom") |
Renames the column, or the table when applied to the class |
| Decorator | Description |
|---|---|
@CreatedAt() |
ISO timestamp written once and never overwritten |
@UpdatedAt() |
ISO timestamp refreshed on every write, unless an explicit value is given |
@DeleteAt() |
Marks the column as the soft delete flag used by destroy() and where() |
| Decorator | Description |
|---|---|
@HasMany(() => Model, foreign_key, local_key?) |
One-to-many. foreign_key lives in the related model, local_key defaults to "id" |
@HasOne(() => Model, foreign_key, local_key?) |
One-to-one, same argument order |
@BelongsTo(() => Model, related_key, local_key) |
Many-to-one. related_key is the key of the parent, local_key is the field of this model that points at it |
@ManyToMany(() => Model, pivot_table, foreign_key, related_key, local_key?, related_pk?) |
Many-to-many through a pivot table |
| Decorator | Runs |
|---|---|
@BeforeCreate() / @AfterCreate() |
Around create() and the first save() |
@BeforeUpdate() / @AfterUpdate() |
Around update(). Receives the changes delta as its argument |
@BeforeDestroy() / @AfterDestroy() |
Around destroy(), forceDestroy() and static delete() |
Hooks are opt-in per operation with { hook: true }. Inside a hook this is the instance and
may be mutated; several hooks of the same type run in declaration order and async hooks are
awaited. In a transaction the after* hooks run after the commit.
class Article extends Table<Article> {
@PrimaryKey() declare id: CreationOptional<string>;
declare title: string;
@Default("") declare slug: string;
@BeforeCreate()
fill_slug() { this.slug = this.title.toLowerCase().replace(/\W+/g, "-"); }
}
await Article.create({ title: "Hello World" }, { hook: true }); // slug: "hello-world"
await Article.create({ title: "Hello World" }); // slug: ""import type {
CreationOptional, // Optional during create(), present afterwards
NonAttribute, // Excluded from the database (relations, computed fields)
InferAttributes, // Database attributes of a model
InferRelations, // Relations of a model
CreateInput, // Input type of create()
UpdateInput, // Input type of update()
WhereOptions, // Query options
QueryResult, // The array returned by where(), with its cursor
QueryOperator, // Available operators
MutationOptions, // { hook?: boolean; tx?: TransactionContext }
DynamiteConfig // Client configuration
} from "@arcaelas/dynamite";For fields that are optional on creation but always present afterwards:
class User extends Table<User> {
@PrimaryKey()
declare id: CreationOptional<string>; // optional in create()
declare name: string; // required in create()
@CreatedAt()
declare created_at: CreationOptional<string>;
}For relations and computed fields that are never stored:
class User extends Table<User> {
declare first_name: string;
declare last_name: string;
declare full_name: NonAttribute<string>;
@HasMany(() => Order, "user_id")
declare orders: NonAttribute<Order[]>;
}const users = await User.where({}); // every record
const admins = await User.where({ role: "admin" });
const one = await User.where("email", "john@example.com");
const first = await User.first({ id: "01JBQ8..." });
const last = await User.last({});await User.where("age", ">=", 18);
await User.where("status", "!=", "banned");
await User.where("role", "in", ["admin", "moderator"]);
await User.where("email", "$include", "gmail");
await User.where({ age: { $gte: 18, $lte: 65 } });Available operators: =, !=, <>, <, <=, >, >=, in, include, with the aliases
$eq, $ne, $lt, $lte, $gt, $gte, $in, $include. null on = reads as
"the attribute does not exist" and on != as "it does exist".
const users = await User.where({}, {
limit: 10,
skip: 20, // alias: offset
cursor: previous.cursor,
order: { created_at: "DESC" },
attributes: ["id", "name", "email"],
deleted: false, // true also returns the soft-deleted ones
include: {
orders: { where: { status: "completed" }, limit: 5 }
}
});order without a field sorts by the @CreatedAt column, and never by the field you meant unless
you name it: ask for { created_at: "DESC" }, not "DESC".
where() returns the array of instances with a cursor property attached whenever a limit
is given. Feeding that cursor back reads only the next page; skip instead reads and discards
everything before it, so cursors are the cheap way to walk a large table.
let page = await User.where({}, { limit: 50 });
while (page.cursor) {
page = await User.where({}, { limit: 50, cursor: page.cursor });
}class User extends Table<User> {
@PrimaryKey()
declare id: CreationOptional<string>;
// HasMany(model, foreign_key, local_key = "id")
@HasMany(() => Order, "user_id", "id")
declare orders: NonAttribute<Order[]>;
// HasOne(model, foreign_key, local_key = "id")
@HasOne(() => Profile, "user_id", "id")
declare profile: NonAttribute<Profile | null>;
// ManyToMany(model, pivot_table, foreign_key, related_key, local_key = "id", related_pk = "id")
@ManyToMany(() => Role, "user_roles", "user_id", "role_id")
declare roles: NonAttribute<Role[]>;
}
class Order extends Table<Order> {
@PrimaryKey()
declare id: CreationOptional<string>;
@Index()
declare user_id: string;
// BelongsTo(model, related_key, local_key)
@BelongsTo(() => User, "id", "user_id")
declare user: NonAttribute<User | null>;
}The foreign key of every @HasMany and @HasOne is registered as a GSI, so loading a relation
is a Query. Mark the same column with @Index() when you also filter by it directly.
const users = await User.where({}, {
include: {
orders: { where: { status: "completed" } },
profile: true,
roles: true
}
});Relations are batch loaded: one round of queries per relation and per depth level, never one query per parent record. Nesting is allowed up to five levels.
const user = await User.first({ id: "user-1" });
await user.attach(Role, "role-123"); // adds the pivot row if it is not there
await user.detach(Role, "role-123"); // removes it
await user.sync(Role, ["role-1", "role-2"]); // leaves exactly theseconst user = await User.create({ name: "John Doe", email: "john@example.com" });
// Batch: 25 records per request, no duplicate-key check
const users = await User.createMany([
{ name: "Ada", email: "ada@example.com" },
{ name: "Alan", email: "alan@example.com" }
]);create() refuses to overwrite an existing primary key and throws when it already exists.
createMany() cannot express that condition and overwrites instead.
const users = await User.where({ active: true });
const user = await User.first({ id: "user-123" });// Static: updates every record matching the filter
await User.update({ role: "premium" }, { id: "user-123" });
// Instance
await user.update({ name: "Jane Doe" });
// Full rewrite of the item
user.name = "Jane Doe";
await user.save();
// Atomic counters, no read involved
await User.increment("credits", 10, { id: "user-123" });
await user.decrement("credits", 1);update() writes only the fields you pass plus the @UpdatedAt columns. save() rewrites the
whole item, which is what you want after mutating several fields by hand.
// Static: hard delete of everything matching the filter
await User.delete({ status: "inactive" });
// Batch by primary key, no read and no hooks
await User.deleteMany(["user-1", "user-2"]);
// Instance: soft delete when the model has @DeleteAt, hard delete otherwise
await user.destroy();
// Always a hard delete
await user.forceDestroy();class Post extends Table<Post> {
@PrimaryKey()
declare id: CreationOptional<string>;
declare title: string;
@DeleteAt()
declare deleted_at: CreationOptional<string>;
}
await post.destroy(); // writes deleted_at
await Post.where({}); // excludes the soft-deleted ones
await Post.where({}, { deleted: true }); // includes them
await post.forceDestroy(); // removes the recordStatic delete() is always a hard delete: soft delete is a decision of the instance.
await dynamite.tx(async (tx) => {
const user = await User.create({ name: "John" }, { tx });
await Order.create({ user_id: user.id, total: 100 }, { tx });
await User.increment("orders_count", 1, { id: user.id }, { tx });
});Every mutation takes the transaction inside its options object. Nothing is written until the
callback returns: if it throws, no operation is applied. A transaction holds up to 100
operations and is sent in batches of 25. __isPersisted and the after* hooks only fire
once the commit succeeds.
DynamoDB charges for what it reads, so the shape of the query is the bill.
| Query | Command | Note |
|---|---|---|
= on the primary key |
GetItem |
One request, one item read |
in on the primary key |
BatchGetItem |
100 keys per request |
= or in on an @Index column |
Query |
Uses <field>_index |
| Anything else | Scan |
Reads the table and filters server-side |
- A
limitstops the read as soon as it has enough items, and travels to DynamoDB asLimitwhen there is nothing left to filter.first()on an indexed field is a single request. - A read with no
limitthat ends in a Scan is split into four parallel segments: same read units, a fraction of the latency. createMany(),deleteMany(), staticdelete()and massupdate()write in batches of 25.attach(),detach(),sync()and loading a@ManyToManyquery the pivot table through its<foreign_key>_indexGSI; none of them scans it.- A projection with
attributescuts the payload, not the read units: DynamoDB charges for the whole item either way. last()with no sort key has to read everything to find the last one. Preferfirst(filters, { order: { created_at: "DESC" } })over an unbounded table.
The rule of thumb: give every column you filter by an @Index(), and declare the matching GSI
in your infrastructure. Without it the query still works — it just reads the entire table.
const dynamite = new Dynamite({
region: "us-east-1",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
tables: [User, Order, Product]
});
await dynamite.connect();
await dynamite.sync();docker run -d -p 8000:8000 amazon/dynamodb-localconst dynamite = new Dynamite({
region: "us-east-1",
tables: [User, Order, Product]
});
await dynamite.connect();DynamiteConfig extends DynamoDBClientConfig, so anything the AWS SDK accepts —
credential providers, retries, custom endpoints — is accepted here too. With no explicit
credentials the SDK resolves them from the environment.
create(data, options?): Promise<T>
createMany(rows, options?): Promise<T[]>
update(changes, filters, options?): Promise<number>
delete(filters, options?): Promise<number>
deleteMany(ids, options?): Promise<number>
increment(field, amount, filters, options?): Promise<number>
decrement(field, amount, filters, options?): Promise<number>
where(filters, options?): Promise<QueryResult<T>>
where(field, value, options?): Promise<QueryResult<T>>
where(field, operator, value, options?): Promise<QueryResult<T>>
first(filters, options?): Promise<T | undefined>
last(filters?, options?): Promise<T | undefined>save(options?): Promise<boolean>
update(changes, options?): Promise<boolean>
destroy(options?): Promise<null>
forceDestroy(options?): Promise<null>
increment(field, amount?): Promise<void>
decrement(field, amount?): Promise<void>
attach(Model, related_id, pivot_data?): Promise<void>
detach(Model, related_id): Promise<void>
sync(Model, related_ids): Promise<void>
toJSON(): Record<string, unknown>
toString(): stringoptions is always MutationOptions: { hook?: boolean; tx?: TransactionContext }.
For complete documentation, examples, and guides:
MIT License - see LICENSE file for details.
Made with care by Miguel Alejandro - Arcaelas Insiders
