Ir al contenido

Sesión 2 — Prisma avanzado: relaciones, queries, paginación y transacciones

Bloque: ORMs, APIs REST y Seguridad Fecha: Miércoles 25 de noviembre · 15:30–19:30 (4h) Stack: Node.js ≥ 24, TypeScript, Prisma 7, PostgreSQL, Docker Continúa de: Sesión 1 — Fundamentos de ORMs + Prisma


Al terminar, el alumno debe ser capaz de:

  1. Modelar una relación N:M explícita (Enrollment) y decidir cuándo una N:M necesita modelo propio.
  2. Leer relaciones con include, select y _count, sabiendo qué datos trae cada uno.
  3. Paginar con offset y con cursor, y elegir cuál usar.
  4. Usar una transacción ($transaction: todo o nada), explicar por qué no evita que se sobrevenda un aforo y cómo lo arregla Serializable + reintento.
  5. Medir un N+1 contando queries y arreglarlo en código generado por IA.

Trabajas en tu clon de academia-api, en tu propia rama. Sigue en tu rama. Si los checks del inicio de la sesión fallan o vas tarde, usa la receta “Cambiar de rama”. Checks del inicio de hoy: npm run seed dos veces seguidas sin error y npm run typecheck sin errores.

El repo trae dos ramas de rescate para hoy:

  • s2-start = final de la sesión 1. Para quien no pasa los checks del inicio, al empezar.
  • s2-checkpoint = todo lo que hacemos en clase hasta la práctica, incluidos los scripts de src/playground/ y el servicio de inscripciones. Para quien se ha quedado atrás, antes de la práctica final (§8). Quien va al día trae de ahí solo unos ficheros.

Cambiar de rama (la misma receta en todas las sesiones):

📋 S2·01 · receta «Cambiar de rama» a s2-start (terminal)
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s2 origin/s2-start # checkpoint: mi-s2-cp origin/s2-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed

Si vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp que las del repo). Y al final, siempre: npm run seed (el script tsx src/seed-manual.ts que creaste en la sesión 1; en s2-start ya existe).

Windows: todo en Git Bash (no PowerShell).

Bloques para copiar: web del curso → Sesión 2 → Chuleta. Cada bloque lleva un ID (📋 S2·05) que verás en la slide; lo que no lleva ID (el schema de Enrollment, las queries que enseñan la idea) se teclea a mano.

En un clon las ramas del curso solo existen como remotas: por eso origin/ (sin él, git responde 's2-start' is not a commit). Traer un solo fichero: git checkout origin/s2-checkpoint -- ruta/del/fichero. Para el checkpoint, en §8 tienes el bloque exacto.


Lo explicáis vosotros, en voz alta: ¿qué es un ORM? y ¿qué hace prisma migrate dev… y qué ya no hace en Prisma 7? Si no tienes el proyecto funcionando, la receta «Cambiar de rama» (📋 S2·01) ahora, no a mitad de sesión.


2. Relaciones: cuándo una N:M necesita modelo propio

Sección titulada «2. Relaciones: cuándo una N:M necesita modelo propio»

La 1:N ya la conocemos (Course.instructor). Hoy añadimos otra (Category → Course) y, sobre todo, una N:M: un alumno se inscribe en muchos cursos y un curso tiene muchos alumnos.

De pasada, las otras dos formas que existen:

  • 1:1: es una 1:N con @unique en la FK (userId Int @unique en un hipotético Profile). Sin el @unique, un User podría tener varios Profile. No la implementamos.
  • N:M implícita: pones Course[] en User y User[] en Course, y Prisma gestiona una tabla intermedia que no ves. Válida solo si la relación no necesita datos propios.
  • N:M explícita: defines tú la tabla intermedia como modelo (Enrollment). Es la que vamos a usar.

Para pensar: “¿Cuándo NO nos sirve la forma implícita para inscripciones?”


3. Code-along: Category, Enrollment, migración y seed

Sección titulada «3. Code-along: Category, Enrollment, migración y seed»

