Unicidad cuando hay borrado blando
Cerrar una cuenta deja filas con deletedAt: existen, no
se ven y sostienen el historial. Eso convive mal con una restricción que se escribió pensando en
filas vivas.
Un índice único no sabe de borrados blandos. Cuenta la fila muerta igual que la viva, así que un alta perfectamente legítima choca contra algo que el usuario ya no puede ver.
En petid-api pasó con dos columnas a la vez:
- el microchip de una mascota: la mascota borrada conserva su chip en el historial, y el mismo animal no podía darse de alta en otra clínica;
- el nombre de un tenant: un tenant dado de baja conserva su nombre como autoría de lo que dejó, y otro no podía llamarse igual.
El índice parcial
La restricción que se quería decir no era “único”, sino “único entre los vivos”:
CREATE UNIQUE INDEX "pets_microchipCode_active_key"
ON "pets"("microchipCode") WHERE "deletedAt" IS NULL;
Prisma no sabe expresar ese WHERE, así que el índice vive en una migración escrita a mano y el
campo pierde su @unique. Queda un hueco de conocimiento entre el schema y la base, y se tapa con
un comentario en el modelo que nombra la migración — es el mismo trato que ya tenía “una chapa
activa por mascota”.
La trampa: el índice compuesto
La respuesta que parece natural —añadir la columna al índice— no funciona:
-- No hace lo que parece
CREATE UNIQUE INDEX … ON "pets"("microchipCode", "deletedAt");
En Postgres dos NULL nunca son iguales dentro de un índice único. Dos mascotas vivas tienen las
dos deletedAt = NULL, así que el par es distinto para el índice y las deja pasar: se pierde
justo la protección que se buscaba, y encima parece que está.
NULLS NOT DISTINCT tampoco sirve aquí si la columna es opcional: haría colisionar entre sí a todas
las mascotas sin chip.
Lo que deja de funcionar
El upsert. Prisma necesita una clave única que conozca, y un índice parcial no lo es. Se
sustituye por buscar y crear:
const tenant =
(await prisma.tenant.findFirst({ where: { slug, deletedAt: null } })) ??
(await prisma.tenant.create({ data: { … } }));
El fallo que se ve raro
Este es el orden que engaña: la consulta de la aplicación ya filtraba por deletedAt, mientras
el índice seguía siendo global. Entonces el alta pasa la comprobación —“no hay ninguna clínica que se
llame así”— y revienta en el INSERT, con un error de Prisma en crudo que sube como 500 en vez del
error de dominio que le corresponde.
La regla que evita todo el episodio: el índice y la consulta que comprueba el duplicado tienen que filtrar lo mismo. Si una mira solo las filas vivas, la otra también.
Qué probar
Con SQL, no con la aplicación: es la base quien tiene que decir que no.
- Dos filas vivas con el mismo valor → falla.
- Una viva y otra borrada con el mismo valor → pasa.
- Varias filas con la columna a
NULL→ pasan todas.