Drizzle ORM com Claude Code: PostgreSQL, migrations, Zod e CI
Implemente Drizzle ORM com Claude Code: schema tipado, migrations, seed, transactions, Zod e validacao em CI.
Drizzle ORM e um ORM focado em TypeScript que fica perto de SQL. Voce descreve as tabelas em TypeScript e escreve consultas que continuam legiveis para quem entende SQL. Isso combina bem com Claude Code, porque o agente consegue ler o schema, explicar relacoes, gerar migrations e apontar qual indice atende qual consulta.
O risco e achar que a camada de banco esta pronta so porque o schema.ts compila. A migration gerada pode apagar uma coluna. O seed pode funcionar uma vez e falhar na segunda execucao. Uma transaction pode segurar conexao enquanto espera email, Stripe ou webhook. Por isso, o pedido para Claude Code deve cobrir schema, migration, query, transaction, seed, validacao Zod e CI.
Confira os detalhes nas fontes oficiais: Drizzle ORM docs, Drizzle Kit docs, Transactions, Drizzle Zod docs e Claude Code docs. Leituras internas uteis: Prisma ORM, database migration e Zod validation.
Fluxo de trabalho
Um prompt bom informa dominio, regras de exclusao e verificacoes esperadas.
Implemente a camada de banco com Drizzle ORM.
Stack:
- PostgreSQL
- drizzle-orm, drizzle-kit, node-postgres
- tabelas: User, Post, Category, Comment, AuditLog
- Post status: draft, published, archived
- email e slug unicos
- lista por status, publishedAt, author, category e search
- ao apagar Post, cascade apenas Comment e tabela intermediaria
- ao apagar User, Posts nao devem ser apagados
Entregue schema, drizzle.config.ts, queries, transaction, seed, Zod e CI.
flowchart LR
A["Prompt"] --> B["schema.ts"]
B --> C["drizzle-kit generate"]
C --> D["review de SQL"]
D --> E["queries, transaction, seed"]
E --> F["Zod validation"]
F --> G["CI checks"]
Schema e migration
Este schema cobre pontos reais de review: unique, indices, relacoes e regras de exclusao.
// db/schema.ts
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
jsonb,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
export const postStatus = pgEnum("post_status", ["draft", "published", "archived"]);
export const users = pgTable(
"users",
{
id: uuid("id").defaultRandom().primaryKey(),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
role: varchar("role", { length: 40 }).default("editor").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("users_email_unique").on(table.email),
index("users_role_idx").on(table.role),
],
);
export const categories = pgTable(
"categories",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 120 }).notNull(),
name: varchar("name", { length: 120 }).notNull(),
},
(table) => [uniqueIndex("categories_slug_unique").on(table.slug)],
);
export const posts = pgTable(
"posts",
{
id: uuid("id").defaultRandom().primaryKey(),
slug: varchar("slug", { length: 160 }).notNull(),
title: varchar("title", { length: 160 }).notNull(),
body: text("body").notNull(),
status: postStatus("status").default("draft").notNull(),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
viewCount: integer("view_count").default(0).notNull(),
featured: boolean("featured").default(false).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("posts_slug_unique").on(table.slug),
index("posts_status_published_at_idx").on(table.status, table.publishedAt),
index("posts_author_id_idx").on(table.authorId),
],
);
export const postCategories = pgTable(
"post_categories",
{
postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
categoryId: uuid("category_id").notNull().references(() => categories.id, { onDelete: "cascade" }),
},
(table) => [primaryKey({ columns: [table.postId, table.categoryId] })],
);
export const comments = pgTable(
"comments",
{
id: uuid("id").defaultRandom().primaryKey(),
postId: uuid("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "restrict" }),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("comments_post_created_at_idx").on(table.postId, table.createdAt),
index("comments_author_id_idx").on(table.authorId),
],
);
export const auditLogs = pgTable(
"audit_logs",
{
id: uuid("id").defaultRandom().primaryKey(),
action: varchar("action", { length: 80 }).notNull(),
targetId: uuid("target_id").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("audit_logs_action_created_at_idx").on(table.action, table.createdAt)],
);
export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), comments: many(comments) }));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
comments: many(comments),
categories: many(postCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({ posts: many(postCategories) }));
export const postCategoriesRelations = relations(postCategories, ({ one }) => ({
post: one(posts, { fields: [postCategories.postId], references: [posts.id] }),
category: one(categories, { fields: [postCategories.categoryId], references: [categories.id] }),
}));
export const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, { fields: [comments.postId], references: [posts.id] }),
author: one(users, { fields: [comments.authorId], references: [users.id] }),
}));
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
verbose: true,
strict: true,
});
npm run db:generate
npm run db:check
npm run db:migrate
Leia o SQL gerado. Procure DROP inesperado, NOT NULL sem backfill, cascade amplo demais e indices que nao servem a consultas reais.
Queries, transaction e seed
// db/client.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });
// db/posts.ts
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "./client";
import { auditLogs, categories, comments, postCategories, posts, users } from "./schema";
import { createPostInputSchema } from "./validation";
type CreatePostInput = z.infer<typeof createPostInputSchema>;
export async function createPost(input: CreatePostInput) {
const data = createPostInputSchema.parse(input);
return db.transaction(async (tx) => {
const [post] = await tx.insert(posts).values({
slug: data.slug,
title: data.title,
body: data.body,
authorId: data.authorId,
}).returning();
for (const slug of data.categorySlugs) {
const [category] = await tx.insert(categories)
.values({ slug, name: slug })
.onConflictDoUpdate({ target: categories.slug, set: { name: slug } })
.returning();
await tx.insert(postCategories)
.values({ postId: post.id, categoryId: category.id })
.onConflictDoNothing();
}
await tx.insert(auditLogs).values({
action: "post.create",
targetId: post.id,
metadata: { slug: post.slug },
});
return post;
});
}
export async function listPublishedPosts(params: { page?: number; perPage?: number; search?: string } = {}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where = params.search
? and(eq(posts.status, "published"), ilike(posts.title, `%${params.search}%`))
: eq(posts.status, "published");
const [items, [{ total }]] = await Promise.all([
db.select({
id: posts.id,
slug: posts.slug,
title: posts.title,
publishedAt: posts.publishedAt,
authorName: users.name,
commentCount: sql<number>`count(${comments.id})::int`,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(comments, eq(comments.postId, posts.id))
.where(where)
.groupBy(posts.id, posts.slug, posts.title, posts.publishedAt, users.name)
.orderBy(desc(posts.publishedAt), desc(posts.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
db.select({ total: sql<number>`count(*)::int` }).from(posts).where(where),
]);
return { items, pagination: { page, perPage, total, totalPages: Math.ceil(total / perPage) } };
}
Seed precisa ser idempotente.
// db/seed.ts
import { db, pool } from "./client";
import { categories, postCategories, posts, users } from "./schema";
async function main() {
const [user] = await db.insert(users)
.values({ email: "masa@example.com", name: "Masa", role: "admin" })
.onConflictDoUpdate({ target: users.email, set: { name: "Masa", role: "admin", updatedAt: new Date() } })
.returning();
const [category] = await db.insert(categories)
.values({ slug: "drizzle", name: "Drizzle ORM" })
.onConflictDoUpdate({ target: categories.slug, set: { name: "Drizzle ORM" } })
.returning();
const [post] = await db.insert(posts)
.values({
slug: "claude-code-drizzle-demo",
title: "Claude Code Drizzle demo",
body: "A seeded post for local verification.",
status: "published",
authorId: user.id,
publishedAt: new Date(),
})
.onConflictDoUpdate({ target: posts.slug, set: { title: "Claude Code Drizzle demo", updatedAt: new Date() } })
.returning();
await db.insert(postCategories).values({ postId: post.id, categoryId: category.id }).onConflictDoNothing();
}
main().finally(async () => pool.end());
Zod e CI
TypeScript nao valida entradas em runtime. Use Zod antes da persistencia.
// db/validation.ts
import { createInsertSchema } from "drizzle-orm/zod";
import { z } from "zod";
import { posts } from "./schema";
export const createPostInputSchema = createInsertSchema(posts, {
slug: (schema) => schema.min(3).max(160).regex(/^[a-z0-9-]+$/),
title: (schema) => schema.min(1).max(160),
body: (schema) => schema.min(50),
})
.pick({ slug: true, title: true, body: true, authorId: true })
.extend({
categorySlugs: z.array(z.string().min(1).max(120)).min(1).max(5),
});
name: drizzle
on:
pull_request:
jobs:
db:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://app:app@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run db:generate
- run: npm run db:check
- run: npm run db:migrate
- run: npm run db:seed
- run: npm run typecheck
- run: npm test
Casos, erros e CTA
| Caso | Ponto critico | Pedido para Claude Code |
|---|---|---|
| CMS editorial | slug, status, publishedAt | Explique qual indice atende cada tela |
| Admin SaaS | tenant, permissoes, audit | Excluir usuario nao remove dados de negocio |
| Plataforma de cursos | progresso, acesso, transaction | Progresso e audit log consistentes |
| Conteudo monetizado | CTA, produto, leitura completa | Analytics sem enfraquecer constraints |
Erros comuns: nao ler SQL, seed de uso unico, API externa dentro de transaction, Zod como substituto de constraints, paginacao sem limite e Claude Code editando migrations sem relacao. Para times, a pagina em ingles Claude Code training and consultation e o melhor proximo passo. Para estudar mais, veja Supabase integration e CI/CD setup.
Em uma API pequena de blog, o maior ganho veio de pedir a Claude Code que explicasse a SQL migration gerada. O primeiro rascunho exagerou uma regra de exclusao. Depois de revisar onDelete, indices e seed idempotente, o diff ficou muito mais facil de aprovar. Drizzle ORM e leve, mas a seguranca vem da combinacao entre velocidade do agente, leitura humana de SQL e prova em CI.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.