Lo que enseña la idea se teclea a mano a la vez que yo: el modelo Enrollment (§3.2) y los lados inversos. Lo que es patrón ya visto o andamiaje se copia de la web: Category → Course (📋 S2·02), el enum (📋 S2·03), los comandos y el seed entero (📋 S2·05), que copiáis en src/seed-manual.ts y leemos juntos.

En la sesión 1 Category existía suelta; ahora la conectamos. Es el mismo patrón que instructor de la sesión 1: se copia.

📋 S2·02 · Category → Course (prisma/schema.prisma)
model Course {
// ...campos de la sesión 1 (title, description, price, published, level, createdAt)
instructorId Int
instructor User @relation(fields: [instructorId], references: [id])
categoryId Int?
category Category? @relation(fields: [categoryId], references: [id])
}
model Category {
id Int @id @default(autoincrement())
name String @unique
courses Course[]
}

categoryId es opcional (Int?) — un curso puede no tener categoría todavía. Es una decisión de negocio, no una limitación técnica. courses Course[] es el lado inverso: no crea ninguna columna, Prisma lo exige para poder navegar la relación en los dos sentidos.

✍️ A mano: el modelo Enrollment, con sus dos FK y el @@unique. Va en prisma/schema.prisma:

model Enrollment {
id Int @id @default(autoincrement())
userId Int
courseId Int
status EnrollmentStatus @default(ACTIVE)
grade Int?
enrolledAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
course Course @relation(fields: [courseId], references: [id])
@@unique([userId, courseId])
}

El enum de estados se copia:

📋 S2·03 · enum EnrollmentStatus (prisma/schema.prisma)
enum EnrollmentStatus {
ACTIVE
COMPLETED
CANCELLED
}

✍️ A mano: en User y Course, el lado inverso, más el aforo:

model User {
// ...campos existentes
coursesTaught Course[]
enrollments Enrollment[]
}
model Course {
// ...campos existentes
maxSeats Int @default(20)
enrollments Enrollment[]
}
  • @@unique([userId, courseId]): un mismo usuario no puede inscribirse dos veces en el mismo curso. Es una regla de negocio aplicada en la base de datos, no solo en el código.
  • maxSeats: lo usaremos en §6 para controlar el aforo.
  • grade es opcional (Int?) porque no existe hasta que el curso se completa.

📋 S2·04 · migrar y generar el cliente (terminal)
npx prisma migrate dev --name add_enrollments
npx prisma generate

El generate es obligatorio: en Prisma 7 migrate dev ya no regenera el cliente. Si lo olvidas, TypeScript dirá que prisma.enrollment no existe.

Abrid el SQL generado (prisma/migrations/<fecha>_add_enrollments/migration.sql). Lo relevante:

-- CreateIndex
CREATE UNIQUE INDEX "Enrollment_userId_courseId_key" ON "Enrollment"("userId", "courseId");
-- AddForeignKey
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

Tres cosas que leer en voz alta:

  • Son dos FK simples, una por columna. No hay ninguna “FK compuesta”.
  • @@unique se traduce en un índice único (CREATE UNIQUE INDEX), no en un ALTER TABLE … UNIQUE. El nombre Enrollment_userId_courseId_key es el que verás en los errores.
  • ON DELETE RESTRICT: no se puede borrar un curso con inscripciones. Lo vamos a sufrir en el seed (y en la sesión 3, al hacer DELETE /courses/:id).

src/seed-manual.ts se puede ejecutar tantas veces como quieras: usuarios y categorías con upsert (si existe no lo toca, si no lo crea), y los cursos de Marta se resetean (primero sus inscripciones, por el RESTRICT). Copia 📋 S2·05 de la web y sustituye entero el de la sesión 1; es este:

