17. Transacciones, migraciones, filtros y rendimiento
Hasta aquí el ORM ha servido para leer y escribir filas. Este capítulo trata de lo que separa una aplicación de demostración de una aplicación que se puede desplegar el viernes por la tarde sin miedo: atomicidad (o todo o nada), concurrencia (dos usuarios tocando la misma fila al mismo tiempo), evolución del esquema (cambiar la base de datos sin cortar el servicio), reglas transversales (borrado lógico, multi-inquilino, auditoría) y rendimiento (importar medio millón de filas sin agotar la memoria ni el pool de conexiones). Son los temas en los que un error no produce un mensaje rojo en la consola: produce dinero descontado que nunca llegó a su destino, pedidos sin líneas o una migración que bloquea la tabla de usuarios durante once minutos.
17.1 Qué vas a poder hacer al terminar
- Explicar qué garantiza ACID y, sobre todo, qué no garantiza, con ejemplos de una aplicación real.
- Elegir con criterio entre la transacción implícita del
flush(),em.transactional(), el control manual y una transacción por petición HTTP, sabiendo qué riesgo asume cada opción. - Reconocer y evitar el error más frecuente del ORM: usar el
EntityManagerexterno dentro del callback deem.transactional(). - Razonar sobre niveles de aislamiento a partir de la anomalía que quieres evitar, no por copiar una línea de configuración, y conocer los valores por defecto de PostgreSQL y MySQL.
- Implementar bloqueo optimista y bloqueo pesimista, traducir mentalmente cada uno al SQL que genera y decidir cuál corresponde a cada caso de uso.
- Construir una cola de trabajos en la propia base de datos con
SKIP LOCKEDsin que dos trabajadores procesen el mismo mensaje. - Sustituir
updateSchemapor migraciones versionadas, escribir undownque funcione y aplicar el patrón expand/contract para desplegar sin tiempo de inactividad. - Ejecutar migraciones en CI/CD con bloqueo, y saber qué hacer cuando una falla a medias.
- Poblar entornos con
SeederyFactory, y anonimizar datos de producción para entornos inferiores. - Aplicar filtros globales para borrado lógico y multi-inquilino, y explicar por qué la seguridad nunca debe descansar solo en un filtro.
- Implementar borrado lógico completo, incluida la interacción con restricciones únicas y con el derecho de supresión de datos personales.
- Usar eventos y suscriptores para auditoría sin esconder lógica de negocio en ellos.
- Diagnosticar y corregir los cuatro problemas de rendimiento clásicos del ORM: memoria creciente, pool agotado, arranque lento y escrituras masivas una a una.
- Comparar las tres estrategias de multi-tenancy y elegir la que corresponde al tamaño de tu producto.
Hay un hilo conductor detrás de las siete secciones grandes: toda escritura ocurre en un contexto. Ese contexto tiene un principio y un final (la transacción), un ámbito de visibilidad (el nivel de aislamiento), una identidad (el Identity Map), unas reglas transversales que se aplican sin que las escribas (los filtros y los eventos) y un coste en recursos compartidos (conexiones y memoria). Cuando algo se comporta de forma inexplicable en un ORM, la pregunta correcta casi siempre es: ¿en qué contexto se está ejecutando esto y quién lo abrió?
17.2 Transacciones: fundamentos
Una transacción es una secuencia de operaciones que la base de datos trata como una unidad indivisible: o se aplican todas o no se aplica ninguna. No es una característica del ORM, es una característica del motor de base de datos; el ORM únicamente decide dónde empieza y dónde acaba.
17.2.1 ACID con ejemplos concretos
| Propiedad | Qué significa | Ejemplo real |
|---|---|---|
| Atomicidad | Todas las operaciones se confirman juntas o se descartan juntas. No existe un estado «a medias» visible ni permanente. | Un pedido con tres líneas: si la tercera línea falla por falta de stock, tampoco debe quedar la cabecera del pedido ni las dos primeras líneas. |
| Consistencia | Al terminar, se cumplen todas las restricciones declaradas en la base de datos: claves foráneas, unicidad, CHECK, NOT NULL. | Una línea de pedido nunca puede apuntar a un pedido inexistente porque la clave foránea se comprueba antes del COMMIT. |
| Islamiento | Las transacciones concurrentes no ven los estados intermedios de las demás. El grado exacto depende del nivel de aislamiento. | Mientras se está calculando una factura, otro usuario no ve las líneas ya insertadas y la cabecera todavía sin insertar. |
| Durabilidad | Una vez confirmada, la transacción sobrevive a un corte de corriente: está escrita en el registro de transacciones en disco. | Si el contenedor de la base de datos muere justo después del COMMIT, el pedido sigue ahí al reiniciar. |
- No garantiza que tu lógica sea correcta. Si calculas mal el total, lo calculas mal de forma atómica y duradera.
- No garantiza aislamiento total salvo en
SERIALIZABLE. En el nivel por defecto de PostgreSQL (READ COMMITTED) dos transacciones pueden leer el mismo saldo y pisarse la escritura: eso es la actualización perdida de la sección 17.5. - No abarca lo que está fuera de la base de datos. Si dentro de la transacción envías un correo, publicas en una cola o cobras con una pasarela de pago, un
ROLLBACKposterior no deshace el correo, ni el mensaje, ni el cobro. Para eso existen los patrones outbox e idempotencia (capítulo 11). - No es gratis. Una transacción abierta retiene una conexión, mantiene bloqueos y, en PostgreSQL, impide que el recolector de versiones limpie filas antiguas.
17.2.2 Qué ocurre si no usas transacciones
El ejemplo canónico es la transferencia entre dos cuentas. Sin transacción, cada instrucción se confirma por separado (en autocommit) y basta con que el proceso muera entre las dos para que el dinero desaparezca.
async transferir(origenId: string, destinoId: string, importe: number) {
const origen = await this.em.findOneOrFail(Cuenta, origenId);
origen.saldo -= importe;
await this.em.flush(); // COMMIT nº 1: el dinero ya salió
// Si el proceso muere aquí, o el destino no existe,
// o la red se corta: el importe se ha evaporado.
const destino = await this.em.findOneOrFail(Cuenta, destinoId);
destino.saldo += importe;
await this.em.flush(); // COMMIT nº 2 (puede no llegar nunca)
}async transferir(origenId: string, destinoId: string, importe: number) {
// Un único flush dentro de una única transacción:
// ambos UPDATE viajan juntos o no viajan.
await this.em.transactional(async (em) => {
const origen = await em.findOneOrFail(Cuenta, origenId);
const destino = await em.findOneOrFail(Cuenta, destinoId);
if (origen.saldo < importe) {
throw new SaldoInsuficienteError(origenId); // provoca ROLLBACK
}
origen.saldo -= importe;
destino.saldo += importe;
});
}COMMIT) el ticket se emite y el inventario se ajusta. Sin ticket, cada producto sería una venta independiente: si te faltara dinero al final, habría que ir deshaciendo ventas una a una, y alguien podría haberse llevado ya la mercancía.El segundo ejemplo, menos vistoso pero mucho más frecuente, es la creación de un agregado con hijos: un pedido con sus líneas. Sin transacción puedes acabar con cabeceras sin líneas (pedidos fantasma que aparecen en los informes con importe cero) o con líneas huérfanas si la clave foránea es NULL-able. El resultado no es un error visible, es un dato sucio que alguien descubrirá tres meses después cuadrando contabilidad.
BEGIN;
INSERT INTO "pedido" ("cliente_id", "total", "creado_en")
VALUES (7, 44.80, now()) RETURNING "id"; -- id = 101
INSERT INTO "pedido_linea" ("pedido_id", "producto_id", "cantidad", "precio")
VALUES (101, 3, 2, 19.90), (101, 8, 1, 5.00); -- inserción por lotes
UPDATE "producto" SET "stock" = "stock" - 2 WHERE "id" = 3;
COMMIT;17.3 Transacciones en MikroORM
17.3.1 La transacción implícita del flush()
MikroORM no escribe nada en la base de datos hasta que llamas a em.flush(). En ese momento el Unit of Work calcula los change sets (qué insertar, qué actualizar, qué borrar), los ordena por dependencias y los ejecuta dentro de una transacción propia si no hay ninguna activa. Es decir: un flush() ya es atómico por sí mismo.
em.persist(a); em.persist(b); entidadC.nombre = 'x'; ← nada viaja a la BD todavía
│
▼ em.flush()
┌─────────────────────────────────────────────────────┐
│ 1. beforeFlush │ (puedes aún modificar entidades)
│ 2. computeChangeSets() ← diff contra la instantánea │
│ 3. onFlush ← change sets ya calculados │
├─────────────────────────────────────────────────────┤
│ 4. BEGIN (solo si no había transacción) │
│ 5. INSERT / UPDATE / DELETE ordenados por topología │
│ 6. COMMIT ó ROLLBACK si algo lanza │
├─────────────────────────────────────────────────────┤
│ 7. afterFlush │
└─────────────────────────────────────────────────────┘
▼
Identity Map intacto · instantáneas actualizadas al nuevo estado
La consecuencia práctica es importante: lo que determina la unidad atómica no son tus llamadas a persist, es dónde pones el flush. Dos flush() seguidos son dos transacciones separadas, con todo lo que eso implica.
flush() no son una transacción Es el error de la sección 17.2.2 disfrazado. Si un caso de uso necesita atomicidad, o acumula todos los cambios y hace un único flush(), o los envuelve explícitamente en em.transactional(). No hay tercera opción.17.3.2 em.transactional(): el EM forkeado
em.transactional(callback) abre una transacción, crea un fork del EntityManager (un contexto nuevo con su propio Identity Map y su propio Unit of Work) y lo pasa como argumento al callback. Al terminar hace flush() y COMMIT; si el callback lanza, hace ROLLBACK y propaga la excepción. Devuelve lo que devuelva el callback.
import { EntityManager } from '@mikro-orm/postgresql';
async crearPedido(dto: CrearPedidoDto): Promise<string> {
// El valor devuelto por el callback es el valor devuelto por transactional.
const id = await this.em.transactional(async (em) => {
const cliente = await em.findOneOrFail(Cliente, dto.clienteId);
const pedido = em.create(Pedido, { cliente, estado: 'PENDIENTE' });
for (const linea of dto.lineas) {
const producto = await em.findOneOrFail(Producto, linea.productoId);
// Cualquier excepción provoca el ROLLBACK automático de todo el bloque.
if (producto.stock < linea.cantidad) throw new StockInsuficienteError(producto.sku);
producto.stock -= linea.cantidad;
em.create(PedidoLinea, { pedido, producto, cantidad: linea.cantidad, precio: producto.precio });
}
// No hace falta llamar a em.flush(): transactional lo hace al final.
return pedido.id;
});
return id;
}El error clásico es ignorar el parámetro y seguir usando el em del servicio. Las operaciones lanzadas contra el EM externo no pertenecen a la transacción: viven en otro contexto, con otro Unit of Work y, dependiendo del driver y del pool, con otra conexión. El resultado es un rollback que no deshace la mitad del trabajo o, peor, un bloqueo mutuo entre dos conexiones del mismo proceso.
async emitir(id: string) {
await this.em.transactional(async (em) => {
const f = await em.findOneOrFail(Factura, id);
f.estado = 'EMITIDA';
// this.em NO es el em de la transacción:
// este flush se confirma por separado y
// sobrevive al ROLLBACK de la transacción.
const contador = await this.em.findOneOrFail(Contador, 'facturas');
contador.siguiente++;
await this.em.flush();
throw new Error('algo falla después');
});
// Resultado: la factura NO se emite,
// pero el contador YA se ha incrementado. Hueco en la numeración.
}async emitir(id: string) {
await this.em.transactional(async (em) => {
const f = await em.findOneOrFail(Factura, id);
f.estado = 'EMITIDA';
// Todo dentro del MISMO em forkeado:
// un único COMMIT o un único ROLLBACK.
const contador = await em.findOneOrFail(Contador, 'facturas');
contador.siguiente++;
throw new Error('algo falla después');
});
// Resultado: nada cambia. Ni factura ni contador.
}Sombrear el nombre (async (em) => ... dentro de una clase cuyo campo es this.em) ayuda, pero no impide escribir this.em por costumbre. Tres medidas eficaces:
- Extraer el cuerpo de la transacción a un método privado que reciba el
emcomo parámetro explícito: dentro de ese método no existethis.emque confundir. - En repositorios y servicios de dominio, aceptar siempre un
em?: EntityManageropcional y usarem ?? this.em. - Activar en desarrollo el registro de SQL con el contexto de transacción y revisar que no aparezcan dos
BEGINdonde esperabas uno.
17.3.3 Transacciones anidadas y savepoints
Si llamas a em.transactional() con un EntityManager que ya está dentro de una transacción, MikroORM no abre una segunda transacción real (la mayoría de motores SQL no lo permiten): crea un savepoint. Un fallo en el bloque interno deshace solo hasta el savepoint; la transacción externa sigue viva y puede confirmar el resto.
BEGIN;
UPDATE "pedido" SET "estado" = 'PAGADO' WHERE "id" = 101;
SAVEPOINT "trx1"; -- entra el bloque interno
INSERT INTO "notificacion" ("pedido_id", "canal") VALUES (101, 'EMAIL');
ROLLBACK TO SAVEPOINT "trx1"; -- el bloque interno falló
-- la notificación se descarta, el pedido sigue pagado
COMMIT;Esto habilita un patrón útil: operaciones opcionales dentro de un caso de uso obligatorio. El pago debe confirmarse; el registro de la notificación es deseable pero no crítico, así que se envuelve en su propio bloque con su propio try/catch. Úsalo con moderación: si abusas de los savepoints acabas con transacciones largas y con una semántica difícil de razonar en las revisiones de código.
transactional
El segundo argumento acepta, entre otras, isolationLevel (sección 17.4), readOnly, ctx (para reutilizar un contexto de transacción existente) y clear (si el fork arranca con el Identity Map vacío). Existe además una opción para ignorar el anidamiento y unirse a la transacción externa en lugar de crear un savepoint. Los nombres exactos y el conjunto disponible cambian entre versiones menores: consulta la página de transacciones de la documentación de tu versión de MikroORM 6 antes de fijarlos en un proyecto.
17.3.4 Control manual: begin, commit, rollback
const em = this.em.fork(); // contexto propio: nunca uses el EM global
await em.begin();
try {
await em.persistAndFlush(cabecera);
await this.procesarLotes(em);
await em.commit(); // commit hace flush implícito de lo pendiente
} catch (e) {
await em.rollback(); // obligatorio: si no, la conexión queda retenida
throw e;
}Cuándo hace falta. Muy pocas veces. El control manual solo se justifica cuando el principio y el final de la transacción no caben en un mismo ámbito léxico: una máquina de estados dirigida por eventos, un adaptador que integra una librería de terceros que exige la conexión abierta, o una transacción por petición implementada en un middleware. En cualquier otro caso, em.transactional() es superior porque el rollback no se puede olvidar.
begin() sin su commit() ni su rollback() en todas las rutas de salida (incluidos los return tempranos) deja una transacción abierta y una conexión fuera del pool. Con suficiente tráfico, el pool se agota y la aplicación deja de responder con un timeout acquiring a connection que no menciona en ningún momento la causa real.17.3.5 @Transactional() y @CreateRequestContext()
Son dos decoradores que resuelven dos problemas distintos y que se confunden constantemente.
@CreateRequestContext() | @Transactional() | |
|---|---|---|
| Qué crea | Un contexto de EntityManager (un fork con su Identity Map) para la duración del método. | Un contexto y además una transacción de base de datos alrededor del método. |
| Problema que resuelve | «Using global EntityManager instance methods for context specific actions is disallowed»: código que corre fuera de una petición HTTP y por tanto sin contexto. | Atomicidad declarativa de un caso de uso, sin anidar un callback. |
| Dónde se usa | Tareas programadas (cron), consumidores de colas, escuchadores de eventos, comandos de CLI, scripts de mantenimiento, procesos de arranque. | Métodos de servicios de aplicación que ya tienen contexto (dentro de una petición HTTP) y necesitan atomicidad. |
| Confirma al terminar | No. Tú decides cuándo llamar a flush(). | Sí: flush() y COMMIT, o ROLLBACK si lanza. |
import { CreateRequestContext, MikroORM } from '@mikro-orm/core';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class InformesCron {
// El decorador NECESITA encontrar la instancia de MikroORM (o un EntityManager)
// en una propiedad de la clase; por eso se inyecta aunque no se use directamente.
constructor(private readonly orm: MikroORM, private readonly em: EntityManager) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
@CreateRequestContext()
async generarInformeDiario(): Promise<void> {
// Aquí this.em resuelve al fork creado por el decorador:
// Identity Map propio, aislado del resto de la aplicación.
const ventas = await this.em.find(Venta, { fecha: { $gte: ayer() } });
this.em.create(InformeDiario, { fecha: ayer(), total: sumar(ventas) });
await this.em.flush();
}
}- En MikroORM 5 el decorador se llamaba
@UseRequestContext(). En la versión 6 el nombre es@CreateRequestContext(), y existe además una variante que reutiliza el contexto si ya hay uno activo en lugar de crear otro (@EnsureRequestContext()). Usa la primera para código que siempre corre fuera de una petición y la segunda para métodos que pueden invocarse desde ambos sitios. - El decorador crea el contexto al invocar el método. Una referencia al
emcapturada antes (por ejemplo en el constructor y guardada en otra variable) no se beneficia de él. - Los decoradores no funcionan sobre funciones sueltas ni sobre arrow functions asignadas a propiedades. En un script suelto usa
RequestContext.create(orm.em, async () => { ... })oorm.em.fork()explícitamente.
import { Transactional } from '@mikro-orm/core';
@Injectable()
export class CobrosService {
constructor(private readonly em: EntityManager) {}
// Equivale a envolver todo el cuerpo en em.transactional(...).
// El em inyectado resuelve al contexto transaccional durante la ejecución.
@Transactional()
async aplicarCobro(pedidoId: string, importe: number): Promise<void> {
const pedido = await this.em.findOneOrFail(Pedido, pedidoId);
pedido.pagado += importe;
if (pedido.pagado >= pedido.total) pedido.estado = 'PAGADO';
this.em.create(MovimientoCaja, { pedido, importe });
}
}@Transactional() El decorador de transacción existe en MikroORM 6 y acepta opciones análogas a las de em.transactional() (nivel de aislamiento, contexto, solo lectura). Como su superficie ha crecido entre versiones menores, comprueba en la documentación de tu versión qué opciones admite y de qué propiedad de la clase obtiene el EntityManager antes de apoyarte en él en producción. Si dudas, em.transactional() explícito es siempre equivalente y no depende de metadatos de decoradores.17.3.6 Transacción por petición HTTP
La idea es tentadora: abrir una transacción al principio de cada petición que modifique datos y confirmarla si el controlador responde sin error. Se implementa con un interceptor de Nest que envuelve la ejecución del manejador dentro de em.transactional() y crea el contexto de petición con el EM transaccional, para que los servicios que inyectan EntityManager reciban ese mismo fork.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { EntityManager, RequestContext } from '@mikro-orm/core';
import { Observable, from, lastValueFrom } from 'rxjs';
const METODOS_SEGUROS = new Set(['GET', 'HEAD', 'OPTIONS']);
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private readonly em: EntityManager) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = ctx.switchToHttp().getRequest<{ method: string }>();
// Las lecturas no necesitan transacción explícita: evita bloqueos innecesarios.
if (METODOS_SEGUROS.has(req.method)) return next.handle();
return from(
// Sin RequestContext.create, los servicios seguirían resolviendo al EM de
// la petición en lugar de al forkeado de la transacción.
this.em.transactional((txEm) =>
RequestContext.create(txEm, () => lastValueFrom(next.handle())),
),
);
}
}Ventajas
- Atomicidad del caso de uso completo sin que cada servicio tenga que acordarse de ella.
- Un error no controlado en la capa que sea (validación tardía, serialización, un guard posterior) deja la base de datos limpia.
- Elimina la clase entera de errores «se guardó la mitad».
Riesgos
- Transacciones largas. La transacción dura tanto como la petición, incluido el tiempo de serialización y cualquier espera.
- Llamadas externas dentro de la transacción. Un servicio que llama a una API de terceros con 3 s de latencia mantiene bloqueos durante 3 s.
- Bloqueos y agotamiento del pool bajo carga: cada petición en vuelo retiene una conexión.
- Falsa sensación de atomicidad: los efectos externos (correos, mensajes en cola) no se deshacen.
- Peticiones que solo leen y que por error entran en la rama transaccional pagan el coste sin beneficio.
Transacción por caso de uso, no por petición. Coloca la frontera transaccional en el servicio de aplicación (el método que representa la intención del usuario: crear pedido, transferir, cerrar caja), con em.transactional() o @Transactional(). Es explícita, visible en la revisión de código, y deja fuera todo lo que no debe estar dentro: validaciones previas, llamadas HTTP a terceros, generación de PDF, envío de notificaciones.
El interceptor global es defendible en dos escenarios: una aplicación CRUD homogénea donde casi todos los endpoints de escritura son un único caso de uso corto, o como red de seguridad temporal en una base de código heredada mientras se introducen fronteras explícitas. Si lo adoptas, exige tres cosas: excluir los métodos seguros, prohibir por convención (y por revisión) las llamadas de red dentro de servicios transaccionales, y vigilar la métrica de duración de transacción.
17.4 Niveles de aislamiento
El aislamiento perfecto (que todo ocurra como si las transacciones se ejecutaran una detrás de otra) es caro. El estándar SQL define cuatro niveles que intercambian corrección por concurrencia, y cada nivel se define por las anomalías que permite.
17.4.1 Las cuatro anomalías, con línea temporal
Lectura sucia (dirty read): leer un dato que otra transacción ha escrito y todavía no ha confirmado.
T1 (transfiere) T2 (informe de saldos)
──────────────────────────────────────────────────────────────────
BEGIN
UPDATE cuenta SET saldo = 0
WHERE id = 'A' (saldo real: 1000)
BEGIN
SELECT saldo FROM cuenta WHERE id='A'
→ 0 ¡dato NO confirmado!
-- decide bloquear la cuenta por saldo cero
ROLLBACK (el saldo vuelve a 1000)
COMMIT
──────────────────────────────────────────────────────────────────
Resultado: T2 tomó una decisión sobre un dato que nunca existió.
Lectura no repetible (non-repeatable read): la misma fila leída dos veces en la misma transacción devuelve valores distintos, porque otra transacción la modificó y confirmó en medio.
T1 (valida y cobra) T2 (el cliente cambia el descuento)
──────────────────────────────────────────────────────────────────
BEGIN
SELECT descuento FROM cliente → 10
BEGIN
UPDATE cliente SET descuento = 50
COMMIT
SELECT descuento FROM cliente → 50 ¡cambió dentro de T1!
-- validó con 10 y cobra con 50
COMMIT
Lectura fantasma (phantom read): la misma consulta con la misma condición devuelve un conjunto de filas distinto porque otra transacción insertó o borró filas que cumplen el criterio.
T1 (cierre de caja) T2 (nueva venta)
──────────────────────────────────────────────────────────────────
BEGIN
SELECT count(*) FROM venta
WHERE dia = hoy() → 120
BEGIN
INSERT INTO venta (dia,...) VALUES (hoy(),...)
COMMIT
SELECT sum(importe) FROM venta
WHERE dia = hoy() → suma de 121 filas ¡fantasma!
COMMIT
──────────────────────────────────────────────────────────────────
Resultado: el informe dice 120 ventas y un importe que corresponde a 121.
Anomalía de serialización (incluye el write skew): cada transacción es correcta por separado y ninguna lee datos sucios, pero el resultado conjunto viola una invariante que ninguna de las dos pudo ver.
Invariante: siempre debe quedar al menos un médico de guardia.
Estado inicial: guardia = { Ana, Luis }
T1 (Ana pide el turno libre) T2 (Luis pide el turno libre)
──────────────────────────────────────────────────────────────────
BEGIN BEGIN
SELECT count(*) FROM guardia → 2 SELECT count(*) FROM guardia → 2
-- 2 > 1, puedo salir -- 2 > 1, puedo salir
DELETE FROM guardia WHERE m='Ana' DELETE FROM guardia WHERE m='Luis'
COMMIT COMMIT
──────────────────────────────────────────────────────────────────
Estado final: guardia = { } La invariante se rompió sin ninguna lectura sucia.
Solo SERIALIZABLE (o un bloqueo explícito) evita esto.
17.4.2 Qué evita cada nivel
| Nivel | Lectura sucia | Lectura no repetible | Fantasma | Anomalía de serialización | Coste |
|---|---|---|---|---|---|
READ UNCOMMITTED | Permitida | Permitida | Permitida | Permitida | Mínimo. Casi nunca justificable. |
READ COMMITTED | Evitada | Permitida | Permitida | Permitida | Bajo. Es el punto de equilibrio habitual. |
REPEATABLE READ | Evitada | Evitada | Depende del motor | Permitida | Medio: más versiones retenidas, posibles errores de serialización. |
SERIALIZABLE | Evitada | Evitada | Evitada | Evitada | Alto: bloqueos o abortos frecuentes; obliga a reintentar. |
- PostgreSQL. Por defecto
READ COMMITTED. No implementa realmenteREAD UNCOMMITTED: lo trata comoREAD COMMITTED. SuREPEATABLE READes aislamiento por instantánea, así que tampoco permite fantasmas, pero puede abortar la transacción con el error40001(«could not serialize access due to concurrent update»). SuSERIALIZABLEusa comprobación de serialización de instantáneas: no bloquea de más, pero aborta con40001y hay que reintentar. - MySQL/MariaDB con InnoDB. Por defecto
REPEATABLE READ, implementado con lecturas consistentes por instantánea más gap locks en las lecturas con bloqueo. Cuidado: una lectura conFOR UPDATEdentro deREPEATABLE READve la última versión confirmada, no la instantánea, lo que sorprende a quien viene de PostgreSQL. - SQLite. Serializa las escrituras a nivel de fichero o de base de datos; el concepto de nivel de aislamiento apenas aplica.
Conclusión práctica: no escribas código que dependa de sutilezas del nivel de aislamiento. Si una invariante importa, protégela con una restricción de la base de datos o con un bloqueo explícito.
17.4.3 Cómo fijar el nivel en MikroORM
import { IsolationLevel } from '@mikro-orm/core';
// Por transacción: lo habitual y lo recomendable.
await this.em.transactional(
async (em) => {
const aforo = await em.count(Reserva, { sesion: sesionId });
if (aforo >= capacidad) throw new AforoCompletoError();
em.create(Reserva, { sesion: sesionId, usuario: usuarioId });
},
{ isolationLevel: IsolationLevel.SERIALIZABLE },
);
// Con control manual, el nivel se pasa al begin.
await em.begin({ isolationLevel: IsolationLevel.REPEATABLE_READ });BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS "count" FROM "reserva" WHERE "sesion_id" = 42;
INSERT INTO "reserva" ("sesion_id", "usuario_id") VALUES (42, 9);
COMMIT;
-- Si otra transacción concurrente hizo lo mismo:
-- ERROR: could not serialize access due to read/write dependencies among transactions (40001)
-- → la aplicación DEBE reintentar la operación completa.SERIALIZABLE (y con REPEATABLE READ en PostgreSQL) la base de datos puede abortar una transacción perfectamente correcta porque otra concurrente le ganó. Si no envuelves la operación en un reintento, has cambiado un error de datos silencioso por un error 500 aleatorio bajo carga. El nivel alto solo es una solución completa acompañado de reintentos idempotentes.Cuándo subir el nivel: invariantes que abarcan varias filas y que no puedes expresar con una restricción (aforo, saldo mínimo agregado, «al menos un administrador», asignación de turnos), informes que deben ver una instantánea coherente de varias tablas, y procesos de cierre contable. Cuándo no: como solución genérica al problema de la actualización perdida sobre una sola fila; ahí el bloqueo optimista o pesimista es más barato y más preciso.
17.5 Concurrencia: bloqueo optimista y pesimista
17.5.1 El problema de la actualización perdida
Es el problema de concurrencia más común y el que más dinero cuesta. Ocurre siempre que el código sigue el patrón leer → decidir en memoria → escribir, porque entre la lectura y la escritura hay una ventana en la que otro proceso puede hacer lo mismo.
Stock inicial del producto 3: 10 unidades.
Dos peticiones simultáneas compran 8 unidades cada una.
Petición A (nodo 1) Petición B (nodo 2)
─────────────────────────────────────────────────────────────────────────
BEGIN BEGIN
SELECT stock FROM producto WHERE id=3
→ 10
SELECT stock FROM producto WHERE id=3
→ 10 (¡lee lo mismo!)
-- 10 >= 8, venta permitida
-- 10 >= 8, venta permitida
UPDATE producto SET stock = 2 WHERE id = 3 (B espera el bloqueo de fila)
COMMIT
UPDATE producto SET stock = 2 WHERE id = 3
COMMIT
─────────────────────────────────────────────────────────────────────────
Stock final: 2. Vendidas: 16 unidades de 10 existentes.
La escritura de A se ha PERDIDO: B la sobrescribió con un valor calculado
a partir de un dato ya obsoleto.
Hay tres formas de cerrar esa ventana, en orden creciente de coste:
- Escritura atómica relativa. Que la base de datos haga la aritmética:
UPDATE producto SET stock = stock - 8 WHERE id = 3 AND stock >= 8. Si afecta a cero filas, no había stock. Es la opción más eficiente y la que más se olvida. - Bloqueo optimista. Permitir la carrera y detectarla al escribir mediante una columna de versión.
- Bloqueo pesimista. Impedir la carrera bloqueando la fila en el momento de leerla.
// Leer, decidir en memoria y escribir:
// dos peticiones concurrentes venden el mismo stock.
async reservar(id: string, cantidad: number) {
const p = await this.em.findOneOrFail(Producto, id);
if (p.stock < cantidad) throw new StockInsuficienteError(id);
p.stock -= cantidad; // valor calculado con un dato posiblemente obsoleto
await this.em.flush();
}// La condición y la resta las evalúa la base de datos
// en la misma instrucción: no hay ventana de carrera.
async reservar(id: string, cantidad: number) {
const filas = await this.em.nativeUpdate(
Producto,
{ id, stock: { $gte: cantidad } },
{ stock: this.em.raw(`stock - ${'?'}`, [cantidad]) },
);
if (filas === 0) throw new StockInsuficienteError(id);
}nativeUpdate pasa por encima del Unit of Work: no dispara hooks de entidad, no actualiza las entidades que ya estuvieran cargadas en el Identity Map y no incrementa la columna de versión. Si en la misma petición vuelves a leer el producto desde el mismo contexto, obtendrás el valor antiguo salvo que hagas em.refresh(p) o em.clear(). Úsalo para contadores y decrementos, no como estilo general de escritura. Comprueba en la documentación de tu versión la forma exacta de expresar una expresión SQL cruda en el objeto de datos (raw() como función importada o como método del EM).17.5.2 Bloqueo optimista
La estrategia optimista asume que los conflictos son raros: no bloquea nada, pero añade a la entidad una columna de versión que se incrementa en cada actualización. La cláusula WHERE del UPDATE incluye la versión leída; si otra transacción ya la cambió, el UPDATE afecta a cero filas y el ORM lanza una excepción.
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
export class Producto {
@PrimaryKey() id!: string;
@Property() nombre!: string;
@Property() stock!: number;
// Versión numérica: la opción recomendada. Empieza en 1 y la gestiona el ORM.
@Property({ version: true }) version!: number;
}
@Entity()
export class Documento {
@PrimaryKey() id!: string;
@Property({ type: 'text' }) contenido!: string;
// Versión por marca de tiempo: legible en informes, pero con menos resolución;
// dos actualizaciones en el mismo milisegundo pueden no detectarse como conflicto.
@Property({ version: true, length: 6 }) actualizadoEn!: Date;
}-- Lectura: el ORM guarda version = 7 en la instantánea del Unit of Work
SELECT "id", "nombre", "stock", "version" FROM "producto" WHERE "id" = 'p-3';
-- Escritura: la versión leída viaja en el WHERE y se incrementa en el SET
UPDATE "producto"
SET "stock" = 2, "version" = 8
WHERE "id" = 'p-3' AND "version" = 7;
-- Si otra transacción confirmó antes, la fila ya tiene version = 8:
-- filas afectadas = 0 → MikroORM lanza OptimisticLockErrorEl mecanismo cubre además un caso que el bloqueo pesimista no puede cubrir: la edición concurrente entre peticiones distintas. Si un formulario web se carga con version = 7, el usuario tarda cinco minutos en enviarlo y otro usuario ha guardado en medio, la versión enviada por el cliente permite detectar el conflicto aunque no exista ninguna transacción abierta durante esos cinco minutos.
import { LockMode, OptimisticLockError } from '@mikro-orm/core';
import { ConflictException } from '@nestjs/common';
@Patch(':id')
// dto.version es la versión que el cliente leyó al cargar el formulario.
async actualizar(@Param('id') id: string, @Body() dto: ActualizarProductoDto): Promise<ProductoDto> {
try {
return await this.em.transactional(async (em) => {
// Comprueba la versión ANTES de aplicar cambios: falla rápido.
const p = await em.findOneOrFail(Producto, id, {
lockMode: LockMode.OPTIMISTIC, lockVersion: dto.version,
});
em.assign(p, { nombre: dto.nombre, precio: dto.precio });
return aDto(p);
});
} catch (e) {
if (e instanceof OptimisticLockError) {
// 409: el recurso cambió desde que el cliente lo leyó.
throw new ConflictException({ code: 'VERSION_CONFLICT',
message: 'El producto ha sido modificado por otro usuario. Recarga y vuelve a intentarlo.' });
}
throw e;
}
}- Conflictos de edición humana (dos personas editando la misma ficha): responde
409con el estado actual del recurso y deja que la interfaz muestre las diferencias. Reintentar en el servidor sería perder el cambio de alguien en silencio, que es exactamente lo que queríamos evitar. - Conflictos entre procesos automáticos (dos consumidores de una cola incrementando un contador): reintenta en el servidor con retroceso exponencial y jitter, releyendo la entidad en cada intento. Tres intentos suelen bastar; si no bastan, el diseño necesita una escritura atómica o una cola por clave.
- En Angular, un interceptor puede traducir el
409concode: 'VERSION_CONFLICT'en un diálogo de «recargar o sobrescribir» reutilizable.
Cuando no puedes o no quieres añadir una columna de versión (por ejemplo en una tabla heredada), MikroORM ofrece concurrencyCheck a nivel de propiedad: las propiedades marcadas se incluyen en el WHERE del UPDATE con el valor que tenían al cargar la entidad. Es un bloqueo optimista basado en el contenido en lugar de en un contador.
@Entity()
export class Tarifa {
@PrimaryKey()
id!: string;
// Sin columna de versión: el propio importe actúa de testigo.
@Property({ concurrencyCheck: true })
importe!: number;
@Property()
descripcion!: string;
}
// UPDATE "tarifa" SET "importe" = 120 WHERE "id" = 't1' AND "importe" = 100;
// 0 filas afectadas → OptimisticLockError17.5.3 Bloqueo pesimista
La estrategia pesimista asume que el conflicto es probable y lo evita bloqueando la fila en la base de datos en el momento de leerla. Solo tiene sentido dentro de una transacción: el bloqueo se libera con el COMMIT o el ROLLBACK.
LockMode | SQL (PostgreSQL) | Semántica |
|---|---|---|
PESSIMISTIC_READ | SELECT ... FOR SHARE | Otros pueden leer, nadie puede modificar la fila hasta que termines. |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE | Bloqueo exclusivo. Los demás FOR UPDATE sobre esa fila esperan. |
PESSIMISTIC_PARTIAL_WRITE | SELECT ... FOR UPDATE SKIP LOCKED | No espera: omite las filas ya bloqueadas por otros. La base de una cola. |
PESSIMISTIC_WRITE_OR_FAIL | SELECT ... FOR UPDATE NOWAIT | No espera: falla inmediatamente si la fila está bloqueada. |
import { LockMode } from '@mikro-orm/core';
async transferir(origenId: string, destinoId: string, importe: number): Promise<void> {
if (origenId === destinoId) throw new BadRequestException('Cuentas iguales');
await this.em.transactional(async (em) => {
// CLAVE: bloquear SIEMPRE en el mismo orden (por id) en todo el sistema.
// Si un caso de uso bloquea A→B y otro B→A, aparecen deadlocks.
const [primero, segundo] = [origenId, destinoId].sort();
const c1 = await em.findOneOrFail(Cuenta, primero, { lockMode: LockMode.PESSIMISTIC_WRITE });
const c2 = await em.findOneOrFail(Cuenta, segundo, { lockMode: LockMode.PESSIMISTIC_WRITE });
const [origen, destino] = c1.id === origenId ? [c1, c2] : [c2, c1];
if (origen.saldo < importe) throw new SaldoInsuficienteError(origenId);
origen.saldo -= importe;
destino.saldo += importe;
em.create(Movimiento, { origen, destino, importe });
});
}
// Si la entidad ya está cargada: await em.lock(cuenta, LockMode.PESSIMISTIC_WRITE);BEGIN;
SELECT "id", "saldo" FROM "cuenta" WHERE "id" = 'A' FOR UPDATE; -- bloquea la fila A
SELECT "id", "saldo" FROM "cuenta" WHERE "id" = 'B' FOR UPDATE; -- bloquea la fila B
UPDATE "cuenta" SET "saldo" = 900 WHERE "id" = 'A';
UPDATE "cuenta" SET "saldo" = 1100 WHERE "id" = 'B';
INSERT INTO "movimiento" ("origen_id", "destino_id", "importe") VALUES ('A', 'B', 100);
COMMIT; -- aquí se liberan los dos bloqueos17.5.4 Una cola de trabajos en la base de datos con SKIP LOCKED
SKIP LOCKED resuelve el problema de reparto entre trabajadores sin coordinación externa: cada trabajador toma las primeras filas pendientes que no estén bloqueadas por otro. Es la forma correcta de implementar una cola sencilla cuando no quieres añadir Redis a la arquitectura.
tabla trabajo: id | estado | intentos | payload
1..4 | PENDIENTE | 0 | ...
Trabajador 1 Trabajador 2
────────────────────────────────────────────────────────────────
BEGIN BEGIN
SELECT ... LIMIT 2
FOR UPDATE SKIP LOCKED
→ filas 1, 2 (bloqueadas)
SELECT ... LIMIT 2
FOR UPDATE SKIP LOCKED
→ filas 3, 4 (omite 1 y 2)
UPDATE estado='EN_CURSO' (1,2) UPDATE estado='EN_CURSO' (3,4)
COMMIT COMMIT
────────────────────────────────────────────────────────────────
Ningún trabajo se procesa dos veces y ningún trabajador espera al otro.
import { LockMode } from '@mikro-orm/core';
async reclamarLote(tamano = 10): Promise<Trabajo[]> {
return this.em.transactional(async (em) => {
const trabajos = await em.find(
Trabajo,
{ estado: 'PENDIENTE', disponibleEn: { $lte: new Date() } },
{ limit: tamano, orderBy: { prioridad: 'desc', creadoEn: 'asc' },
lockMode: LockMode.PESSIMISTIC_PARTIAL_WRITE }, // FOR UPDATE SKIP LOCKED
);
for (const t of trabajos) {
t.estado = 'EN_CURSO';
t.intentos++;
t.reclamadoEn = new Date();
}
return trabajos; // flush y COMMIT los hace transactional
});
}BEGIN;
SELECT "t0".* FROM "trabajo" AS "t0"
WHERE "t0"."estado" = 'PENDIENTE' AND "t0"."disponible_en" <= now()
ORDER BY "t0"."prioridad" DESC, "t0"."creado_en" ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
UPDATE "trabajo" SET "estado" = 'EN_CURSO', "intentos" = 1, "reclamado_en" = now()
WHERE "id" IN (1, 2, 3);
COMMIT;UPDATE genera muchas versiones muertas en PostgreSQL: vigila el autovacuum) y la falta de funciones avanzadas (prioridades dinámicas, reintentos programados sofisticados, paneles). Cuando lleguen esos límites, BullMQ (capítulo 11) es el paso siguiente natural.17.5.5 Optimista frente a pesimista: criterios de elección
| Criterio | Bloqueo optimista | Bloqueo pesimista |
|---|---|---|
| Cómo funciona | Detecta el conflicto al escribir (columna de versión en el WHERE). | Lo previene al leer (SELECT ... FOR UPDATE). |
| Coste en la base de datos | Casi nulo: ningún bloqueo adicional. | Bloqueos de fila mientras dure la transacción; otros esperan. |
| Requiere transacción abierta | No para detectar; sí para escribir de forma atómica. | Sí, obligatoriamente. |
| Funciona entre peticiones HTTP | Sí: la versión viaja al cliente y vuelve. | No: nunca mantengas una transacción abierta esperando a un usuario. |
| Comportamiento con mucha contención | Malo: muchos conflictos, muchos reintentos, trabajo desperdiciado. | Bueno: se serializa el acceso, cada uno espera su turno. |
| Riesgo de deadlock | Muy bajo. | Real si el orden de bloqueo no es coherente. |
| Experiencia de usuario | Puede recibir un 409 y perder el trabajo si no se le muestran las diferencias. | Espera (latencia) pero no ve conflictos. |
| Casos de uso típicos | Edición de fichas por humanos, agregados con poca contención, APIs REST con ETag. | Saldos, stock de artículos muy demandados, numeración secuencial, colas, reparto de recursos escasos. |
SET x = x - ? con condición) cuando la operación es un simple incremento o decremento. Las tres técnicas conviven en la misma aplicación sin problema.17.5.6 Deadlocks: cómo se producen y cómo convivir con ellos
Un deadlock es un ciclo de esperas: A espera un recurso que tiene B y B espera un recurso que tiene A. Ninguna avanza, así que el motor elige una víctima y la aborta con un error específico (40P01 en PostgreSQL, 1213 en MySQL).
T1: transferir de A a B T2: transferir de B a A
──────────────────────────────────────────────────────────────────
BEGIN BEGIN
SELECT A FOR UPDATE (tiene A)
SELECT B FOR UPDATE (tiene B)
SELECT B FOR UPDATE ── espera a T2 ──┐
SELECT A FOR UPDATE ── espera a T1 ──┐
│
┌───────────────────────────────────────────────────────────────────┘
└── CICLO: el motor aborta una de las dos (deadlock detected)
Solución: ordenar SIEMPRE los bloqueos por un criterio estable (el id).
Con orden estable, T2 también pediría A antes que B: esperaría, pero no se
formaría el ciclo.
- Orden coherente de bloqueo. Es la medida más eficaz: ordena las claves antes de bloquear, como en el ejemplo de la transferencia. Afecta también al orden en que insertas en varias tablas.
- Transacciones cortas. Menos tiempo con bloqueos tomados es menos probabilidad de ciclo. Saca de la transacción todo lo que no necesita estar dentro.
- Menos filas bloqueadas. Un
UPDATEcon unWHEREpoco selectivo puede bloquear miles de filas y, sin índice adecuado, escalar a bloqueos de rango. - Reintento. Un deadlock no es un error de programación puntual: es un evento esperable. La operación debe poder repetirse.
import { DriverException, OptimisticLockError } from '@mikro-orm/core';
// Códigos SQLSTATE que SIEMPRE merecen reintento: son conflictos, no errores de datos.
const REINTENTABLES = new Set([
'40001', // serialization failure (PostgreSQL: SERIALIZABLE / REPEATABLE READ)
'40P01', // deadlock detected (PostgreSQL)
]);
function esReintentable(e: unknown): boolean {
if (e instanceof OptimisticLockError) return true;
const code = (e as DriverException)?.code ?? (e as { code?: string }).code;
// MySQL usa códigos numéricos: 1213 deadlock, 1205 lock wait timeout.
return REINTENTABLES.has(String(code)) || ['1213', '1205'].includes(String(code));
}
export async function conReintentos<T>(op: () => Promise<T>, intentos = 3): Promise<T> {
let ultimo: unknown;
for (let i = 0; i < intentos; i++) {
try {
return await op();
} catch (e) {
if (!esReintentable(e)) throw e; // un error de negocio no se reintenta
ultimo = e;
const espera = 25 * 2 ** i + Math.random() * 50; // backoff + jitter
await new Promise((r) => setTimeout(r, espera));
}
}
throw ultimo;
}
// Uso: cada intento vuelve a abrir la transacción y a releer los datos.
await conReintentos(() => this.transferencias.transferir(a, b, 100));current transaction is aborted. El reintento tiene que envolver a em.transactional() por fuera, y cada intento debe releer las entidades desde cero (un fork nuevo o un em.clear()), porque el Identity Map del intento anterior contiene datos obsoletos.17.5.7 Idempotencia y claves de idempotencia
Reintentar solo es seguro si repetir la operación produce el mismo resultado que ejecutarla una vez. Y hay que reintentar en muchos sitios: el cliente pulsa dos veces, el móvil pierde la cobertura y repite el POST, el balanceador reenvía, la cola entrega el mensaje dos veces. La solución estándar es que el cliente envíe una clave de idempotencia y que el servidor la guarde con una restricción de unicidad.
@Entity()
@Unique({ properties: ['clave'] })
export class OperacionIdempotente {
@PrimaryKey() id!: string;
@Property() clave!: string; // cabecera Idempotency-Key generada por el cliente
@Property() endpoint!: string;
@Property() huellaPeticion!: string; // detecta claves reutilizadas con otro cuerpo
@Property({ type: 'json', nullable: true }) respuesta?: unknown;
@Property() creadoEn: Date = new Date();
}async cobrar(clave: string, dto: CobroDto): Promise<RespuestaCobro> {
return this.em.transactional(async (em) => {
const huella = sha256(JSON.stringify(dto));
// 1. ¿Ya se procesó esta clave? Devuelve la MISMA respuesta.
const previa = await em.findOne(OperacionIdempotente, { clave });
if (previa) {
if (previa.huellaPeticion !== huella) {
throw new ConflictException('La clave de idempotencia se reutilizó con otro cuerpo');
}
return previa.respuesta as RespuestaCobro;
}
// 2. Reserva la clave DENTRO de la transacción: si dos peticiones
// simultáneas llegan aquí, la restricción única hace fallar a una
// (error 23505) y esa devuelve 409 o reintenta la lectura del paso 1.
const registro = em.create(OperacionIdempotente,
{ clave, endpoint: 'POST /pagos', huellaPeticion: huella });
// 3. Trabajo real, en la misma transacción.
const pago = em.create(Pago, { importe: dto.importe, pedido: dto.pedidoId });
const respuesta: RespuestaCobro = { pagoId: pago.id, estado: 'ACEPTADO' };
registro.respuesta = respuesta;
return respuesta;
});
}Idempotency-Key. Y ese tipo de llamada no debe estar dentro de la transacción: el patrón correcto es registrar la intención de forma atómica (tabla de salida) y ejecutar el efecto externo después, con reintentos idempotentes.17.6 Esquema y migraciones
17.6.1 SchemaGenerator: útil en desarrollo, prohibido en producción
MikroORM sabe deducir el esquema de la base de datos a partir de los metadatos de tus entidades y compararlo con el esquema real. El objeto que hace ese trabajo es el SchemaGenerator, accesible como orm.schema y expuesto en la CLI como los comandos schema:*.
| Método | CLI | Qué hace | Dónde es legítimo |
|---|---|---|---|
ensureDatabase() | schema:create --run (implícito) | Crea la base de datos si no existe. | Arranque de entorno local y de tests. |
createSchema() | schema:create --run | Genera todas las tablas desde cero. | Base de datos vacía en local o en CI. |
updateSchema() | schema:update --run | Calcula la diferencia y la aplica. | Solo desarrollo local, y con reservas. |
dropSchema() | schema:drop --run | Elimina tablas (y opcionalmente la propia base). | Limpieza entre suites de test. |
refreshDatabase() | schema:fresh --run | drop + create en un paso. | Preparación de tests de integración. |
clearDatabase() | — | Vacía el contenido conservando las tablas. | Entre tests: mucho más rápido que recrear. |
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init(configTest); // base de datos de test dedicada
await orm.schema.refreshDatabase(); // esquema limpio desde las entidades
});
beforeEach(async () => {
await orm.schema.clearDatabase(); // vacía datos y conserva estructura: más rápido
orm.em.clear(); // Identity Map limpio entre tests
});
afterAll(() => orm.close(true));updateSchema no se usa nunca en producción
- Ejecuta operaciones destructivas. Si renombras una propiedad de la entidad, el diff no ve un renombrado: ve una columna que sobra y otra que falta. Genera
DROP COLUMN+ADD COLUMNy los datos desaparecen sin previo aviso. - No hay control de versiones. No queda registro de qué se aplicó, ni cuándo, ni por quién; no se puede revisar en un pull request ni revertir con criterio.
- No es reproducible. El SQL depende del estado actual de esa base de datos, así que preproducción y producción pueden divergir silenciosamente.
- Bloquea. Un
ALTER TABLEgenerado automáticamente sobre una tabla de diez millones de filas puede tomar un bloqueo exclusivo durante minutos, con toda la aplicación esperando. - Se ejecuta N veces con N réplicas. Si lo llamas al arrancar la aplicación, cinco pods intentan modificar el esquema a la vez.
Regla: schema:update sirve para ver el SQL que hace falta (--dump) y como punto de partida de una migración. Nunca como mecanismo de despliegue.
17.6.2 Migraciones: configuración y comandos
Una migración es un fichero versionado, revisable y ordenado que describe un cambio de esquema con SQL explícito. El paquete @mikro-orm/migrations (o @mikro-orm/migrations-mongodb) añade el Migrator y sus comandos de CLI.
import { defineConfig } from '@mikro-orm/postgresql';
import { Migrator } from '@mikro-orm/migrations';
import { SeedManager } from '@mikro-orm/seeder';
export default defineConfig({
// ...entidades, conexión
extensions: [Migrator, SeedManager], // en v6 las extensiones se registran así
migrations: {
tableName: 'mikro_orm_migrations', // tabla de control de versiones
path: './dist/migrations', // JS compilado (producción)
pathTs: './src/migrations', // TS (desarrollo)
glob: '!(*.d).{js,ts}',
emit: 'ts',
transactional: true, // cada migración en su transacción
allOrNothing: true, // todas las pendientes en UNA transacción
disableForeignKeys: false, // en PostgreSQL requiere superusuario: déjalo en false
dropTables: false, // sin DROP TABLE generados automáticamente
safe: true, // sin DROP COLUMN generados automáticamente
snapshot: true, // instantánea para calcular diffs incrementales
},
});| Comando | Qué hace | Notas |
|---|---|---|
migration:create | Genera una migración con el diff entre entidades y esquema. | Revísala siempre a mano antes de confirmarla. |
migration:create --blank | Crea una migración vacía. | Para migraciones de datos, índices CONCURRENTLY o SQL que el diff no puede inferir. |
migration:create --initial | Migración inicial que asume el esquema ya existente. | Para adoptar migraciones en un proyecto en marcha. |
migration:up | Aplica las pendientes en orden. | Acepta --to y --only para acotar. |
migration:down | Revierte la última (o hasta la indicada). | Solo si el down existe y está probado. |
migration:pending | Lista lo que falta por aplicar. | Ideal como comprobación previa al despliegue. |
migration:list | Lista lo ya ejecutado. | Lee la tabla de migraciones. |
migration:fresh | Borra todo y aplica todas las migraciones desde cero. | --seed para poblar. Jamás en producción. |
migration:check | Indica con el código de salida si hay cambios sin migrar. | Excelente gate en CI: falla si alguien tocó una entidad sin migración. |
{
"scripts": {
"mikro": "mikro-orm --config ./src/mikro-orm.config.ts",
"db:migration:create": "npm run mikro -- migration:create",
"db:migration:up": "npm run mikro -- migration:up",
"db:migration:pending": "npm run mikro -- migration:pending",
"db:migrate:prod": "mikro-orm --config ./dist/mikro-orm.config.js migration:up"
}
}La tabla de migraciones (mikro_orm_migrations) es el estado: una fila por migración aplicada, con su nombre y su fecha. El Migrator compara los ficheros del directorio con esas filas para decidir qué está pendiente. De ahí dos consecuencias operativas: nunca renombres ni edites una migración ya aplicada (deja de reconocerse y se intenta aplicar de nuevo), y nunca borres filas de esa tabla a mano salvo que sepas exactamente qué estás reparando.
17.6.3 Anatomía de una migración
import { Migration } from '@mikro-orm/migrations';
export class Migration20260615103000 extends Migration {
// up: el cambio hacia adelante.
async up(): Promise<void> {
this.addSql(`alter table "pedido" add column "estado" varchar(20) null;`);
this.addSql(`update "pedido" set "estado" = 'PENDIENTE' where "estado" is null;`);
this.addSql(`create index "pedido_estado_idx" on "pedido" ("estado");`);
}
// down: la vuelta atrás, en orden inverso.
async down(): Promise<void> {
this.addSql(`drop index "pedido_estado_idx";`);
this.addSql(`alter table "pedido" drop column "estado";`);
}
}this.addSql()encola instrucciones que se ejecutan en orden al aplicar la migración; no las ejecuta en el momento. Si necesitas leer datos para decidir, usathis.execute()o el query builder subyacente conthis.getKnex().- Cada migración se ejecuta dentro de una transacción si el motor lo permite (
transactional: true), y conallOrNothingtodas las pendientes comparten una sola transacción: si la cuarta falla, se deshacen las cuatro. - El nombre del fichero fija el orden. No lo cambies después de aplicarlo.
down debe existir y probarse
No es para «volver atrás en producción»: en producción revertir un esquema con datos nuevos suele ser imposible sin pérdida. Es para tres cosas muy concretas: (1) iterar en local sin recrear la base entera; (2) validar en CI que la migración es reversible, lo que obliga a pensar en qué datos se destruyen; (3) documentar de forma ejecutable qué introdujo el cambio. Una migración con down vacío es una declaración de que el cambio es irreversible, y eso debe ser una decisión consciente y comentada, no un olvido. Ponlo a prueba en CI con migration:up, migration:down y migration:up otra vez.
Algunas operaciones no pueden ejecutarse dentro de una transacción. La más frecuente es la creación de índices sin bloquear escrituras en PostgreSQL (CREATE INDEX CONCURRENTLY), que el motor rechaza en un bloque transaccional. La migración debe declararse como no transaccional.
export class Migration20260620090000 extends Migration {
// Desactiva la transacción SOLO para esta migración.
override isTransactional(): boolean {
return false;
}
async up(): Promise<void> {
// CONCURRENTLY no bloquea escrituras, pero tarda más y puede quedar
// el índice en estado INVALID si falla: hay que comprobarlo después.
this.addSql(`create index concurrently "factura_cliente_fecha_idx"
on "factura" ("cliente_id", "fecha");`);
}
async down(): Promise<void> {
this.addSql(`drop index concurrently if exists "factura_cliente_fecha_idx";`);
}
}CREATE INDEX CONCURRENTLY falla a medias, PostgreSQL deja un índice inválido que ocupa espacio y no se usa. Se detecta con SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; y se corrige eliminándolo y volviéndolo a crear. Con allOrNothing: true recuerda que esta migración queda fuera de la transacción común: si el despliegue se aborta después, el índice ya está creado.17.6.4 Migraciones sin tiempo de inactividad: expand/contract
Durante un despliegue hay un intervalo en el que convive código antiguo con código nuevo (despliegue progresivo, varias réplicas, rolling update de Kubernetes). Por tanto el esquema debe ser compatible con ambas versiones a la vez. El patrón que lo garantiza es expand/contract: primero se amplía el esquema de forma compatible, luego se migra el código y los datos, y solo al final se contrae eliminando lo viejo.
Objetivo: renombrar "nombre_completo" en "nombre" + "apellidos".
PASO 1 · EXPAND (migración) añade columnas NULL, no toca nada más
┌──────────────────────────────────────────────────────────────────────┐
│ nombre_completo │ nombre (null) │ apellidos (null) │
└──────────────────────────────────────────────────────────────────────┘
código v1 (lee/escribe nombre_completo) ✔ sigue funcionando
PASO 2 · DOBLE ESCRITURA (despliegue de código v2)
v2 escribe en nombre_completo Y en nombre/apellidos; lee nombre_completo
└── v1 y v2 conviven ✔ · esperar a que TODAS las réplicas sean v2
PASO 3 · RELLENO (script por lotes, fuera de la transacción de despliegue)
UPDATE ... SET nombre=..., apellidos=... WHERE nombre IS NULL (en tandas)
└── idempotente: se puede repetir y reanudar
PASO 4 · CAMBIO DE LECTURA (despliegue de código v3)
v3 lee nombre/apellidos y sigue escribiendo en ambos sitios
└── ventana de seguridad: si v3 falla, se vuelve a v2 sin pérdida
PASO 5 · DEJAR DE ESCRIBIR LO VIEJO (despliegue de código v4)
v4 ignora nombre_completo por completo
PASO 6 · CONTRACT (migración, días después)
ALTER TABLE ... DROP COLUMN nombre_completo; + NOT NULL en las nuevas
────────────────────────────────────────────────────────────────────────
Cada paso es desplegable y reversible por separado: nunca hay un instante en el
que el esquema y el código en ejecución sean incompatibles.
async up(): Promise<void> {
// Renombrado "atómico": rompe el código antiguo
// en el instante en que se aplica.
this.addSql(`alter table "cliente"
rename column "nombre_completo" to "nombre";`);
this.addSql(`alter table "cliente"
add column "apellidos" varchar(120) not null;`);
}
// Consecuencias durante el rolling update:
// · las réplicas v1 fallan con "column nombre_completo
// does not exist" en CADA consulta
// · NOT NULL sin default hace fallar la propia
// migración si la tabla ya tiene filasasync up(): Promise<void> {
// Paso 1: solo añadir, siempre nullable y sin
// reescribir la tabla. Compatible con v1 y v2.
this.addSql(`alter table "cliente"
add column "nombre" varchar(60) null;`);
this.addSql(`alter table "cliente"
add column "apellidos" varchar(120) null;`);
}
async down(): Promise<void> {
this.addSql(`alter table "cliente" drop column "nombre";`);
this.addSql(`alter table "cliente" drop column "apellidos";`);
}| Cambio | Riesgo si se hace de golpe | Receta segura |
|---|---|---|
| Añadir columna | Bajo si es NULL y sin valor por defecto volátil. | Añádela NULL. En PostgreSQL moderno un DEFAULT constante no reescribe la tabla; un DEFAULT con función sí puede hacerlo. |
| Renombrar columna | Rompe el código antiguo inmediatamente. | Expand/contract completo (los seis pasos del diagrama). |
| Cambiar de tipo | Reescritura completa de la tabla con bloqueo exclusivo; posible pérdida por truncamiento. | Columna nueva con el tipo nuevo, doble escritura, relleno por lotes, cambio de lectura, eliminación de la antigua. |
| Añadir índice en tabla grande | CREATE INDEX normal bloquea las escrituras hasta terminar. | CREATE INDEX CONCURRENTLY en una migración no transaccional; en MySQL 8, DDL en línea. |
Añadir NOT NULL | Escaneo completo con bloqueo exclusivo, y fallo si hay nulos. | Rellenar primero por lotes; luego CHECK (col IS NOT NULL) NOT VALID, VALIDATE CONSTRAINT (no bloquea lecturas) y por último SET NOT NULL. |
| Añadir clave foránea | Valida todas las filas existentes con bloqueo. | ADD CONSTRAINT ... NOT VALID y después VALIDATE CONSTRAINT. |
| Eliminar columna | Rompe cualquier réplica antigua y cualquier SELECT *. | Último paso del contract, cuando ninguna versión desplegada la menciona. |
-- 1. Añadir columna: instantáneo, no reescribe la tabla
ALTER TABLE "cliente" ADD COLUMN "nombre" varchar(60) NULL;
-- 2. Relleno por lotes: nunca en un solo UPDATE de 20 millones de filas
UPDATE "cliente" SET "nombre" = split_part("nombre_completo", ' ', 1)
WHERE "id" IN (
SELECT "id" FROM "cliente" WHERE "nombre" IS NULL ORDER BY "id" LIMIT 5000
);
-- repetir hasta que afecte a 0 filas, con una pausa entre tandas
-- 3. NOT NULL en dos fases, sin bloqueo largo de escritura
ALTER TABLE "cliente" ADD CONSTRAINT "cliente_nombre_no_nulo"
CHECK ("nombre" IS NOT NULL) NOT VALID; -- inmediato: no valida lo existente
ALTER TABLE "cliente" VALIDATE CONSTRAINT "cliente_nombre_no_nulo"; -- escanea sin bloquear lecturas
ALTER TABLE "cliente" ALTER COLUMN "nombre" SET NOT NULL; -- ya es barato
ALTER TABLE "cliente" DROP CONSTRAINT "cliente_nombre_no_nulo";
-- 4. Índice sin bloquear escrituras (fuera de transacción)
CREATE INDEX CONCURRENTLY "cliente_nombre_idx" ON "cliente" ("nombre");
-- 5. Protección universal contra ALTER que se queda esperando un bloqueo
SET lock_timeout = '3s'; -- si no consigue el bloqueo en 3 s, falla en vez de encolar
SET statement_timeout = '30s';ALTER TABLE necesita un bloqueo exclusivo. Si hay una consulta larga en curso, el ALTER se pone a la cola, y todas las consultas posteriores sobre esa tabla se ponen detrás de él, aunque solo lean. Una migración que «debería tardar 5 ms» acaba dejando la tabla inaccesible durante minutos. Por eso lock_timeout en las migraciones no es un lujo: es obligatorio.17.6.5 Migraciones de datos frente a migraciones de esquema
| Migración de esquema | Migración de datos | |
|---|---|---|
| Qué cambia | Estructura: tablas, columnas, índices, restricciones. | Contenido: rellenar, normalizar, corregir, reclasificar. |
| Duración | Idealmente milisegundos. | Puede ser de horas. |
| Transacción | Sí, una por migración. | Por lotes, con commit por lote. |
| Reejecutable | No: la controla la tabla de migraciones. | Debe ser idempotente y reanudable. |
| Dónde vive | src/migrations. | Script o comando de CLI aparte, invocable a mano. |
Un relleno pequeño (unas miles de filas, tiempo previsible) puede ir dentro de la migración. Sácalo a un script aparte cuando se cumpla cualquiera de estas condiciones: afecta a más de unas decenas de miles de filas, necesita lógica de negocio de la aplicación (no expresable en SQL), debe ejecutarse por tandas con pausas, o puede necesitar reanudarse tras un fallo. Una migración que tarda una hora bloquea el despliegue durante una hora y, con allOrNothing, mantiene una transacción gigante abierta.
// Ejecución: node dist/scripts/rellenar-nombres.js
// Idempotente: procesa solo lo que falta, se puede parar y reanudar.
const orm = await MikroORM.init();
const em = orm.em.fork();
let total = 0;
for (;;) {
const lote = await em.find(Cliente, { nombre: null }, { limit: 5000, orderBy: { id: 'asc' } });
if (lote.length === 0) break;
for (const c of lote) {
const [nombre, ...resto] = c.nombreCompleto.split(' ');
c.nombre = nombre;
c.apellidos = resto.join(' ') || '-';
}
await em.flush(); // un COMMIT por lote: transacciones cortas
em.clear(); // libera el Identity Map: memoria constante
console.log(`rellenados ${(total += lote.length)}`);
await new Promise((r) => setTimeout(r, 100)); // deja respirar a la base de datos
}
await orm.close(true);17.6.6 Migraciones en CI/CD
git push
▼
┌─────────────────────────── CI ────────────────────────────┐
│ build · lint · test │
│ migration:check ← ¿alguien tocó una entidad sin migrar? │
│ up → down → up ← el down funciona de verdad │
└─────────────────────────┬─────────────────────────────────┘
▼
┌───────────────── DESPLIEGUE (una sola vez) ───────────────┐
│ Job / init container / release phase: │
│ 1. pg_try_advisory_lock ← evita ejecuciones simultáneas │
│ 2. migration:up │
│ 3. libera el bloqueo │
└─────────────────────────┬─────────────────────────────────┘
▼
┌──────────── ARRANQUE DE LAS RÉPLICAS DE LA APP ───────────┐
│ N pods · NINGUNO ejecuta migraciones · solo migration: │
│ pending como comprobación de salud al arrancar │
└───────────────────────────────────────────────────────────┘
- Cuándo: después de construir la imagen y antes de que arranque el código nuevo. Como el esquema es compatible con la versión anterior (expand/contract), aplicarlas antes es seguro.
- Quién: un paso de despliegue dedicado (un
Jobde Kubernetes, un init container del Deployment, una release phase), nunca elbootstrapde la aplicación. Con réplicas, el arranque se ejecuta N veces. - Bloqueo: aunque el paso sea único, un reintento del pipeline o dos despliegues solapados pueden coincidir. Un advisory lock de PostgreSQL es la forma más simple de serializarlo.
- Credenciales: el usuario que migra necesita permisos de DDL; el usuario de la aplicación, no. Dos usuarios distintos reducen el daño de una inyección SQL.
const orm = await MikroORM.init();
const conexion = orm.em.getConnection();
// Identificador arbitrario pero fijo para "migraciones de esta aplicación".
const [{ bloqueado }] = await conexion.execute<{ bloqueado: boolean }[]>(
'select pg_try_advisory_lock(918273645) as bloqueado');
if (!bloqueado) {
console.error('Otro proceso está migrando. Abortando sin error.');
await orm.close(true);
process.exit(0); // no es un fallo: alguien más se está encargando
}
try {
const pendientes = await orm.migrator.getPendingMigrations();
console.log(`Aplicando ${pendientes.length} migraciones`);
await orm.migrator.up();
} finally {
await conexion.execute('select pg_advisory_unlock(918273645)');
await orm.close(true);
}- No la relances a ciegas. Mira primero la tabla
mikro_orm_migrations: te dice qué se aplicó realmente. - Si era transaccional (el caso normal en PostgreSQL, que soporta DDL transaccional), la base de datos ya deshizo el cambio: corrige el SQL y vuelve a desplegar. En MySQL, en cambio, el DDL no es transaccional: la mitad de los
ALTERpueden haberse aplicado. - Si quedó a medias, escribe una migración nueva de reparación en lugar de editar la fallida: inspecciona el estado real, hazla idempotente (
IF NOT EXISTS,IF EXISTS) y déjala en el historial. Editar una migración ya registrada oculta el problema y rompe los entornos donde sí funcionó. - Si el despliegue del código ya salió y el esquema no está listo, la vuelta atrás del código debe ser posible sin vuelta atrás del esquema: eso es precisamente lo que garantiza expand/contract.
- Antes de tocar producción a mano, ten una copia de seguridad reciente y verificada. Una restauración probada vale más que cualquier procedimiento improvisado.
17.7 Seeders y datos de prueba
Un entorno sin datos no se puede demostrar ni probar. @mikro-orm/seeder aporta dos piezas: el Seeder (un guion que puebla la base de datos, componible con otros seeders) y la Factory (una plantilla que genera entidades con datos verosímiles y permite sobrescribir lo que importa en cada test).
import { Factory } from '@mikro-orm/seeder';
import { faker } from '@faker-js/faker/locale/es';
import { Usuario } from '../entities/usuario.entity';
export class UsuarioFactory extends Factory<Usuario> {
model = Usuario;
// Valores por defecto verosímiles: todo lo que el test no fija.
definition(): Partial<Usuario> {
return {
nombre: faker.person.firstName(), apellidos: faker.person.lastName(),
email: faker.internet.email().toLowerCase(), activo: true,
creadoEn: faker.date.past({ years: 2 }),
};
}
}import { EntityManager } from '@mikro-orm/core';
import { Seeder } from '@mikro-orm/seeder';
export class DatabaseSeeder extends Seeder {
async run(em: EntityManager): Promise<void> {
// Datos deterministas primero: los que la aplicación necesita para arrancar.
em.create(Rol, { codigo: 'ADMIN', nombre: 'Administrador' });
em.create(Rol, { codigo: 'USER', nombre: 'Usuario' });
// Un usuario fijo y conocido para poder entrar a mano en desarrollo.
new UsuarioFactory(em).makeOne({ email: 'admin@ejemplo.local', nombre: 'Admin' });
// Volumen para ver la paginación y medir consultas.
new UsuarioFactory(em).each((u) => { u.activo = Math.random() > 0.1; }).make(200);
// Relaciones: crea el agregado completo desde la factoría del padre.
new PedidoFactory(em).make(50);
// El SeedManager hace flush al terminar; llamar a otros seeders:
// return this.call(em, [CatalogoSeeder, PromocionesSeeder]);
}
}npx mikro-orm seeder:create DatabaseSeeder # andamiaje de un seeder nuevo
npx mikro-orm seeder:run # ejecuta el defaultSeeder de la configuración
npx mikro-orm seeder:run --class=CatalogoSeeder
npx mikro-orm migration:fresh --seed # local desde cero: esquema + migraciones + datos| Datos de desarrollo | Datos de test | |
|---|---|---|
| Objetivo | Poder usar la aplicación y ver casos variados. | Probar un comportamiento concreto. |
| Volumen | Cientos o miles de filas. | El mínimo imprescindible. |
| Determinismo | Aleatorio está bien (con credenciales fijas conocidas). | Obligatorio: sin aleatoriedad no controlada. Fija la semilla de faker. |
| Quién lo crea | Un seeder global. | Factorías dentro de cada test, con makeOne({...}). |
| Antipatrón | Depender de un volcado de producción. | Un seeder compartido enorme: los tests se acoplan entre sí y fallan en cascada. |
Copiar la base de producción a preproducción es cómodo y es una de las formas más habituales de infringir el RGPD: los datos personales pasan a un entorno con menos controles, más accesos y a menudo con correo real saliente. Si necesitas volumen y distribución realistas, aplica una anonimización irreversible en el mismo proceso de copia y nunca en un paso manual posterior.
- Sustituye nombres, direcciones y teléfonos por datos sintéticos, no por transformaciones reversibles.
- Reescribe los correos a un dominio inexistente (
usuario+123@ejemplo.invalid) para que sea imposible enviar nada a una persona real. - Sustituye los hashes de contraseña por el de una contraseña conocida de test.
- Elimina, no ofusques, los datos de categoría especial (salud, biometría) y los medios de pago.
- Conserva las distribuciones que importan para el rendimiento: número de filas, cardinalidad de las claves foráneas, tamaño de los campos de texto.
- Deja registro del proceso: un script versionado, revisable y ejecutado automáticamente.
17.8 Filtros globales
Un filtro global es una condición que MikroORM añade automáticamente al WHERE de todas las consultas sobre una entidad, sin que tengas que escribirla. Resuelve el problema de las condiciones transversales: reglas que deben cumplirse en el 100 % de las consultas y que, escritas a mano, se olvidan exactamente donde más duele.
import { Entity, Filter, PrimaryKey, Property } from '@mikro-orm/core';
@Entity()
// 1. Borrado lógico: activo por defecto en todas las consultas.
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
// 2. Multi-inquilino: la condición es una función y args: true exige parámetros.
@Filter({ name: 'tenant', args: true, default: true,
cond: (args: { tenantId: string }) => ({ tenant: args.tenantId }) })
// 3. Visibilidad por permisos: la condición puede depender del tipo de operación
// ('read', 'update', 'delete'). Con default: false se activa por consulta.
@Filter({ name: 'soloPublicados', default: false,
cond: (args, type) => (type === 'read' ? { estado: 'PUBLICADO' } : {}) })
export class Documento {
@PrimaryKey() id!: string;
@Property() titulo!: string;
@Property() estado!: 'BORRADOR' | 'PUBLICADO';
@Property() tenant!: string;
@Property({ nullable: true }) deletedAt?: Date;
}// Definidos globalmente en la configuración: se aplican a las entidades indicadas.
export default defineConfig({
filters: {
softDelete: { cond: { deletedAt: null }, default: true, entity: ['Documento', 'Usuario'] },
},
});
// Añadidos en tiempo de ejecución sobre un EM concreto (por ejemplo, en un middleware):
em.addFilter('tenant', (args: { tenantId: string }) => ({ tenant: args.tenantId }), ['Documento']);
// Los parámetros se fijan una vez por contexto y valen para toda la petición:
em.setFilterParams('tenant', { tenantId: peticion.tenantId });
// Y se desactivan o activan por consulta:
await em.find(Documento, {}, { filters: { softDelete: false } }); // incluye borrados
await em.find(Documento, {}, { filters: ['soloPublicados'] }); // activa uno no-default
await em.find(Documento, {}, { filters: false }); // desactiva TODOS (peligroso)-- em.find(Documento, { estado: 'PUBLICADO' }) con softDelete y tenant activos
SELECT "d0".* FROM "documento" AS "d0"
WHERE "d0"."estado" = 'PUBLICADO' AND "d0"."deleted_at" IS NULL AND "d0"."tenant" = 'acme';
-- em.find(Documento, { estado: 'PUBLICADO' }, { filters: false })
SELECT "d0".* FROM "documento" AS "d0" WHERE "d0"."estado" = 'PUBLICADO';
-- ← ninguna protección: ni borrado lógico ni aislamiento por inquilinoLos filtros se aplican a los métodos del EntityManager (find, findOne, count, carga de relaciones, nativeUpdate, nativeDelete). No se aplican a SQL crudo (em.getConnection().execute(...)), y su comportamiento con el QueryBuilder ha cambiado entre versiones. Antes de confiar en ellos como mecanismo de aislamiento, verifica con el registro de SQL activado que la condición aparece en las consultas de tu versión, incluidas las del QueryBuilder y las de las relaciones cargadas de forma perezosa.
Un filtro es una comodidad, no un control de acceso. Motivos:
- Se puede desactivar por consulta, y un
filters: falseañadido para depurar puede acabar en producción. - No cubre SQL crudo, vistas, procedimientos, informes ni herramientas externas conectadas a la misma base de datos.
- Depende de que los parámetros estén bien fijados: si
setFilterParamsno se ejecutó en esa ruta, la consulta puede fallar (mejor caso) o filtrar porundefined.
Defensa en profundidad para multi-inquilino: filtro global más comprobación explícita del tenantId en el caso de uso al cargar el agregado raíz, más políticas de seguridad a nivel de fila en la base de datos (ROW LEVEL SECURITY en PostgreSQL) cuando el dato es realmente sensible, más un test de integración que intente leer datos de otro inquilino y espere un error.
17.9 Soft delete (borrado lógico)
Borrar lógicamente es marcar la fila como eliminada en lugar de suprimirla. Se usa cuando el dato tiene valor histórico (facturas, movimientos), cuando hay que poder deshacer, cuando otras filas lo referencian o cuando una auditoría exige rastro. La implementación completa tiene más piezas de las que parece.
// Clase base reutilizable: la columna, el filtro y las operaciones.
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
export abstract class BaseBorrable {
@Property({ nullable: true, index: true }) deletedAt?: Date | null;
@Property({ nullable: true }) deletedBy?: string | null; // casi siempre se acaba necesitando
get estaBorrado(): boolean { return this.deletedAt != null; }
}@Injectable()
export class DocumentosService {
constructor(private readonly em: EntityManager) {}
// BORRAR: no es em.remove(), es una actualización.
async borrar(id: string, usuarioId: string): Promise<void> {
const doc = await this.em.findOneOrFail(Documento, id); // el filtro evita reborrar
doc.deletedAt = new Date();
doc.deletedBy = usuarioId;
await this.em.flush();
}
// RESTAURAR: hay que desactivar el filtro para poder encontrarlo.
async restaurar(id: string): Promise<void> {
const doc = await this.em.findOneOrFail(Documento, id, { filters: { softDelete: false } });
doc.deletedAt = null;
doc.deletedBy = null;
await this.em.flush();
}
// CONSULTAR INCLUYENDO BORRADOS: solo para administración o papelera.
async listarPapelera(): Promise<Documento[]> {
return this.em.find(Documento, { deletedAt: { $ne: null } },
{ filters: { softDelete: false }, orderBy: { deletedAt: 'desc' } });
}
// PURGAR de verdad: borrado físico pasados N días (retención).
async purgar(dias = 30): Promise<number> {
const limite = new Date(Date.now() - dias * 86_400_000);
// Sin desactivar el filtro no borraría nada: exige deletedAt IS NULL.
return this.em.nativeDelete(Documento, { deletedAt: { $lt: limite } },
{ filters: { softDelete: false } });
}
}17.9.1 El problema clásico: restricciones únicas y filas borradas
Si usuario.email es único y borras lógicamente a ana@ejemplo.com, la fila sigue existiendo: la restricción única impide que Ana vuelva a registrarse. Es el error que aparece siempre, semanas después de implementar el borrado lógico, y en forma de duplicate key value violates unique constraint incomprensible para el usuario.
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null },
default: true })
export class Usuario {
@PrimaryKey() id!: string;
// Unicidad global: cuenta también las filas borradas.
@Property({ unique: true })
email!: string;
@Property({ nullable: true }) deletedAt?: Date;
}
// Ana se borra y quiere volver:
// ERROR: duplicate key value violates unique
// constraint "usuario_email_unique"@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null },
default: true })
// Índice único PARCIAL: solo aplica a las filas vivas.
// Se declara con una expresión para que la migración lo cree.
@Index({
name: 'usuario_email_activo_unq',
expression: `create unique index "usuario_email_activo_unq"
on "usuario" ("email") where "deleted_at" is null`,
})
export class Usuario {
@PrimaryKey() id!: string;
@Property() email!: string; // sin unique: lo aporta el índice parcial
@Property({ nullable: true }) deletedAt?: Date;
}-- OPCIÓN A · Índice único parcial (PostgreSQL, SQLite). La más limpia.
CREATE UNIQUE INDEX "usuario_email_activo_unq" ON "usuario" ("email") WHERE "deleted_at" IS NULL;
-- OPCIÓN B · Columna generada + único compuesto (MySQL 8, que no tiene índices parciales).
-- NULL no rompe la unicidad, pero aquí necesitamos un valor discriminante.
ALTER TABLE "usuario"
ADD COLUMN "borrado_token" varchar(40)
GENERATED ALWAYS AS (IF("deleted_at" IS NULL, 'ACTIVO', CONCAT('DEL-', "id"))) STORED;
CREATE UNIQUE INDEX "usuario_email_token_unq" ON "usuario" ("email", "borrado_token");
-- OPCIÓN C · Liberar el valor al borrar (destructiva pero simple):
UPDATE "usuario" SET "deleted_at" = now(), "email" = concat('borrado+', "id", '@invalid')
WHERE "id" = 'u-1';
-- Pierdes el email original: útil precisamente cuando además hay que anonimizar.17.9.2 Claves foráneas, integridad y derecho de supresión
- Las claves foráneas no se enteran.
ON DELETE CASCADEnunca se dispara porque no hayDELETE. Si borras lógicamente un pedido, sus líneas siguen «vivas» y visibles: el borrado en cascada lógico tienes que programarlo tú, y debe ser transaccional. - Las consultas por relación pueden mentir. Un
JOINcon una tabla cuyo filtro de borrado no se aplica (SQL crudo, vistas, informes) devuelve filas que la aplicación considera inexistentes. Coherencia o dolor: si una entidad es borrable lógicamente, todo lo que la consulte debe saberlo. - Los agregados y contadores.
count(*)en informes, cuotas de plan, límites de uso: cada uno debe decidir explícitamente si cuenta lo borrado. - El rendimiento. Las filas borradas siguen ocupando la tabla y sus índices. Con mucho borrado lógico, conviene un índice parcial (
WHERE deleted_at IS NULL) y una purga periódica.
Marcar deletedAt no es suprimir datos personales: los datos siguen ahí, íntegros y consultables. Si un usuario ejerce su derecho de supresión, necesitas un procedimiento distinto:
- Suprimir de verdad lo que no tenga base legal de conservación (perfil, preferencias, dispositivos, sesiones, ficheros subidos).
- Anonimizar de forma irreversible lo que debas conservar por obligación legal o contable: una factura debe seguir existiendo, pero puede apuntar a un cliente «Usuario eliminado» sin datos identificativos.
- Propagar la supresión a copias y derivados: caché, índices de búsqueda, almacenes de eventos, exportaciones, registros de auditoría, copias de seguridad (con su plazo documentado) y proveedores externos.
- Dejar constancia de la supresión (fecha y alcance) sin conservar los datos suprimidos: se registra el hecho, no el contenido.
Traducción a diseño: el borrado lógico sirve para la papelera del producto; la supresión legal es un caso de uso aparte, con su propio código, sus propios tests y su propio registro. No los confundas.
17.10 Eventos y suscriptores
MikroORM permite enganchar código al ciclo de vida de las entidades de dos formas: hooks (decoradores en métodos de la propia entidad) y suscriptores (clases que implementan EventSubscriber y observan varias entidades). Los hooks son locales y sencillos; los suscriptores son globales, testeables por separado y pueden inyectarse dependencias.
| Evento | Cuándo se dispara | Qué es seguro hacer |
|---|---|---|
onInit | Al crear la instancia: tanto al hidratar desde la base de datos como con em.create(). | Inicializar campos calculados en memoria. Nada asíncrono ni de base de datos: se ejecuta muchísimas veces. |
onLoad | Después de cargar y hidratar por completo la entidad desde la base de datos. | Enriquecer con datos derivados; admite código asíncrono. Cuidado: se ejecuta por cada entidad cargada. |
beforeCreate | Antes del INSERT, con el change set ya calculado. | Rellenar valores derivados de la propia entidad (slug, checksum). Los cambios se incluyen en el INSERT. |
afterCreate | Después del INSERT (la clave ya existe). | Registrar, publicar eventos de dominio. Modificar la entidad aquí no se persiste en ese flush. |
beforeUpdate | Antes del UPDATE. | Actualizar campos derivados. Se puede consultar el change set para saber qué cambió. |
afterUpdate | Después del UPDATE. | Efectos posteriores: invalidar caché, encolar trabajos. |
beforeDelete / afterDelete | Alrededor del DELETE. | Validaciones de última hora y limpieza de recursos externos (ficheros). |
beforeFlush | Al principio del flush, antes de calcular los change sets. | El único sitio donde es totalmente seguro crear, modificar o borrar entidades: todavía entrarán en el cálculo. |
onFlush | Change sets ya calculados, antes de ejecutar el SQL. | Inspeccionar y ajustar change sets; si añades entidades hay que recalcular explícitamente. |
afterFlush | Cuando todo el SQL del flush se ha ejecutado. | Solo lectura y efectos externos. Ya no puedes añadir nada a ese flush. |
beforeTransactionStart, afterTransactionStart, beforeTransactionCommit, afterTransactionCommit, beforeTransactionRollback, afterTransactionRollback | Alrededor de las fronteras de la transacción. | Instrumentación: métricas de duración, trazas, fijar variables de sesión (por ejemplo el inquilino para ROW LEVEL SECURITY). |
import { BeforeCreate, BeforeUpdate, Entity, EventArgs, OnInit } from '@mikro-orm/core';
@Entity()
export class Articulo {
@PrimaryKey() id!: string;
@Property() titulo!: string;
@Property() slug!: string;
@Property({ type: 'text' }) cuerpo!: string;
@Property() palabras: number = 0;
// Derivar datos de la propia entidad: uso legítimo y sin sorpresas.
@BeforeCreate()
@BeforeUpdate()
recalcular(args: EventArgs<Articulo>): void {
this.slug = slugify(this.titulo);
this.palabras = this.cuerpo.trim().split(/\s+/).length;
// El change set indica qué cambió realmente (útil para evitar trabajo).
const cambios = args.changeSet?.payload;
if (cambios && 'cuerpo' in cambios) this.revisionesCuerpo++;
}
@OnInit()
inicializar(): void {
this.slug ??= ''; // solo memoria: nada de await ni de consultas
}
}nativeUpdate, nativeDelete, qb.update(), em.insertMany() y el SQL crudo no pasan por el Unit of Work: no hay hooks, no hay suscriptores, no hay columna de versión incrementada. Si tu lógica de auditoría o de invalidación de caché vive en un hook, cualquier escritura masiva la salta en silencio. Es la razón principal para no poner reglas importantes en los eventos.@Entity()
export class Pedido {
// Lógica de negocio escondida en un hook:
@AfterUpdate()
async alActualizar(args: EventArgs<Pedido>) {
if (this.estado === 'PAGADO') {
// Efectos invisibles desde el caso de uso:
await mailer.enviarFactura(this); // no se deshace con ROLLBACK
await almacen.reservarStock(this); // ¿y si falla?
await erp.sincronizar(this); // 800 ms dentro del flush
}
}
}
// Problemas: no se dispara con nativeUpdate; sí se dispara al importar datos
// o al ejecutar un seeder; imposible de testear sin base de datos; el orden
// de los efectos depende del orden interno del flush.@Injectable()
export class PedidosService {
// El caso de uso es explícito y legible de arriba abajo.
async marcarPagado(id: string): Promise<void> {
await this.em.transactional(async (em) => {
const p = await em.findOneOrFail(Pedido, id);
p.marcarPagado(); // invariantes en el dominio
// Efectos externos: registrados de forma atómica,
// ejecutados FUERA de la transacción (patrón outbox).
em.create(EventoSaliente, { tipo: 'PedidoPagado', payload: { pedidoId: p.id } });
});
}
}
// Testeable sin base de datos, visible en la revisión,
// idempotente y con reintentos en el consumidor.17.11 Auditoría: quién cambió qué y cuándo
| Enfoque | Qué guarda | Ventajas | Inconvenientes |
|---|---|---|---|
Columnas createdBy / updatedBy / updatedAt | Solo el último autor y la última fecha. | Trivial de implementar y de consultar; coste casi nulo. | No hay historial: el cambio anterior se pierde. |
| Tabla de auditoría con el diff | Una fila por cambio con el antes y el después de los campos afectados. | Historial completo, consultable, con contexto de aplicación (usuario, IP, motivo). | Crece rápido; hay que decidir retención; no captura cambios hechos fuera de la aplicación. |
| Triggers en la base de datos | Lo mismo, pero escrito por el motor. | Captura todo, incluidas las escrituras manuales y las masivas. | No conoce el usuario de aplicación (hay que pasarlo por una variable de sesión); lógica fuera del repositorio; más difícil de versionar y de testear. |
| Event sourcing ligero | Los eventos de dominio como fuente de verdad del historial. | Semántica de negocio («pedido cancelado por falta de stock»), no diffs de columnas. | Requiere diseño previo, versionado de eventos y proyecciones; sobredimensionado para un CRUD. |
import { ChangeSetType, EventSubscriber, FlushEventArgs } from '@mikro-orm/core';
// El usuario actual viaja en AsyncLocalStorage (capítulo 11), no como parámetro.
@Injectable()
export class AuditoriaSubscriber implements EventSubscriber {
constructor(em: EntityManager, private readonly ctx: ContextoPeticion) {
em.getEventManager().registerSubscriber(this);
}
// Limitar el alcance evita auditar la propia tabla de auditoría (bucle infinito).
getSubscribedEntities(): string[] { return ['Pedido', 'Producto', 'Usuario', 'Tarifa']; }
// onFlush: los change sets ya están calculados y seguimos DENTRO del flush,
// así que las filas de auditoría se escriben en la misma transacción.
async onFlush(args: FlushEventArgs): Promise<void> {
const autor = this.ctx.usuarioId ?? 'sistema';
let nuevos = 0;
for (const cs of args.uow.getChangeSets()) {
const antes: Record<string, unknown> = {};
const despues: Record<string, unknown> = {};
if (cs.type === ChangeSetType.UPDATE) {
for (const campo of Object.keys(cs.payload)) {
if (CAMPOS_SENSIBLES.has(campo)) continue; // nunca audites contraseñas
antes[campo] = (cs.originalEntity as Record<string, unknown>)?.[campo];
despues[campo] = (cs.payload as Record<string, unknown>)[campo];
}
if (Object.keys(despues).length === 0) continue; // cambio irrelevante
}
args.em.create(RegistroAuditoria, { entidad: cs.name, entidadId: String(cs.entity.id),
operacion: cs.type, antes, despues, autor, fecha: new Date(),
peticionId: this.ctx.correlacionId });
nuevos++;
}
// Las entidades creadas aquí no entran en los change sets ya calculados:
// hay que pedir explícitamente que se recalculen.
if (nuevos > 0) args.uow.computeChangeSets();
}
}onFlush en tu versión El acceso a los change sets (uow.getChangeSets(), cs.payload, cs.originalEntity) y la necesidad de recalcular al crear entidades dentro del evento son detalles internos que MikroORM documenta, pero que han evolucionado entre versiones. Consulta la página de eventos de tu versión 6 y protege el comportamiento con un test de integración: es la única forma de detectar una regresión al actualizar. Si prefieres no depender de internals, la alternativa es un servicio de auditoría llamado explícitamente por cada caso de uso: más verboso, pero inmune a cambios del ORM.BEGIN;
UPDATE "tarifa" SET "importe" = 120 WHERE "id" = 't1' AND "version" = 3;
INSERT INTO "registro_auditoria"
("entidad", "entidad_id", "operacion", "antes", "despues", "autor", "peticion_id", "fecha")
VALUES ('Tarifa', 't1', 'update', '{"importe":100}', '{"importe":120}',
'u-42', 'a1b2c3', now());
COMMIT; -- el cambio y su rastro se confirman juntos: nunca uno sin el otro17.12 Rendimiento
17.12.1 Operaciones masivas
El Unit of Work está optimizado para el caso normal: unas decenas de entidades por transacción, con seguimiento de cambios, hooks y relaciones. Para medio millón de filas ese seguimiento es puro coste. MikroORM ofrece atajos que no pasan por el Unit of Work, y por eso son rápidos y por eso hay que usarlos con los ojos abiertos.
| Operación | Qué hace | Coste | Qué pierdes |
|---|---|---|---|
em.persist() + flush() | Change sets, agrupación por lotes, hooks, relaciones. | Alto por entidad. | Nada. |
em.insertMany(E, datos) | Un INSERT con varias tuplas; devuelve las claves. | Mínimo. | Hooks, eventos, Identity Map, versión, relaciones en cascada. |
em.upsertMany(E, datos) | INSERT ... ON CONFLICT DO UPDATE. | Mínimo. | Lo mismo. Ideal para sincronizaciones idempotentes. |
em.nativeUpdate(E, where, datos) | Un UPDATE masivo en el servidor. | Mínimo. | Hooks, versión, coherencia del Identity Map ya cargado. |
em.nativeDelete(E, where) | DELETE masivo. | Mínimo. | Cascadas de la ORM (las de la base de datos sí actúan). |
// Importar 500.000 filas de un CSV.
async importar(filas: FilaCsv[]) {
for (const f of filas) this.em.create(Cliente, mapear(f));
// Un único flush con 500.000 entidades:
// · el Identity Map guarda 500.000 objetos
// · el UoW guarda otras 500.000 instantáneas y calcula 500.000 diffs
// · una transacción gigantesca de varios minutos
await this.em.flush();
}
// Resultado real: heap de Node por encima de 2 GB, "JavaScript heap out of
// memory" y una transacción que bloquea el autovacuum mientras dura.// Por lotes, con memoria constante y transacciones cortas.
async importar(filas: AsyncIterable<FilaCsv>) {
const em = this.em.fork(); // contexto propio y aislado
let buffer: EntityData<Cliente>[] = [];
for await (const f of filas) {
buffer.push(mapear(f));
if (buffer.length >= 2_000) {
await em.insertMany(Cliente, buffer); // sin UoW: rápido
buffer = []; em.clear(); // libera el Identity Map
}
}
if (buffer.length) await em.insertMany(Cliente, buffer);
}
// Memoria estable (~1 lote), transacciones cortas y progreso reanudable.batchSize y agrupación de instrucciones Cuando el flush tiene que insertar o actualizar muchas entidades del mismo tipo, MikroORM agrupa las instrucciones en lotes cuyo tamaño controla la opción global batchSize (unos cientos por defecto). Subirlo reduce el número de idas y vueltas a la base de datos, pero aumenta el tamaño de cada sentencia y el consumo de memoria del driver; PostgreSQL además tiene un límite de parámetros por sentencia. Ajústalo midiendo (por ejemplo 500–2000 para importaciones) en lugar de a ojo, y recuerda que batchSize no elimina el coste del Unit of Work: solo el de la red.17.12.2 El coste del Identity Map en procesos largos
El Identity Map es una tabla hash que garantiza «una fila, un objeto» dentro de un contexto. Su efecto secundario es que nada de lo que cargas se libera mientras el contexto viva: en una petición HTTP de 80 ms es exactamente lo que quieres; en un proceso que recorre un millón de filas es una fuga de memoria por diseño.
Memoria del proceso durante un recorrido de 1.000.000 de filas
sin em.clear() con em.clear() cada 2.000 filas
▲ ▲
│ ╱ OOM │
│ ╱ │
│ ╱ │ ▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂▁▂
│ ╱ │
│ ╱╱ │
└──────────────────────────► tiempo └──────────────────────────► tiempo
Identity Map + instantáneas del UoW memoria acotada por el lote
em.clear()vacía el Identity Map del contexto actual. Las entidades que tuvieras en variables locales quedan «desconectadas»: no se les seguirán los cambios.em.fork()crea un contexto nuevo. Es lo correcto para cada unidad de trabajo independiente (un mensaje de cola, un fichero, un inquilino).- Nunca uses el EM global de la aplicación en un bucle largo. Su Identity Map crecerá hasta que el proceso muera.
- Para lecturas de gran volumen que no vas a modificar, evita cargar entidades: consulta con
qb.execute()o pide objetos planos. Sin entidades no hay Identity Map ni instantáneas, y el consumo baja de forma drástica.
17.12.3 Pool de conexiones
export default defineConfig({
pool: {
min: 2, // conexiones siempre listas: evita latencia en frío
max: 10, // TOPE por instancia de la aplicación
acquireTimeoutMillis: 5_000, // fallar rápido si el pool está saturado
idleTimeoutMillis: 30_000, // devolver conexiones ociosas
},
driverOptions: {
connection: {
statement_timeout: 15_000, // ninguna consulta debe durar más
idle_in_transaction_session_timeout: 10_000, // mata transacciones olvidadas
},
},
}); Presupuesto de conexiones (PostgreSQL max_connections = 100)
┌────────────────────────────────────────────────────────────────┐
│ 100 total del servidor │
│ -3 superusuario reservado (superuser_reserved_connections) │
│ -10 migraciones, copias de seguridad, panel, analítica │
│ ───────────────────────────────────────────────────────────────│
│ = 87 disponibles para la aplicación │
│ 87 / (réplicas de la API + trabajadores de colas + crons) = │
│ 87 / (4 pods + 2 workers + 1 cron) ≈ 12 → pool.max = 10 │
└────────────────────────────────────────────────────────────────┘
Más conexiones NO es más rendimiento: por encima de unas 2-4 veces el número
de núcleos de la base de datos, el rendimiento total BAJA por contención.
Si necesitas mucha concurrencia, pon un pooler (PgBouncer) delante.
- Fuga de conexiones. Sus dos causas casi únicas son un
begin()sincommit/rollbacken alguna rama y una transacción que espera algo externo (una llamada HTTP, un bloqueo, la entrada del usuario). Se diagnostica conSELECT state, count(*) FROM pg_stat_activity GROUP BY state: si ves muchasidle in transaction, tienes una fuga. - Timeout al adquirir. El error «timeout acquiring a connection» casi nunca significa «el pool es pequeño»: significa que alguien retiene conexiones demasiado tiempo. Subir
maxa ciegas traslada el problema al servidor de base de datos. - Serverless y funciones. Cada instancia tiene su propio pool y las instancias se multiplican sin control: ahí un pooler externo o un driver HTTP no es opcional.
- Cierra bien.
orm.close(true)al terminar un script o al recibirSIGTERM; si no, el proceso no muere y el orquestador acaba matándolo.
17.12.4 Caché de resultados, caché de metadatos y arranque
export default defineConfig({
// 1. CACHÉ DE RESULTADOS: opcional por consulta, nunca global sin pensarlo.
// adapter: RedisCacheAdapter → necesario si hay varias réplicas.
resultCache: { expiration: 5_000 }, // valor por defecto en milisegundos
// 2. CACHÉ DE METADATOS: acelera el arranque. En producción, generada en el build.
metadataCache: { enabled: true },
// 3. PROVEEDOR DE METADATOS: de dónde salen los tipos.
// metadataProvider: TsMorphMetadataProvider, // analiza el código fuente
});
// Uso de la caché de resultados por consulta:
const top = await em.find(Producto, { destacado: true }, { cache: 60_000 });
const menu = await em.find(Categoria, {}, { cache: ['menu-principal', 300_000] });
await em.clearCache('menu-principal'); // invalidación explícita al editar categoríasReflectMetadataProvider | TsMorphMetadataProvider | |
|---|---|---|
| De dónde saca los tipos | De emitDecoratorMetadata en tiempo de ejecución. | Analiza el código TypeScript (o los .d.ts) con la API del compilador. |
| Verbosidad | Hay que declarar el tipo explícitamente en los casos que el reflejo no distingue. | Deduce casi todo del propio código. |
| Arranque | Rápido: milisegundos. | Lento: puede añadir segundos al primer arranque. |
| Requisitos en producción | Ninguno especial. | Necesita los ficheros de tipos junto al código compilado. |
| Recomendación | Por defecto (es el predeterminado en la versión 6). | Solo si su comodidad compensa; siempre con caché de metadatos generada en el build. |
El descubrimiento de entidades cuesta tiempo en cada arranque, y en un despliegue con escalado automático los arranques son frecuentes. La solución es generar la caché de metadatos como paso del build (la CLI tiene un comando cache:generate, con una variante que produce un único fichero combinado) e incluir ese fichero en la imagen. Con ella el arranque no vuelve a analizar entidades.
Comprueba en la documentación de tu versión el nombre exacto de la opción y del comando, porque cambiaron entre la versión 5 y la 6. Y ten en cuenta dos detalles: si empaquetas con un bundler, el descubrimiento por patrones de fichero no funciona (hay que registrar las entidades explícitamente), y una caché de metadatos obsoleta produce errores desconcertantes: bórrala en cada build.
17.12.5 Lista de comprobación de rendimiento del ORM
- Registro de SQL activado en desarrollo. Si no ves las consultas, no estás optimizando: estás adivinando.
- Ninguna consulta dentro de un bucle. El N+1 es el problema número uno con diferencia (capítulo 16).
- Proyección explícita (
fields) en las listas grandes: no traigas columnas de texto ni JSON que nadie muestra. - Paginación siempre, con límite máximo impuesto por el servidor, no por el cliente.
- Índices que cubran los
WHEREy losORDER BYreales, verificados conEXPLAIN ANALYZE, no supuestos. - Escrituras masivas con
insertMany/nativeUpdatey procesamiento por lotes conem.clear(). - Transacciones cortas y sin llamadas de red dentro.
- Pool dimensionado con la aritmética de la sección 17.12.3, con
statement_timeoutyidle_in_transaction_session_timeoutconfigurados. - Caché de metadatos generada en el build y arranque medido.
- Métricas en producción: duración de consulta (percentil 95 y 99), consultas por petición, duración de transacción, conexiones en uso, tamaño del heap. Sin estas cinco, los problemas se descubren por Twitter.
17.13 Multi-tenancy
A · BASE DE DATOS POR INQUILINO B · ESQUEMA POR INQUILINO C · COLUMNA DISCRIMINADORA
┌──────────┐ ┌──────────┐ ┌───────── una base ────────┐ ┌──────── una base ────────┐
│ db_acme │ │ db_globex│ │ schema acme │ schema glob │ │ tabla pedido │
│ ┌──────┐ │ │ ┌──────┐ │ │ ┌────────┐ │ ┌────────┐ │ │ id │ tenant_id │ total │
│ │pedido│ │ │ │pedido│ │ │ │ pedido │ │ │ pedido │ │ │ 1 │ acme │ 120 │
│ └──────┘ │ │ └──────┘ │ │ └────────┘ │ └────────┘ │ │ 2 │ globex │ 80 │
└──────────┘ └──────────┘ └─────────────┴─────────────┘ └──────────────────────────┘
Aislamiento máximo Aislamiento medio Aislamiento lógico
Coste operativo máximo Coste medio Coste mínimo
| Criterio | Base de datos por inquilino | Esquema por inquilino | Columna discriminadora |
|---|---|---|---|
| Aislamiento de datos | Total (físico). | Alto (lógico fuerte). | Depende del código: un WHERE olvidado es una fuga. |
| Migraciones | N ejecuciones, una por inquilino; hay que orquestarlas y controlar fallos parciales. | N ejecuciones sobre la misma conexión; más rápido, pero igual de N. | Una sola. Ventaja enorme. |
| Coste por inquilino | Alto (conexiones, memoria, copias de seguridad). | Medio. | Casi nulo: escala a decenas de miles. |
| Copia, restauración o exportación de un inquilino | Trivial. | Sencilla. | Laboriosa: hay que recorrer todas las tablas. |
| Consultas entre inquilinos (métricas del producto) | Difícil. | Media. | Trivial. |
| Ruido entre vecinos | Ninguno. | Compartido pero acotable. | Un inquilino grande afecta a todos. |
| Cuándo elegirla | Pocos clientes grandes, requisitos regulatorios o de residencia de datos, contratos con aislamiento explícito. | Punto intermedio: decenas o cientos de inquilinos con exigencia de separación. | Opción por defecto en SaaS con muchos inquilinos pequeños. |
// 1. La entidad declara el filtro con argumentos.
@Entity()
@Filter({ name: 'tenant', cond: (a: { tenantId: string }) => ({ tenantId: a.tenantId }),
args: true, default: true })
export class Pedido {
@PrimaryKey() id!: string;
@Property({ index: true }) tenantId!: string; // índice: entra en TODAS las consultas
@Property() total!: number;
}
// 2. Un middleware fija el inquilino al principio de cada petición.
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly em: EntityManager) {}
use(req: Request, _res: Response, next: NextFunction): void {
const tenantId = resolverInquilino(req); // subdominio, JWT o cabecera
if (!tenantId) throw new ForbiddenException('Inquilino no resuelto');
this.em.setFilterParams('tenant', { tenantId }); // solo para ESTA petición
next();
}
}
// 3. Al crear entidades, el tenantId hay que ponerlo: el filtro solo filtra lecturas
// y actualizaciones, no rellena valores. Un suscriptor beforeCreate es buen sitio.// B · ESQUEMA POR INQUILINO: entidades con esquema comodín.
@Entity({ schema: '*' })
export class Pedido { /* ... */ }
// Un fork por petición con el esquema del inquilino resuelto:
const em = orm.em.fork();
em.schema = inquilino.esquema; // afecta a las consultas de este contexto
const pedidos = await em.find(Pedido, {}); // SELECT ... FROM "acme"."pedido"
await orm.em.find(Pedido, {}, { schema: 'acme' }); // o bien por consulta
// Migraciones: una pasada por esquema, registrando el resultado de cada uno.
for (const t of await listarInquilinos()) await orm.migrator.up({ /* esquema de t */ });
// A · BASE DE DATOS POR INQUILINO: una instancia de MikroORM por inquilino,
// creadas de forma perezosa y con una caché con expiración para no agotar conexiones.
const orms = new Map<string, MikroORM>();
async function ormDe(inquilino: string): Promise<MikroORM> {
if (!orms.has(inquilino)) {
orms.set(inquilino, await MikroORM.init({ ...base, dbName: `db_${inquilino}`,
pool: { min: 0, max: 2 } })); // pool mínimo: son N instancias
}
return orms.get(inquilino)!;
}schema: '*', opción schema por consulta y por contexto), pero los nombres exactos para fijar el esquema de un EntityManager forkeado y para dirigir el Migrator a un esquema concreto han cambiado entre versiones menores. Consulta la sección de multiple schemas de la documentación de tu versión 6 y cubre la decisión con un test que verifique que dos inquilinos no se ven entre sí.tenantId en todas las tablas, resolución de inquilino centralizada, filtros más comprobación explícita) y, cuando llegue el cliente grande que exige base de datos propia, muévelo a la estrategia A sin cambiar el modelo. Lo que no funciona es mezclar estrategias sin una capa que las abstraiga.17.14 Errores comunes y cómo solucionarlos
| Síntoma | Causa | Solución |
|---|---|---|
Parte del caso de uso se guarda aunque haya ROLLBACK | Se usó this.em (el EM externo) dentro del callback de em.transactional(). | Usar exclusivamente el em del parámetro; pasarlo como argumento explícito a los métodos auxiliares. |
Using global EntityManager instance methods for context specific actions is disallowed | Código sin contexto de petición: cron, consumidor de cola, script, escuchador de eventos. | @CreateRequestContext() en el método, o orm.em.fork() / RequestContext.create() explícitos. |
timeout acquiring a connection / la API se congela | Conexiones retenidas: begin() sin rollback en alguna rama, o transacciones que esperan una llamada de red. | em.transactional() en lugar de control manual; sacar la E/S externa de la transacción; idle_in_transaction_session_timeout; revisar pg_stat_activity. |
| Peticiones lentas y bloqueos en cascada al desplegar | Transacción por petición que abarca serialización, validación y llamadas a terceros. | Frontera transaccional en el caso de uso; excluir métodos seguros; vigilar la métrica de duración de transacción. |
deadlock detected (40P01 / 1213) esporádico | Dos rutas bloquean las mismas filas en orden distinto. | Ordenar los bloqueos por un criterio estable; transacciones cortas; reintento con retroceso exponencial. |
could not serialize access (40001) al subir el aislamiento | SERIALIZABLE o REPEATABLE READ en PostgreSQL sin política de reintentos. | Envolver la operación completa en un reintento idempotente, releyendo los datos en cada intento. |
current transaction is aborted | Se siguió usando la transacción después de un error del motor. | Abandonar esa transacción; el reintento debe abrir una nueva y partir de un Identity Map limpio. |
| Dos usuarios guardan y uno pierde su cambio sin aviso | Actualización perdida: leer, decidir en memoria y escribir sin control de concurrencia. | Columna de versión (@Property({ version: true })) y respuesta 409, o escritura atómica relativa con condición en el WHERE. |
Migración con DROP COLUMN inesperado | Se aceptó sin revisar el diff de migration:create tras renombrar una propiedad. | Revisar toda migración generada; safe: true y dropTables: false; expand/contract para renombrar. |
Migración sin down: imposible iterar o revertir | Se generó y nadie lo completó. | Exigir down en la revisión de código y probar up → down → up en CI. |
updateSchema borró datos en un entorno compartido | Uso del SchemaGenerator fuera de local y de test. | Prohibirlo por configuración y por revisión; migraciones versionadas y un usuario de aplicación sin permisos de DDL. |
| El esquema se migra N veces o falla con varias réplicas | Migraciones ejecutadas en el arranque de la aplicación. | Paso de despliegue único (Job o init container) con advisory lock. |
Un ALTER TABLE trivial deja la tabla inaccesible | El ALTER espera un bloqueo y encola tras él todas las consultas siguientes. | lock_timeout corto en las migraciones y reintento; índices con CONCURRENTLY. |
duplicate key value violates unique constraint al recuperar una cuenta borrada | Borrado lógico con restricción única global. | Índice único parcial (WHERE deleted_at IS NULL), columna generada en MySQL, o liberar el valor al borrar. |
| Las filas borradas lógicamente aparecen en un informe | El filtro no cubre SQL crudo, vistas ni herramientas externas. | Vistas de solo filas vivas, comprobación explícita en las consultas nativas y tests de aislamiento. |
| La auditoría o la caché no se actualizan en escrituras masivas | nativeUpdate, insertMany y el SQL crudo no disparan hooks ni suscriptores. | No dejar reglas importantes en los eventos; invalidar explícitamente tras la operación masiva, o usar triggers si debe capturarse todo. |
JavaScript heap out of memory en una importación | Identity Map e instantáneas del Unit of Work creciendo sin límite. | Procesar por lotes con em.clear(), em.fork() por unidad de trabajo y insertMany para las inserciones. |
| Arranque de la aplicación de varios segundos | Descubrimiento de entidades en cada arranque, agravado por TsMorphMetadataProvider. | Caché de metadatos generada en el build; ReflectMetadataProvider salvo motivo fuerte. |
Una entidad muestra valores antiguos tras un nativeUpdate | El Identity Map conserva la versión anterior: la operación nativa no lo actualiza. | em.refresh(entidad) o em.clear() después de la operación nativa. |
17.15 Buenas y malas prácticas
Haz esto
- Una transacción por caso de uso, abierta y cerrada en el servicio de aplicación, corta y sin E/S externa dentro.
- Usa siempre el
emdel callback y pásalo explícitamente a los métodos auxiliares. - Protege las invariantes en la base de datos: restricciones únicas,
CHECK, claves foráneas. El código se olvida; la restricción no. - Bloqueo optimista por defecto en entidades editables por personas; pesimista en las pocas filas calientes.
- Reintentos idempotentes para deadlocks y fallos de serialización, siempre por fuera de la transacción.
- Migraciones versionadas y revisadas, con
down, probadas en CI y conlock_timeout. - Expand/contract para cualquier cambio incompatible; el esquema debe soportar dos versiones del código a la vez.
- Migraciones en un paso de despliegue único con bloqueo, nunca en el arranque de la aplicación.
- Lotes y
em.clear()en cualquier proceso que recorra más de unos miles de filas. - Filtros globales como comodidad y comprobaciones explícitas como seguridad, con un test que intente cruzar la frontera entre inquilinos.
- Mide en producción: duración de consulta y de transacción, conexiones en uso, memoria.
Evita esto
schema:updateen cualquier entorno compartido. Es la vía más rápida a una pérdida de datos irreversible.- Dos
flush()creyendo que forman una unidad atómica. - Llamar a una API externa, generar un PDF o enviar un correo dentro de una transacción.
- Transacciones que esperan a un ser humano (bloqueo pesimista mientras un usuario rellena un formulario).
- Subir el nivel de aislamiento «por si acaso» sin implementar reintentos.
- Lógica de negocio en hooks de entidad: efectos invisibles que además se saltan en las operaciones nativas.
- Editar o renombrar una migración ya aplicada en cualquier entorno.
- Rellenos de datos de millones de filas dentro de una migración de esquema.
filters: falsepara «arreglar» una consulta que no encuentra un registro.- Confundir borrado lógico con supresión de datos personales.
- Subir
pool.maxpara tapar una fuga de conexiones. - Cachear resultados en lugar de arreglar la consulta y sus índices.
17.16 Preguntas frecuentes
¿Necesito em.transactional() si solo hago un flush()?
flush() ya se ejecuta dentro de su propia transacción, así que todos los cambios acumulados en el Unit of Work viajan juntos. em.transactional() hace falta cuando necesitas varios flush() en la misma unidad atómica, cuando quieres fijar el nivel de aislamiento, cuando hay lecturas con bloqueo pesimista (que exigen transacción abierta) o cuando el caso de uso debe abarcar también operaciones nativas.¿Por qué el callback de transactional recibe un em si ya tengo this.em?
BEGIN. Si se reutilizara el contexto externo, un ROLLBACK dejaría el Identity Map lleno de entidades con valores que ya no existen en la base de datos, y cualquier trabajo pendiente del contexto externo se colaría en la transacción. El fork es lo que hace que el rollback sea limpio.¿Bloqueo optimista o pesimista para el stock de un producto?
UPDATE stock = stock - ? WHERE id = ? AND stock >= ?, que resuelve el problema sin conflictos). Para una venta relámpago con miles de peticiones sobre la misma referencia, el bloqueo optimista degenera en un festival de reintentos: ahí es preferible pesimista, o sacar el reparto de unidades a una cola por producto para serializar el acceso sin bloquear la base de datos.¿Puedo devolver 409 y reintentar automáticamente en el servidor?
409 con el estado actual y deja decidir al usuario. Si viene de procesos automáticos que compiten por una fila, reintenta en el servidor con retroceso exponencial y jitter, releyendo la entidad en cada intento y limitando a tres o cuatro intentos.¿Es aceptable usar schema:update en preproducción?
¿Y si necesito revertir una migración en producción?
down puede recrear la estructura pero no los datos: por eso las contracciones se hacen días después, cuando ya no hay dudas. La estrategia real de recuperación no es «revertir el esquema», es «poder revertir el código sin tocar el esquema», que es exactamente lo que garantiza expand/contract.¿Cada migración en su transacción o todas en una?
transactional: true por migración es imprescindible en cuanto el motor lo soporte. allOrNothing: true añade una transacción que envuelve a todas las pendientes: es lo que quieres en un despliegue, porque un fallo en la tercera deja la base de datos exactamente como estaba. Ten en cuenta dos excepciones: las migraciones no transaccionales (índices CONCURRENTLY) quedan fuera de esa garantía, y en MySQL el DDL no es transaccional, así que allOrNothing no puede protegerte igual.¿Los seeders valen para producción?
INSERT ... ON CONFLICT DO NOTHING): así quedan versionados y no dependen de que alguien ejecute un comando. Para datos de ejemplo, jamás: un seeder de desarrollo ejecutado por error en producción puede crear usuarios con contraseñas conocidas.¿Un filtro global me protege contra fugas entre inquilinos?
¿Cómo consulto entidades borradas lógicamente sin desactivar el filtro en todas partes?
{ filters: { softDelete: false } }, y encapsúlalos en un repositorio con nombres explícitos (buscarIncluyendoBorrados). Lo que no debes hacer es desactivar el filtro de forma global «para simplificar»: en cuanto lo haces, el borrado lógico deja de existir en la práctica y las filas borradas empiezan a aparecer en la interfaz.¿El borrado lógico cumple con el derecho de supresión del RGPD?
¿Por qué mis hooks no se ejecutan en algunas escrituras?
nativeUpdate, nativeDelete, insertMany, el QueryBuilder de actualización y el SQL crudo van directas al servidor. Es intencionado (es lo que las hace rápidas) y es la razón principal para no colocar reglas de negocio en hooks. Si un comportamiento debe cumplirse siempre, su sitio es una restricción de la base de datos, un trigger o el propio caso de uso.¿Cuál es el tamaño correcto del pool de conexiones?
max mayor.¿Debo activar la caché de resultados de forma global?
¿Puedo tener transacciones distribuidas entre la base de datos y una cola?
17.17 Ejercicios
17.1 Escribe un caso de uso que cree un pedido con tres líneas y descuente stock, primero con dos flush() separados y después con em.transactional(). Provoca un error entre ambos flush() y comprueba en la base de datos qué queda en cada versión.
17.2 Añade @Property({ version: true }) a una entidad, activa el registro de SQL y describe exactamente qué cambia en el UPDATE generado. Después provoca un OptimisticLockError desde dos contextos (dos em.fork()) en el mismo test.
17.3 Crea una migración con migration:create tras añadir una propiedad, léela línea a línea y escribe el down si falta. Ejecuta up, down y up y comprueba la tabla mikro_orm_migrations en cada paso.
17.4 Implementa una transferencia entre cuentas con bloqueo pesimista y orden estable de bloqueo. Escribe un test que lance 50 transferencias cruzadas concurrentes (de A a B y de B a A) y verifique que la suma de los saldos no cambia y que no aparece ningún deadlock.
17.5 Monta una cola de trabajos en la base de datos con PESSIMISTIC_PARTIAL_WRITE. Arranca dos trabajadores en paralelo y demuestra con un test que ningún trabajo se procesa dos veces. Añade reintentos con disponibleEn y un límite de intentos.
17.6 Implementa el borrado lógico completo de Usuario: filtro por defecto, borrado, restauración, papelera, purga por retención e índice único parcial sobre el correo. Escribe el test que reproduce el error de la restricción única y demuestra que tu índice lo resuelve.
17.7 Escribe un suscriptor de auditoría que registre el diff de las entidades que elijas en la misma transacción del cambio, excluyendo campos sensibles. Demuestra con un test que un nativeUpdate no genera registro de auditoría y razona qué harías al respecto.
17.8 Ejecuta el ciclo completo de expand/contract para dividir nombreCompleto en nombre y apellidos: migración de expansión, código de doble escritura, script de relleno por lotes reanudable, cambio de lectura, migración de contracción con NOT NULL en dos fases. Documenta en qué momento exacto sería seguro revertir el código a la versión anterior.
17.9 Importa 500.000 filas de un CSV manteniendo la memoria por debajo de 300 MB. Compara tres variantes (un solo flush, lotes con flush + clear, y insertMany) midiendo tiempo total y memoria máxima. Explica los resultados.
17.10 Implementa multi-tenancy con columna discriminadora: filtro con argumentos, middleware de resolución, relleno automático del tenantId al crear y un test que intente leer datos de otro inquilino por todas las vías posibles (find, relación cargada, QueryBuilder, SQL crudo) y compruebe qué vías quedan expuestas.
17.11 Añade un endpoint de cobro idempotente con cabecera Idempotency-Key. Lanza la misma petición 20 veces en paralelo y demuestra que se crea un único cobro y que las 20 respuestas son idénticas.
17.12 Instrumenta la aplicación con suscriptores de transacción para medir la duración de cada transacción y el número de consultas por caso de uso. Publica las métricas y añade una alerta para transacciones de más de un segundo.
Solución comentada · 17.4 Transferencia con bloqueo
Las tres decisiones que hacen que este test pase: bloquear en orden estable, tener la comprobación de saldo después del bloqueo y no reutilizar el EM externo.
// src/cuentas/transferencias.service.ts
@Injectable()
export class TransferenciasService {
constructor(private readonly em: EntityManager) {}
async transferir(origenId: string, destinoId: string, importe: number): Promise<void> {
if (importe <= 0) throw new BadRequestException('Importe no positivo');
if (origenId === destinoId) throw new BadRequestException('Cuentas iguales');
await conReintentos(() =>
this.em.transactional(async (em) => {
// 1. ORDEN ESTABLE: ambas direcciones bloquean en el mismo orden.
// Sin esto, A→B y B→A forman un ciclo y el motor mata una.
const ids = [origenId, destinoId].sort();
// 2. Bloqueo secuencial, nunca con Promise.all: el orden importa
// y dos consultas en paralelo sobre la misma conexión no lo garantizan.
const cuentas = new Map<string, Cuenta>();
for (const id of ids) {
cuentas.set(id, await em.findOneOrFail(Cuenta, id,
{ lockMode: LockMode.PESSIMISTIC_WRITE }));
}
const origen = cuentas.get(origenId)!;
const destino = cuentas.get(destinoId)!;
// 3. La invariante se comprueba con el dato ya bloqueado:
// nadie puede cambiarlo entre la lectura y el UPDATE.
if (origen.saldo < importe) throw new SaldoInsuficienteError(origenId);
origen.saldo -= importe;
destino.saldo += importe;
em.create(Movimiento, { origen, destino, importe, fecha: new Date() });
}),
);
}
}
Y el test que de verdad demuestra algo: concurrencia real y comprobación de la invariante global.
it('conserva la suma de saldos con 50 transferencias cruzadas', async () => {
await sembrar({ A: 1_000, B: 1_000 });
// 25 en cada dirección, todas a la vez: sin orden estable, aquí saltan deadlocks.
await Promise.all(Array.from({ length: 50 }, (_, i) =>
i % 2 === 0 ? servicio.transferir('A', 'B', 10) : servicio.transferir('B', 'A', 10),
));
const em = orm.em.fork(); // contexto limpio: sin Identity Map viejo
const [a, b] = await Promise.all([
em.findOneOrFail(Cuenta, 'A'), em.findOneOrFail(Cuenta, 'B'),
]);
expect(a.saldo + b.saldo).toBe(2_000); // ninguna actualización perdida
expect(await em.count(Movimiento, {})).toBe(50);
});
Por qué no basta con la transacción. Si quitas lockMode, el test sigue pasando en PostgreSQL con importes fijos por una razón sutil: el UPDATE del ORM escribe el valor absoluto calculado en memoria, y en READ COMMITTED dos transacciones pueden leer el mismo saldo. Con suficiente concurrencia (sube a 500 operaciones) verás cómo la suma deja de cuadrar. Es la actualización perdida en estado puro.
Solución comentada · 17.8 Migración expand/contract
Paso 1 · expansión. Solo añade columnas nullables. Se puede aplicar con la versión antigua del código en marcha.
// src/migrations/Migration20260701090000_expand_nombre.ts
export class Migration20260701090000 extends Migration {
async up(): Promise<void> {
this.addSql(`set lock_timeout = '3s';`);
this.addSql(`alter table "cliente" add column "nombre" varchar(60) null;`);
this.addSql(`alter table "cliente" add column "apellidos" varchar(120) null;`);
}
async down(): Promise<void> {
this.addSql(`alter table "cliente" drop column "apellidos";`);
this.addSql(`alter table "cliente" drop column "nombre";`);
}
}
Paso 2 · doble escritura. El código nuevo escribe en los dos sitios y sigue leyendo el antiguo. Un único punto de escritura evita olvidos.
// src/clientes/cliente.entity.ts (versión de transición)
setNombreCompleto(valor: string): void {
this.nombreCompleto = valor; // formato antiguo (lo que se lee hoy)
const [nombre, ...resto] = valor.trim().split(/\s+/);
this.nombre = nombre; // formato nuevo (aún no se lee)
this.apellidos = resto.join(' ') || '-';
}
Paso 3 · relleno reanudable. Fuera de la migración, por lotes, idempotente y con pausa.
// Reanudable: filtra por "nombre is null", así que repetirlo no hace daño.
for (;;) {
const lote = await em.find(Cliente, { nombre: null }, { limit: 5_000, orderBy: { id: 'asc' } });
if (!lote.length) break;
for (const c of lote) c.setNombreCompleto(c.nombreCompleto);
await em.flush();
em.clear();
await new Promise((r) => setTimeout(r, 100));
}
Pasos 4 y 5 · cambio de lectura y fin de la escritura antigua. Dos despliegues separados. Mientras el código siga escribiendo en nombre_completo, revertir es gratis: los datos antiguos están al día. Ese es el último momento seguro para revertir sin pérdida, y responde a la pregunta del enunciado.
Paso 6 · contracción. Días después, con métricas que confirmen que nadie lee la columna antigua.
// src/migrations/Migration20260715090000_contract_nombre.ts
export class Migration20260715090000 extends Migration {
async up(): Promise<void> {
this.addSql(`set lock_timeout = '3s';`);
// NOT NULL en dos fases: validar sin bloquear lecturas y después marcar.
this.addSql(`alter table "cliente" add constraint "cliente_nombre_nn"
check ("nombre" is not null) not valid;`);
this.addSql(`alter table "cliente" validate constraint "cliente_nombre_nn";`);
this.addSql(`alter table "cliente" alter column "nombre" set not null;`);
this.addSql(`alter table "cliente" drop constraint "cliente_nombre_nn";`);
this.addSql(`alter table "cliente" drop column "nombre_completo";`);
}
async down(): Promise<void> {
// Reversible en estructura, NO en datos: se documenta explícitamente.
this.addSql(`alter table "cliente" add column "nombre_completo" varchar(180) null;`);
this.addSql(`update "cliente" set "nombre_completo" = "nombre" || ' ' || "apellidos";`);
this.addSql(`alter table "cliente" alter column "nombre" drop not null;`);
}
}Solución comentada · 17.6 Soft delete completo
La entidad, con el filtro por defecto y sin unique en el correo: la unicidad la aporta un índice parcial creado por la migración.
// src/entities/usuario.entity.ts
@Entity()
@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })
export class Usuario {
@PrimaryKey() id: string = randomUUID();
@Property() email!: string; // unicidad solo entre los NO borrados
@Property() nombre!: string;
@Property({ nullable: true, index: true }) deletedAt?: Date | null;
@Property({ nullable: true }) deletedBy?: string | null;
}
// src/migrations/Migration20260710090000_email_unico_activos.ts
async up(): Promise<void> {
// 1. Detecta duplicados preexistentes ANTES de crear el índice:
// si hay dos usuarios activos con el mismo correo, la creación fallará.
this.addSql(`create unique index "usuario_email_activo_unq"
on "usuario" ("email") where "deleted_at" is null;`);
}
async down(): Promise<void> {
this.addSql(`drop index "usuario_email_activo_unq";`);
}
El repositorio encapsula las consultas que necesitan ver los borrados, para que el resto de la aplicación no toque nunca la opción filters.
// src/usuarios/usuarios.repository.ts
async borrar(id: string, autor: string): Promise<void> {
await this.em.transactional(async (em) => {
const u = await em.findOneOrFail(Usuario, id);
u.deletedAt = new Date();
u.deletedBy = autor;
// Cascada LÓGICA: las claves foráneas no se enteran de un borrado lógico.
await em.nativeUpdate(ApiToken, { usuario: id }, { deletedAt: u.deletedAt });
});
}
async restaurar(id: string): Promise<void> {
await this.em.transactional(async (em) => {
const u = await em.findOneOrFail(Usuario, id, { filters: { softDelete: false } });
// Antes de restaurar, comprobar que el correo sigue libre: otro usuario
// pudo registrarse con él mientras este estaba borrado.
if (await em.count(Usuario, { email: u.email, id: { $ne: id } })) {
throw new ConflictException('El correo ya está en uso');
}
u.deletedAt = null;
u.deletedBy = null;
});
}
buscarIncluyendoBorrados(where: FilterQuery<Usuario>): Promise<Usuario[]> {
return this.em.find(Usuario, where, { filters: { softDelete: false } });
}
El test que reproduce el error clásico y demuestra la corrección:
it('permite volver a registrar un correo cuyo usuario fue borrado', async () => {
const ana = await repo.crear({ email: 'ana@ejemplo.com', nombre: 'Ana' });
await repo.borrar(ana.id, 'admin');
orm.em.clear();
// Sin el índice parcial: duplicate key value violates unique constraint
const nueva = await repo.crear({ email: 'ana@ejemplo.com', nombre: 'Ana 2' });
expect(nueva.id).not.toBe(ana.id);
// Y la restauración ahora debe fallar de forma controlada, no con un 500.
await expect(repo.restaurar(ana.id)).rejects.toThrow(ConflictException);
});
Detalle que se olvida siempre: la restauración puede ser imposible. Si liberas el correo al borrar y otra persona lo reutiliza, restaurar crearía un duplicado. Decide la política (rechazar, pedir un correo nuevo, fusionar cuentas) y escríbela en un test, porque de lo contrario aparecerá en producción como un error 500 sin explicación.
17.18 Resumen del capítulo
- El
flush()ya es atómico, pero la unidad atómica la decides tú al elegir dónde ponerlo. Dosflush()no forman una transacción. em.transactional()forkea el EntityManager. Usa elemdel parámetro; el externo vive en otro contexto y sobrevive al rollback. Es el error número uno del capítulo.- La frontera transaccional va en el caso de uso, corta y sin llamadas externas. Una transacción por petición HTTP es cómoda y peligrosa a la vez: solo con métodos seguros excluidos y disciplina sobre la E/S.
- Los niveles de aislamiento se eligen por la anomalía que hay que evitar, no por costumbre. PostgreSQL usa
READ COMMITTEDpor defecto; MySQL,REPEATABLE READ. Subir el nivel obliga a reintentar. - La actualización perdida es el problema real de la vida diaria. Se resuelve con escritura atómica relativa, bloqueo optimista (versión y
409) o bloqueo pesimista (FOR UPDATE), y cada uno tiene su terreno. SKIP LOCKEDconvierte una tabla en una cola correcta y transaccional, útil hasta un volumen respetable.- Los deadlocks son esperables: orden estable de bloqueo, transacciones cortas y reintento idempotente.
updateSchemajamás en producción. Migraciones versionadas, revisadas, condownprobado, conlock_timeouty ejecutadas en un paso de despliegue único con bloqueo.- Expand/contract es el único patrón que permite desplegar sin cortes: el esquema debe ser compatible con dos versiones del código a la vez, y las contracciones se hacen días después.
- Las migraciones de datos no son migraciones de esquema: por lotes, idempotentes, reanudables y fuera del pipeline de despliegue cuando son grandes.
- Los filtros globales son comodidad, no seguridad. Defensa en capas para multi-inquilino y para el borrado lógico.
- El borrado lógico rompe las restricciones únicas (índice parcial), no dispara cascadas de claves foráneas y no equivale a suprimir datos personales.
- Los eventos sirven para lo transversal (auditoría, normalización, métricas) y nunca para la lógica de negocio: no se disparan en las operaciones nativas y esconden efectos.
- El rendimiento del ORM se reduce a cuatro palancas: no cargar lo que no necesitas, escribir por lotes, limpiar el Identity Map en procesos largos y dimensionar el pool con aritmética.
- Multi-tenancy: columna discriminadora por defecto; esquema o base de datos por inquilino cuando lo exija el aislamiento, sabiendo que el coste operativo pasa a crecer con cada cliente.
17.19 Recursos adicionales
- MikroORM · Transactions and concurrency — referencia de
transactional, niveles de aislamiento,LockModey bloqueo optimista. - MikroORM · Migrations — configuración del
Migrator, comandos y opciones de las migraciones. - MikroORM · Schema generator — qué hace exactamente cada operación y sus límites.
- MikroORM · Seeding —
Seeder,Factorye integración con faker. - MikroORM · Filters — filtros de entidad y globales, parámetros y activación por consulta.
- MikroORM · Events and hooks — lista completa de eventos y qué es seguro hacer en cada uno.
- MikroORM · Caching — caché de resultados, adaptadores y claves.
- MikroORM · Multiple schemas — base para las estrategias de multi-tenancy por esquema.
- MikroORM · Usage with NestJS — contexto de petición,
@CreateRequestContexte inyección del EntityManager. - PostgreSQL · Transaction isolation — la explicación canónica de las anomalías y de los niveles reales.
- PostgreSQL · Explicit locking —
FOR UPDATE,SKIP LOCKED, bloqueos de tabla y advisory locks. - MySQL · InnoDB transaction isolation levels — diferencias de implementación frente a PostgreSQL.
- Martin Fowler · Parallel Change — la formulación original del patrón expand/contract.
COMMIT.