Referencia de la API de Table¶
Descripción general¶
Table es la clase base de todos los modelos. Aporta el CRUD tipado, el sistema de consultas, la carga de relaciones y el ciclo de vida de una instancia.
Lo que ofrece:
- Tipado estricto derivado de la propia clase
- CRUD como métodos estáticos y como métodos de instancia
- Un sistema de consultas que elige
GetItem,BatchGetItem,QueryoScansegún la forma del filtro - Relaciones
HasMany,HasOne,BelongsToyManyToManycon carga por lotes - Timestamps automáticos y soft delete
- Paginación por cursor, ordenamiento, proyección e includes anidados
Importación¶
Definición de modelo¶
import {
Table, Name, PrimaryKey, NotNull, Default, Index,
CreatedAt, UpdatedAt, DeleteAt, CreationOptional
} from '@arcaelas/dynamite';
@Name("users")
class User extends Table<User> {
@PrimaryKey()
declare id: CreationOptional<string>;
@Index()
@NotNull()
declare email: string;
@NotNull()
declare name: string;
@Default(() => 25)
declare age: CreationOptional<number>;
@CreatedAt()
declare created_at: CreationOptional<string>;
@UpdatedAt()
declare updated_at: CreationOptional<string>;
@DeleteAt()
declare deleted_at: CreationOptional<string>;
}
Constructor¶
constructor(data: Partial<InferAttributes<T>>)¶
Construye una instancia en memoria. No escribe nada en DynamoDB.
Comportamiento:
- Ejecuta el pipeline de escritura de todas las columnas, no solo de las presentes en
data. Por eso@Default,@PrimaryKeyy@CreatedAtquedan resueltos en la construcción, y por eso@NotNullrechaza ahí mismo un campo ausente - Exige un cliente configurado: lanza si no se llamó a
connect() - La instancia no está persistida hasta que corre
save()ocreate()
const user = new User({ email: "john@example.com", name: "John Doe" });
user.id; // "01JBQ8..." — ya generado
user.created_at; // ya generado
await user.save(); // ahora existe en DynamoDB
Opciones de mutación¶
Toda mutación recibe el mismo objeto de opciones como último argumento:
interface MutationOptions {
hook?: boolean; // ejecuta los hooks de ciclo de vida; apagado por defecto
tx?: TransactionContext; // ejecuta dentro de una transacción atómica
}
- Los hooks son opt-in por llamada: sin
{ hook: true }no corre ninguno. - Dentro de una transacción no se escribe nada hasta que el callback retorna. Los hooks
after*y__isPersistedsolo se activan tras el commit. increment()ydecrement()aceptan{ tx }pero nunca disparan hooks.
Métodos de instancia¶
save(options?: MutationOptions): Promise<boolean>¶
Escribe el item completo.
Comportamiento:
- Sobre una instancia que nunca se persistió delega en
create(), que se niega a pisar una clave primaria existente - Sobre una instancia persistida envía un
PutItemcon todas las columnas, así que cualquier campo que hayas mutado a mano queda escrito - Que una instancia esté persistida se registra internamente, no se deduce del id: una instancia nueva ya trae su id resuelto por
@PrimaryKey
Retorna: true
const user = new User({ email: "jane@example.com", name: "Jane Smith" });
await user.save(); // alta
user.name = "Jane Doe";
await user.save(); // reescritura completa del item
update(patch: Partial<InferAttributes<T>>, options?: MutationOptions): Promise<boolean>¶
Actualiza solo los campos que le pasas.
Comportamiento:
- Los campos de relación presentes en
patchse ignoran en lugar de lanzar - Las columnas
@UpdatedAtse renuevan aunque no formen parte depatch - Sin hooks y fuera de una transacción es un solo
UpdateItemque escribe únicamente los campos tocados, condicionado a que el registro siga existiendo. Si ya no existe, retornafalsey no escribe nada - Con
{ hook: true }aplica los cambios, ejecutabeforeUpdate, escribe el item y ejecutaafterUpdate. Ambos hooks reciben el delta de cambios
Retorna: true cuando el registro se actualizó
destroy(options?: MutationOptions): Promise<null>¶
Elimina el registro, de forma suave cuando el modelo lo permite.
Comportamiento:
- Con una columna
@DeleteAtescribe ahí el timestamp actual y guarda: el registro sigue en la tabla y desaparece dewhere() - Sin
@DeleteAtelimina el registro - Lanza
Cannot destroy record without IDcuando la instancia no tiene clave primaria
await post.destroy(); // soft delete
await post.destroy({ hook: true }); // beforeDestroy + afterDestroy
forceDestroy(options?: MutationOptions): Promise<null>¶
Elimina el registro con un DeleteItem, ignorando @DeleteAt.
increment(campo, cantidad = 1): Promise<void> / decrement(campo, cantidad = 1): Promise<void>¶
Suma o resta sobre una columna numérica de forma atómica en el servidor, sin leer el valor previo, y refleja el cambio en memoria.
- El tipo restringe
campoa las columnas numéricas del modelo - Lanza
Cannot increment without primary keycuando la instancia no tiene id
attach<R>(Modelo, related_id, pivot_data?): Promise<void>¶
Agrega una fila a la tabla pivote de una relación @ManyToMany.
- La instancia tiene que estar persistida: si no, lanza
- Es idempotente, un par existente se deja como está
pivot_dataagrega columnas extra a la fila del pivote- La búsqueda usa el GSI
<clave_foranea>_indexdel pivote, nunca un Scan
detach<R>(Modelo, related_id): Promise<void>¶
Quita la fila del pivote de ese par. No hace nada si falta la relación, la fila o la clave local.
sync<R>(Modelo, related_ids): Promise<void>¶
Deja la relación con exactamente related_ids: quita lo que no está en la lista y agrega lo que falta, en lotes de 25.
- Lanza si el modelo relacionado no tiene schema, si no hay relación
@ManyToManyentre ambos modelos, o si la clave local está indefinida
toJSON(): Record<string, unknown>¶
Objeto plano con las columnas del modelo. Omite null y undefined, y serializa recursivamente las relaciones cargadas.
toString(): string¶
JSON.stringify de la instancia.
Métodos estáticos¶
create<M>(data, options?: MutationOptions): Promise<M>¶
Crea un registro.
- Escribe con
attribute_not_existssobre la clave primaria: nunca pisa, y lanzaRecord with <clave> '<valor>' already exists in <tabla>cuando el id ya está tomado - Dentro de una transacción la instancia se marca como persistida solo tras el commit
const user = await User.create({ name: "Juan", email: "juan@example.com" });
await User.create({ name: "Juan" }, { hook: true });
await dynamite.tx(async (tx) => { await User.create({ name: "Juan" }, { tx }); });
createMany<M>(filas, options?: MutationOptions): Promise<M[]>¶
Crea varios registros con BatchWriteItem, 25 por petición, reintentando lo que DynamoDB deje sin procesar.
- No puede comprobar claves primarias duplicadas, que
BatchWriteItemno admite: un registro existente se sobreescribe - Retorna las instancias ya marcadas como persistidas
const logs = await Log.createMany([
{ level: "info", message: "arranque" },
{ level: "warn", message: "cache vacía" }
]);
update<M>(cambios, filtros, options?: MutationOptions): Promise<number>¶
Actualiza todos los registros que casan con filtros y retorna cuántos.
- Con un filtro simple por clave primaria es un solo
UpdateItemcon los campos tocados, sin lectura previa, siempre que ningún@Setni@Validatede esos campos declare el argumentocurrent. Cuando alguno lo declara, el registro se lee primero para poder pasárselo - Con cualquier otro filtro resuelve la consulta, aplica los cambios y escribe en lotes de 25
- Las columnas
@UpdatedAtse renuevan en cada registro afectado - Con
{ hook: true },beforeUpdateyafterUpdatecorren una vez por registro afectado
const afectados = await User.update({ status: "suspended" }, { status: "inactive" });
await User.update({ status: "active" }, { id: "user-1" });
delete<M>(filtros, options?: MutationOptions): Promise<number>¶
Elimina todos los registros que casan con filtros y retorna cuántos.
- Siempre es borrado definitivo, con o sin
@DeleteAt: el soft delete es una decisión de la instancia y vive endestroy() - Un filtro simple por clave primaria sobre un modelo sin
@DeleteAty sin hooks de destroy es un soloDeleteItem - En el resto de los casos resuelve la consulta y borra en lotes de 25
const eliminados = await User.delete({ status: "suspended" });
await User.delete({ id: "user-1" }, { hook: true });
deleteMany<M>(ids, options?: MutationOptions): Promise<number>¶
Elimina por clave primaria con BatchWriteItem, sin leer nada antes. Siempre es borrado definitivo y no ejecuta hooks.
increment<M>(campo, cantidad, filtros, options?): Promise<number> / decrement<M>(...)¶
Suma atómica en el servidor.
- Un filtro por clave primaria actualiza ese único registro sin leerlo
- Cualquier otro filtro resuelve primero la consulta y luego actualiza en paralelo todo lo que casa
- Retorna cuántos registros se tocaron
await User.increment("credits", 10, { id: "user-1" });
await User.decrement("stock", 1, { sku: "ABC" });
first<M>(filtros, options?): Promise<M | undefined>¶
El primer registro que casa con los filtros, o undefined. Es where() con limit: 1, así que sobre un campo indexado es una sola petición.
const user = await User.first({ email: "juan@example.com" });
const reciente = await User.first({ role: "admin" }, { order: { created_at: "DESC" } });
last<M>(filtros?, options?): Promise<M | undefined>¶
El último registro, ordenado en descendente por la columna @CreatedAt o, si no la hay, por la clave primaria.
Sin sort key en la tabla el orden se resuelve en memoria, lo que obliga a leer todo lo que casa con el filtro para quedarse con un registro. Sobre una tabla grande se usa first(filtros, { order: { created_at: "DESC" } }) acotado por un @Index.
where() — Consultas¶
Sobrecargas¶
User.where(filtros, opciones?)
User.where(campo, valor, opciones?)
User.where(campo, operador, valor, opciones?)
await User.where({ status: "active" });
await User.where("name", "Juan");
await User.where("age", ">=", 18);
await User.where({ age: { $gte: 18, $lte: 65 } });
Operadores¶
| Operador | Alias | Significado |
|---|---|---|
= | $eq | Igual. Con null, "el atributo no existe" |
<>, != | $ne | Distinto. Con null, "el atributo sí existe" |
< | $lt | Menor que |
<= | $lte | Menor o igual |
> | $gt | Mayor que |
>= | $gte | Mayor o igual |
in | $in | Contenido en el arreglo |
include | $include, contains, $contains | Contiene el substring o el elemento |
Una columna desconocida lanza Unknown column '<campo>' in <tabla>. Un arreglo vacío en in lanza Operator 'in' requires a non-empty array.
Opciones¶
const users = await User.where({ status: "active" }, {
order: { created_at: "DESC" }, // por campo; "ASC"/"DESC" solo ordena por @CreatedAt
limit: 10,
skip: 20, // alias: offset
cursor: anterior.cursor, // página siguiente; ignora skip
attributes: ["id", "name"], // proyección
deleted: true, // incluye los que tienen soft delete
include: {
profile: true,
orders: { where: { status: "completed" }, limit: 5 }
}
});
limit: 0retorna un arreglo vacío sin tocar la red.ordera secas ordena por la columna@CreatedAt, o por la clave primaria si no la hay. Para ordenar por una fecha hay que nombrarla:{ created_at: "DESC" }.attributesconstruye instancias con solo esas columnas.deletedsustituye al antiguo_includeTrashed, que sigue funcionando como alias.
Resultado y paginación¶
where() retorna el arreglo de instancias con una propiedad no enumerable cursor. Trae valor mientras queden páginas.
let pagina = await User.where({}, { limit: 50 });
while (pagina.cursor) {
pagina = await User.where({}, { limit: 50, cursor: pagina.cursor });
}
skip lee y descarta todo lo anterior en cada página; el cursor lee solo la página pedida.
Costo y rendimiento¶
| Filtro | Comando | Peticiones |
|---|---|---|
= sobre la clave primaria | GetItem | 1 |
in sobre la clave primaria | BatchGetItem | 1 por cada 100 claves |
= o in sobre una columna @Index | Query sobre <campo>_index | 1 por valor distinto |
| Cualquier otro filtro | Scan | la tabla completa, filtrada en el servidor |
- Los filtros extra sobre una lectura por clave primaria se evalúan sobre el item ya leído: la consulta sigue siendo una sola petición.
- Un
limitcorta la lectura en cuanto reúne los registros pedidos, y viaja comoLimitcuando no queda nada que filtrar en el servidor. - Una lectura sin
limitque termina enScanse parte en cuatro segmentos paralelos: las mismas unidades de lectura y una fracción de la latencia. Sinorder, el orden resultante es arbitrario, como ya lo era. - Si el GSI de un
@Indexno existe, la consulta no falla: cae aScan, quita el índice de su registro interno y sigue. Funciona, y cuesta la tabla entera: declara<campo>_indexcon proyecciónALLen la infraestructura. attributesrecorta la carga útil, no las unidades de lectura: DynamoDB cobra el item completo.- Las relaciones se cargan por lotes: una tanda de consultas por relación y por nivel, hasta cinco niveles. Las tablas pivote se leen por su GSI
<clave_foranea>_index.
Errores¶
| Mensaje | Causa |
|---|---|
DynamoDB client no configurado. Use Dynamite.connect() primero. | Se construyó una instancia o se consultó antes de connect() |
Record with <clave> '<valor>' already exists in <tabla> | create() sobre una clave primaria ya tomada |
Unknown column '<campo>' in <tabla> | Un filtro sobre una columna que el modelo no declara |
Operator 'in' requires a non-empty array. | in con un arreglo vacío |
Cannot destroy record without ID | destroy()/forceDestroy() sobre una instancia sin clave primaria |
Cannot increment without primary key | increment()/decrement() sobre una instancia sin clave primaria |
No se puede attach sin ID: la instancia debe persistirse primero con save() o create() | attach() sobre una instancia que nunca se persistió |
Transaction exceeds 100 operations limit | Más de 100 operaciones dentro de un mismo tx() |
Límites¶
- Una transacción admite 100 operaciones como máximo y se envía en lotes de 25.
includeanida hasta cinco niveles.BatchGetItemlee 100 claves por petición yBatchWriteItemescribe 25; la librería trocea y reintenta sola.- DynamoDB limita un item a 400 KB y una página de consulta a 1 MB.
Archivo fuente¶
src/core/table.ts