📋 S2·05 · src/seed-manual.ts completo (sustituye el de la S1)
// src/seed-manual.ts — sustituye entero el de la S1
import { prisma } from './db.js';
const PLACEHOLDER = 'hashed-password-placeholder'; // sesión 4: Argon2id
async function upsertUser(email: string, name: string,
role: 'STUDENT' | 'INSTRUCTOR' | 'ADMIN') {
return prisma.user.upsert({
where: { email },
update: {},
create: { email, name, password: PLACEHOLDER, role },
});
}
async function main() {
const marta = await upsertUser('marta@academia.dev', 'Marta', 'INSTRUCTOR');
const students = await Promise.all([
upsertUser('luis@academia.dev', 'Luis', 'STUDENT'),
upsertUser('sara@academia.dev', 'Sara', 'STUDENT'),
upsertUser('omar@academia.dev', 'Omar', 'STUDENT'),
]);
const [backend, databases] = await Promise.all(
['Backend', 'Bases de datos'].map((name) =>
prisma.category.upsert({ where: { name }, update: {}, create: { name } }),
),
);
// Course no tiene campo único aparte del id:
// reseteamos los cursos de Marta (primero sus inscripciones, por el RESTRICT)
await prisma.enrollment.deleteMany({ where: { course: { instructorId: marta.id } } });
await prisma.course.deleteMany({ where: { instructorId: marta.id } });
// create, no createMany: necesitamos el id de cada curso
const ts = await prisma.course.create({ data: { title: 'TypeScript desde cero', price: 49.99,
published: true, level: 'BEGINNER', instructorId: marta.id, categoryId: backend.id } });
const prismaAdv = await prisma.course.create({ data: { title: 'Prisma avanzado', price: 79.99,
published: true, level: 'ADVANCED', instructorId: marta.id, categoryId: databases.id } });
await prisma.course.create({ data: { title: 'SQL para backend', price: 39.99,
published: false, level: 'INTERMEDIATE', instructorId: marta.id, categoryId: databases.id } });
// Inscripciones cubriendo los 3 estados (cada par usuario-curso es único)
await prisma.enrollment.createMany({
data: [
{ userId: students[0].id, courseId: ts.id, status: 'ACTIVE' },
{ userId: students[1].id, courseId: ts.id, status: 'COMPLETED', grade: 9 },
{ userId: students[2].id, courseId: prismaAdv.id, status: 'CANCELLED' },
],
});
console.log('Seed OK:', await prisma.course.count(), 'cursos,',
await prisma.enrollment.count(), 'inscripciones');
} // fin de main()
main().finally(() => prisma.$disconnect());

Tres detalles de TypeScript y de Prisma:

  • 'STUDENT' | 'INSTRUCTOR' | 'ADMIN' es un tipo unión: el parámetro solo acepta uno de esos tres textos.
  • Promise.all lanza todas las promesas a la vez y devuelve los resultados en el mismo orden: students[0] es Luis. [backend, databases] desestructura el array de dos categorías.
  • En la sesión 1 los cursos se creaban con createMany, que solo devuelve { count }. Aquí hace falta el id de cada curso para las inscripciones, así que se usa create uno a uno. Y cada curso lleva su categoryId: sin él, el filtro por Backend de §4.4 devuelve [].

📋 S2·06 · el seed dos veces (terminal)
npm run seed # Seed OK: 3 cursos, 3 inscripciones
npm run seed # Seed OK: 3 cursos, 3 inscripciones (otra vez, sin errores)

npm run seed = tsx src/seed-manual.ts, el script que creaste en la sesión 1. Las cifras cuentan con que tu seed de la sesión 1 usó a la instructora marta@academia.dev (como pedía la práctica): este seed solo resetea los cursos de Marta.


Todo en src/playground/queries.ts (npx tsx src/playground/queries.ts). Todas las queries de esta sección van dentro de main().

El esqueleto del fichero se copia:

📋 S2·07 · esqueleto de src/playground/queries.ts
// src/playground/queries.ts
import { prisma } from '../db.js';
async function main() {
// ✍️ a mano: las queries de esta sección, aquí dentro
}
main().finally(() => prisma.$disconnect());

✍️ A mano, dentro de main():

const withEnrollments = await prisma.course.findFirst({
include: { enrollments: { include: { user: true } } },
});
console.log('include →', withEnrollments?.title,
withEnrollments?.enrollments.map((e) => e.user.name));

Salida: include → TypeScript desde cero [ 'Luis', 'Sara' ].

✍️ A mano (y lo mismo en §4.3 y §4.4: son las queries que enseñan la idea):

const titles = await prisma.course.findMany({
select: { title: true, instructor: { select: { name: true } } },
});

