Prisma ORM con Claude Code: schema, migraciones, transacciones y tests
Implementa Prisma ORM con Claude Code: diseño de schema, revisión de migraciones, transactions, seed/test y checklist.
Prisma ORM es una capa de base de datos tipada para trabajar con una DB de forma segura desde TypeScript. Con Claude Code, no solo sirve para generar CRUD: puedes pedir diseño de schema, revisión de migration SQL, uso de Prisma Client, límites de transaction, seed, tests y checklist de publicación.
El problema aparece cuando se automatiza sin criterio. Una petición vaga como “hazme el CRUD con Prisma” puede dejar índices incompletos,include demasiado grandes, reglas cascade peligrosas o migraciones que pasan en local pero fallan con datos reales. Este artículo usa una API de blog para mostrar un flujo práctico, listo para un sitio de contenido, SaaS o herramienta interna.
Consulta las referencias oficiales: Prisma ORM, Prisma Schema, Transactions y Prisma Migrate. También ayudan las guías internas de Claude Code, Drizzle ORM, Supabase y Redis caching.
Flujo recomendado
flowchart LR
A["Reglas del producto para Claude Code"] --> B["Diseñar schema.prisma"]
B --> C["Generar y revisar migration.sql"]
C --> D["Implementar queries con Prisma Client"]
D --> E["Agregar seed y tests"]
E --> F["Checklist antes de producción"]
Piensa en tres artefactos: schema, SQL migration y código TypeScript. Claude Code puede preparar los tres, pero una persona debe validar constraints, índices, reglas de borrado y fronteras de transaction.
Prompt útil para Claude Code
Diseña una capa Prisma ORM para una API de blog.
Contexto:
- TypeScript + Prisma ORM + SQLite en local, con posible migración futura a PostgreSQL
- Modelos: User, Post, Category, Comment, Notification, AuditLog
- Post usa status como string: DRAFT/PUBLISHED/ARCHIVED
- email y slug deben ser únicos
- La lista pública filtra por status, publishedAt, author y category
- Al borrar un Post se eliminan en cascade Comment y filas de unión
- Borrar User queda restringido; la anonimización será otra migration
Entrega:
1. prisma/schema.prisma
2. Puntos de revisión para migration SQL
3. Código Prisma Client para create/list/publish
4. Comandos de seed y test
5. Checklist de producción
La clave es incluir intención operativa: qué datos son únicos, qué no debe borrarse, qué pantallas necesitan índices y qué acciones requieren atomicidad.
Setup local
{
"type": "module",
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "prisma db seed",
"dev": "tsx src/demo.ts",
"test": "vitest run"
},
"dependencies": {
"@prisma/client": "latest",
"dotenv": "latest"
},
"devDependencies": {
"prisma": "latest",
"tsx": "latest",
"typescript": "latest",
"vitest": "latest"
}
}
npm install
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma src
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String
role String @default("editor")
posts Post[]
comments Comment[]
notifications Notification[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
body String
status String @default("DRAFT")
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
categories CategoriesOnPosts[]
comments Comment[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([status, publishedAt])
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
posts CategoriesOnPosts[]
}
model CategoriesOnPosts {
postId String
categoryId String
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
model Comment {
id String @id @default(cuid())
body String
postId String
authorId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@index([postId, createdAt])
@@index([authorId])
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String
message String
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
}
model AuditLog {
id String @id @default(cuid())
action String
targetId String
metadata String?
createdAt DateTime @default(now())
@@index([action, createdAt])
}
Pide a Claude Code que justifique cada índice. status + publishedAt sirve a la lista pública; postId + createdAt sirve a los comentarios. Si un índice no tiene pantalla o consulta asociada, probablemente sobra.
Revisión de migraciones
npx prisma format
npx prisma migrate dev --create-only --name init_blog
# Revisar prisma/migrations/*/migration.sql en el PR
npx prisma migrate dev
npx prisma generate
En producción:
npx prisma migrate deploy
db push es cómodo para prototipos, pero no sustituye una historia de migrations revisable. Antes de publicar, revisaDROP, nuevosNOT NULL, unique constraints sobre datos existentes y cambios en cascade.
La guía de rollback debe estar en la PR antes del deploy. Si la migration ya terminó bien, normalmente conviene crear otra migration hacia adelante que revierta el cambio. Si falló a medias, sigue el workflow oficial de down migrations: verificar backup, ejecutar migrate status, aplicar SQL revisado si hace falta y marcar solo esa migration como rolled back con migrate resolve.
npx prisma migrate status
npx prisma migrate resolve --rolled-back "20260603090000_failed_change"
Prisma Client y transactions
// src/db.ts
import { PrismaClient } from "./generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/posts.ts
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function listPublishedPosts(params: {
page?: number;
perPage?: number;
categorySlug?: string;
search?: string;
}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where: Prisma.PostWhereInput = {
status: "PUBLISHED",
...(params.categorySlug
? { categories: { some: { category: { slug: params.categorySlug } } } }
: {}),
...(params.search
? { OR: [{ title: { contains: params.search } }, { body: { contains: params.search } }] }
: {}),
};
const [items, total] = await prisma.$transaction([
prisma.post.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
select: {
id: true,
slug: true,
title: true,
publishedAt: true,
author: { select: { name: true } },
_count: { select: { comments: true } },
},
}),
prisma.post.count({ where }),
]);
return { items, pagination: { page, perPage, total } };
}
export async function publishPost(postId: string) {
return prisma.$transaction(async (tx) => {
const post = await tx.post.findUnique({
where: { id: postId },
select: { id: true, title: true, status: true, authorId: true },
});
if (!post) throw new Error("Post not found");
if (post.status === "PUBLISHED") return post;
const published = await tx.post.update({
where: { id: post.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
select: { id: true, title: true, status: true, publishedAt: true },
});
await tx.notification.create({
data: {
userId: post.authorId,
type: "POST_PUBLISHED",
message: `${post.title} was published`,
},
});
await tx.auditLog.create({
data: {
action: "POST_PUBLISHED",
targetId: post.id,
metadata: JSON.stringify({ title: post.title }),
},
});
return published;
});
}
Usa$transaction([]) para lecturas independientes como lista y count. Usa una transaction interactiva cuando una operación de negocio debe tocar varias tablas de forma atómica. No envíes emails ni llames APIs externas dentro de la transaction.
SQL seguro
Primero usa Prisma Client. Cuando una consulta de reporting necesite SQL, el pitfall es concatenar strings. La documentación de raw queries de Prisma recomienda tagged templates o Prisma.sql para parametrizar valores.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function topAuthors(limit = 10) {
return prisma.$queryRaw<
{ authorId: string; postCount: bigint }[]
>(Prisma.sql`
SELECT "authorId", COUNT(*) AS "postCount"
FROM "Post"
WHERE "status" = ${"PUBLISHED"}
GROUP BY "authorId"
ORDER BY "postCount" DESC
LIMIT ${Math.min(limit, 50)}
`);
}
Pide a Claude Code que justifique cada $queryRawUnsafe. Si solo quiere cambiar nombres de tabla o columna dinámicamente, usa una allowlist revisada por una persona.
Seed, test y checklist
// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { email: "editor@example.com" },
update: { name: "Editor" },
create: { email: "editor@example.com", name: "Editor", role: "admin" },
});
await prisma.post.upsert({
where: { slug: "claude-code-prisma-demo" },
update: { status: "PUBLISHED", publishedAt: new Date() },
create: {
slug: "claude-code-prisma-demo",
title: "Claude Code Prisma demo",
body: "Seeded article for local verification.",
status: "PUBLISHED",
publishedAt: new Date(),
authorId: user.id,
},
});
}
main().finally(async () => prisma.$disconnect());
npm run db:migrate -- --name init_blog
npm run db:generate
npm run db:seed
npm run test
Checklist: constraints e índices conectados a pantallas reales, SQL sin cambios destructivos accidentales, paginación con límite, transactions cortas, seed idempotente, tests de rollback o doble ejecución, y producción conmigrate deploy.
Casos de uso, errores y CTA
Tres casos típicos: gestión editorial, panel SaaS multi-tenant y activación de permisos tras un pago. El primero necesita slug y estado de publicación; el segundo exige tenant scope en cada query; el tercero requiere idempotencia, audit log y transaction clara.
En una prueba de una pequeña API de blog, Masa hizo que Claude Code explicara los riesgos de migration antes de escribir la app. Eso sacó dos problemas temprano: cascade demasiado amplio y demasiados campos devueltos en la lista pública. Ese es el uso correcto: acelerar, pero verificar con tipos, SQL y tests.
Para estudiar por cuenta propia, revisa /products/. Para equipos que quieren un workflow común de revisión de DB con Claude Code, mira /training/. Con un schema real o un PR generado por IA, la conversación puede ir directo a riesgos concretos y mejoras aplicables.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Checklist de auditoría inicial de repo con Claude Code
Audita un repo en 20 minutos antes de la primera edición: alcance, riesgos, pruebas y CTA de revenue.
Claude Code Harness Lite: una barandilla pequeña para cambios seguros
Un flujo inicial para separar lectura, edición, prueba, URL pública y CTA de ingresos con Claude Code.
Primer mapa de repositorio con Claude Code: leer código existente sin gastar contexto
Flujo seguro para leer un repositorio con Claude Code antes de editar: mapa, tareas pequeñas, pruebas, PDF gratis, Gumroad y consultoría.