ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Unicidad cuando hay borrado blando

template-api
último cambio 2026-09-25

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 í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.