4.3 _count — contar relaciones sin traerlas

Sección titulada «4.3 _count — contar relaciones sin traerlas»
const counts = await prisma.course.findMany({
select: { title: true, _count: { select: { enrollments: true } } },
});

Para dashboards (“cuántos inscritos tiene cada curso”) sin traer todas las filas de Enrollment y contarlas en memoria. Ojo: cuenta todas, también las CANCELLED; para el aforo (§6) contaremos solo las ACTIVE.

4.4 Filtrar por una relación y escribir con connect

Sección titulada «4.4 Filtrar por una relación y escribir con connect»

Filtrar por campos de una relación es anidar el where:

const inBackend = await prisma.course.findMany({ where: { category: { name: 'Backend' } } });
console.log('filtro →', inBackend.map((c) => c.title)); // filtro → [ 'TypeScript desde cero' ]

Si te sale [], tu seed no asigna categoryId a los cursos (§3.4).

(Para relaciones a-muchos existen some, every y none, por ejemplo usuarios con alguna inscripción COMPLETED. No los necesitamos hoy.)

Y para inscribir a Omar en “SQL para backend”, dos registros que ya existen, connect los relaciona:

// sigue en main() de src/playground/queries.ts
const omar = await prisma.user.findUniqueOrThrow({ where: { email: 'omar@academia.dev' } });
const sql = await prisma.course.findFirstOrThrow({ where: { title: 'SQL para backend' } });
const enrollment = await prisma.enrollment.upsert({
where: { userId_courseId: { userId: omar.id, courseId: sql.id } },
update: {},
create: { user: { connect: { id: omar.id } }, course: { connect: { id: sql.id } } },
});
console.log('connect →', enrollment);

findUniqueOrThrow busca por un campo @unique (el email) y findFirstOrThrow por cualquiera (el título no es único: devuelve el primero). Las dos lanzan un error si no hay fila, en vez de devolver null.

userId_courseId es la clave que Prisma genera a partir de @@unique([userId, courseId]): el índice único sirve también para buscar.

En src/playground/queries.ts:

  1. Escribe y ejecuta las queries de §4.1–§4.4 (include, select, _count, el filtro y el upsert).
  2. Cambia el upsert por un prisma.enrollment.create({ data: { user: { connect: … }, course: { connect: … } } }) y ejecuta el script.
  3. Vuelve a poner el upsert.

Criterio de “hecho”: el script imprime las cuatro queries y el filtro da [ 'TypeScript desde cero' ]; en el paso 2, la ejecución con create revienta con PrismaClientKnownRequestError P2002 y en meta aparece Enrollment_userId_courseId_key (el índice que vimos en el SQL). Como el upsert ya inscribió a Omar, revienta a la primera (si acabas de ejecutar npm run seed, a la segunda).


Se copia (la idea se ve sin teclearla); va dentro de main() de queries.ts:

📋 S2·08 · paginación offset (dentro de main en queries.ts)
const page = 1, pageSize = 2;
const offsetPage = await prisma.course.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});

El segundo criterio (id) es el desempate: el seed crea varios cursos casi en el mismo instante; si dos tienen el mismo createdAt, sin desempate PostgreSQL puede devolverlos en cualquier orden y un curso aparecer en dos páginas (o en ninguna).

La primera página va sin cursor; las siguientes, desde el último id visto. ✍️ A mano:

const first = await prisma.course.findMany({ take: 2, orderBy: { id: 'asc' } });
const next = await prisma.course.findMany({
take: 2,
skip: 1, // saltar el propio cursor
cursor: { id: first.at(-1)!.id },
orderBy: { id: 'asc' },
});
console.log('cursor →', first.map((c) => c.id), '→', next.map((c) => c.id));

first.at(-1)!: at(-1) es el último elemento, que para TypeScript puede ser undefined; el ! le dice «no lo es, confía en mí».

Offset Cursor
Pros Simple, permite “ir a la página 5” Estable ante inserciones/borrados concurrentes; usa el índice de la clave
Contras Page drift (duplicados o saltos si cambian filas mientras paginas); un skip grande recorre y descarta filas No puedes saltar a la página 5, solo avanzar
Para Paneles de administración con paginación numerada, si la tabla no es gigante Scroll infinito y feeds

En queries.ts:

  1. Con offset, imprime los ids de la página 1 y de la página 2 (pageSize = 2).
  2. Con cursor, imprime first y next.
  3. Quita el skip: 1 y vuelve a ejecutar.

Criterio de “hecho”: en 1 y 2, ningún id se repite entre páginas y entre las dos salen los 3 cursos del seed; en 3, el último id de first aparece también en next.


Tres ideas nuevas en esta sección, y ninguna más: transacción (todo o nada), aislamiento y Serializable + reintento. Nadie cambia de rama aquí.

Omar se cambia de curso: hay que dar de baja su inscripción en “SQL para backend” y crear otra en “TypeScript desde cero”. Si la baja se guarda y el alta falla, Omar se queda sin ninguno.

Una transacción agrupa varias operaciones para que se hagan todas o ninguna: si una falla, la base deshace las anteriores. A eso se le llama atomicidad. En Prisma:

// ejemplo: solo se lee, no hace falta teclearlo
await prisma.$transaction(async (tx) => {
await tx.enrollment.update({
where: { userId_courseId: { userId: omar.id, courseId: sql.id } },
data: { status: 'CANCELLED' },
});
await tx.enrollment.create({
data: { userId: omar.id, courseId: ts.id },
});
}); // si el create lanza un error, el update se deshace
  • Dentro del callback, tx, no prisma: tx es la base dentro de esta transacción y tiene los mismos modelos (tx.enrollment, tx.category…). Lo que hagas con prisma va por fuera y no se deshace.
  • Si la función termina bien, se guarda todo; si lanza un error, no se guarda nada (y el error te llega a ti).

Copia el andamiaje al final de main() en src/playground/queries.ts (usa el prisma que ya importa el fichero; no hay imports nuevos):

📋 S2·09 · andamiaje del todo o nada (al final de main en queries.ts)
// añade al final de main() en src/playground/queries.ts
const fail = true; // paso 2: cámbialo a false
try {
await prisma.$transaction(async (tx) => {
// ✍️ a mano: dos tx.category.create y el throw
});
} catch (err) {
console.log('tx →', (err as Error).message);
}
const names = ['Frontend', 'DevOps'];
console.log('guardadas →', await prisma.category.count({ where: { name: { in: names } } }));
await prisma.category.deleteMany({ where: { name: { in: names } } }); // deja la base como estaba

✍️ A mano, dentro del callback, en el sitio del comentario:

await tx.category.create({ data: { name: 'Frontend' } });
await tx.category.create({ data: { name: 'DevOps' } });
if (fail) throw new Error('falla a mitad');
  1. Ejecuta npx tsx src/playground/queries.ts con fail = true.
  2. Cambia a fail = false y vuelve a ejecutar.
  3. Extra: con fail = true, cambia el primer tx.category por prisma.category. ¿Cuántas se guardan?

Salida (las dos últimas líneas):

tx → falla a mitad ← con fail = true
guardadas → 0
guardadas → 2 ← con fail = false

Criterio de “hecho”: con fail = true sale guardadas → 0 (el error deshizo las dos) y con fail = false, guardadas → 2.

Copia 📋 S2·10 de la web y guárdalo como src/playground/s2-listado-y-aforo.ia.ts. Es código “generado por un asistente de IA”:

📋 S2·10 · src/playground/s2-listado-y-aforo.ia.ts (código de IA)
// ─────────────────────────────────────────────────────────────────────────
// Generado por un asistente de IA · Sesión 2
// Prompt 1: "Lista los estudiantes con cuántas inscripciones tiene cada uno"
// Prompt 2: "Función para inscribir a un alumno respetando el aforo maxSeats"
// Tu tarea: (1) activa log de queries y cuenta cuántas lanza listStudents.
// (2) lee enrollStudent: ¿qué pasa si dos alumnos se inscriben a la vez?
// (lo comprobaréis con la demo src/playground/race.ts).
// ─────────────────────────────────────────────────────────────────────────
import { prisma } from '../db.js';
export async function listStudents() {
const students = await prisma.user.findMany({ where: { role: 'STUDENT' } });
const result = [];
for (const s of students) {
const enrollments = await prisma.enrollment.findMany({ where: { userId: s.id } });
result.push({ name: s.name, enrollments: enrollments.length });
}
return result;
}
export async function enrollStudent(userId: number, courseId: number) {
const course = await prisma.course.findUnique({ where: { id: courseId } });
const count = await prisma.enrollment.count({ where: { courseId } });
if (course && count < course.maxSeats) {
return prisma.enrollment.create({ data: { userId, courseId } });
}
throw new Error('Course full');
}

Compila, funciona con un usuario y parece razonable. enrollStudent la analizamos ahora, en papel (no se ejecuta); listStudents es la práctica final.

“Curso con maxSeats: 1. Los alumnos A y B se inscriben a la vez. ¿Cuántos quedan inscritos?” Rellenad una línea de tiempo de dos columnas, intercalando A y B paso a paso:

Alumno A Alumno B
1 · leer cuántos hay ? ?
2 · comprobar si cabe ? ?
3 · insertar ? ?

Y después: ¿qué más falla en enrollStudent?

¿Y si lo meto en $transaction? No basta. La transacción da atomicidad (todo o nada), y aquí nada falla: las dos inserciones terminan bien. Lo que falta es aislamiento: qué ve una transacción de lo que hace otra que aún no ha terminado. Con el nivel por defecto de Postgres (READ COMMITTED), A no ve el alta de B hasta que B termina: las dos leen 0, aunque cada una vaya en su propia transacción. Y @@unique tampoco ayuda: son dos alumnos distintos.

6.4 La carrera, en el proyector, y la solución

Sección titulada «6.4 La carrera, en el proyector, y la solución»

El profesor lo ejecuta en su máquina; tú solo miras (no hace falta cambiar de rama):

Ventana de terminal
npx tsx src/playground/race.ts
Ingenua : inscrito | inscrito → 2 inscritos / 1 plaza
Serializable: inscrito | rechazado (Curso completo) → 1 inscritos / 1 plaza

(El orden de inscrito/rechazado en la segunda línea puede variar.) “Ingenua” es la de la IA metida en $transaction (y contando solo las ACTIVE): sobrevende igual. La segunda es la solución, que vive en src/services/enrollments.service.ts y se lee, no se teclea:

// src/services/enrollments.service.ts · enrollStudent (resumida)
for (let attempt = 1; ; attempt++) {
try {
return await prisma.$transaction(async (tx) => { /* leer, comprobar, insertar */ },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
} catch (err) {
if (isSerializationFailure(err) && attempt < 3) continue; // otra vez
throw err;
}
}
  • Con Serializable, Postgres detecta que las dos transacciones se pisan y aborta una.
  • La repetimos: al repetirse ya ve la plaza ocupada → “Curso completo”.
  • El error llega de dos formas; isSerializationFailure mira las dos.

El servicio completo (con enrollStudentNaive, isSerializationFailure y src/errors.ts: ConflictError = 409, NotFoundError = 404) llega a tu rama en el punto de control de §8, porque la sesión 3 lo usa.


Volvemos a listStudents. Para saber cuántas queries lanza no hay que adivinar: se cuentan, con el logging por eventos de Prisma. Es lo que hace src/playground/n-plus-one.ts (está en el checkpoint, solo con la versión N+1: ❌ N+1 (query por alumno): 4 queries). En la práctica lo montas tú; el cliente que cuenta es instrumentación, se copia:

📋 S2·11 · cliente que cuenta queries (inicio de src/playground/list-students.ts)
// src/playground/list-students.ts
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../../generated/prisma/client.js'; // dos ../
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
log: [{ emit: 'event', level: 'query' }],
});
let queries = 0;
prisma.$on('query', () => { queries++; });

Desde src/playground el cliente generado está dos niveles arriba (../../generated/…, con .js). Es un cliente propio: el prisma de db.ts no cuenta queries.

include no hace un JOIN: lanza 2 queries fijas (las filas principales, y luego las relacionadas con WHERE … IN (...)), las tenga quien las tenga. Y _count resuelve el número en 1.


8. Práctica: medir y arreglar el N+1 de la IA

Sección titulada «8. Práctica: medir y arreglar el N+1 de la IA»

¿Vas al día? Sigue en tu rama y trae solo los ficheros que no has escrito tú (no toca tu queries.ts ni tu seed). n-plus-one.ts es la ayuda de esta práctica; el servicio de inscripciones y src/errors.ts los usa la sesión 3; race.ts es la demo del proyector, por si quieres repetirla en casa:

📋 S2·12 · traer del checkpoint solo los ficheros que no has escrito (terminal)
git fetch origin
git checkout origin/s2-checkpoint -- src/services src/errors.ts src/playground/race.ts src/playground/n-plus-one.ts

¿Vas tarde? La receta “Cambiar de rama” con origin/s2-checkpoint (la rama se llama mi-s2-cp porque mi-s2 puede existir ya):

📋 S2·13 · receta «Cambiar de rama» a s2-checkpoint (terminal)
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s2-cp origin/s2-checkpoint # mi-s2 ya existe: otro nombre
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed

Si vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp que las del repo). Después, npm run seed.

Sobre tu rama, con el seed de hoy cargado.

  1. Crea src/playground/list-students.ts con el cliente que cuenta queries de §7 (📋 S2·11) y, debajo, esta función para medir (📋 S2·14):

📋 S2·14 · measure y main (sigue en src/playground/list-students.ts)
// sigue en src/playground/list-students.ts
async function measure(label: string, fn: () => Promise<unknown>) {
queries = 0;
const result = await fn();
console.log(`${label}: ${queries} queries`, JSON.stringify(result));
}
// al final del fichero, como en queries.ts
async function main() {
await measure('IA (bucle)', listStudents); // + una por versión tuya
}
main().finally(() => prisma.$disconnect());

Ejecuta con npx tsx src/playground/list-students.ts.

  1. Copia dentro listStudents de la IA (de 📋 S2·10 en la web; si pasaste a mi-s2-cp, el fichero s2-listado-y-aforo.ia.ts se quedó en tu rama mi-s2), usando este prisma, y mídela.
  2. ✍️ A mano (es la práctica): una versión con include que devuelva lo mismo ({ name, enrollments }); mídela.
  3. Escribe una versión con _count que devuelva lo mismo y mídela.
  4. Extra: crea un cuarto alumno (con Prisma Studio o un upsert), vuelve a medir las tres y bórralo al terminar: el seed solo resetea los cursos de Marta, así que el alumno extra sobrevive a npm run seed y desde ahí todo daría 5/2/1.

Pista: si los arrays salen en distinto orden, ¿qué le falta a findMany? ¿Atascado montando el contador? El cliente de n-plus-one.ts del checkpoint te vale; su measure no (es () => Promise<void> y no imprime el resultado): usa el de arriba.

Criterio de “hecho”: npx tsx src/playground/list-students.ts imprime 4 → 2 → 1 queries, y las tres versiones devuelven el mismo array. En el extra, la de la IA pasa a 5 y las tuyas no se mueven.


La lección del código de IA de hoy: las dos funciones compilaban y daban el resultado correcto con tres alumnos y un usuario a la vez. Sus fallos solo aparecen al crecer: con más filas (N+1) o con más usuarios simultáneos (aforo). “Funciona en mi máquina” no es una prueba: mídelo — cuenta las queries, lanza las peticiones a la vez.

  • Cuándo una relación N:M necesita modelo explícito.
  • Diferencia entre include y select, y cuántas queries lanza include.
  • Por qué el orderBy necesita desempate, y cuándo usar cursor.
  • Por qué una $transaction no impidió la sobreventa, y qué sí lo hizo.

Exponemos todo esto como una API REST con Express — endpoints, capas (controlador/servicio/repositorio), validación con Zod y manejo de errores centralizado. Los ConflictError/NotFoundError de hoy se convertirán en 409/404 (y src/errors.ts pasará a src/errors/app-error.ts). Punto de partida común: rama origin/s3-start = final de hoy (= s2-end, que además trae la solución de la práctica). No hay tarea para casa.