Pular para o conteúdo

Builder OS

Builder · OS
L02 · Schema e migration
~12 MIN DE LEITURA

Lição 2 de 13: Schema e migration

lição 2/13 do Módulo 3
AO FIM, VOCÊ VAI TER
  • Schema da tabela principal definido em código (Drizzle/Prisma/equivalente)
  • Migration gerada e aplicada no banco local
  • Tabela visível via \d [tabela] (ou comando equivalente do ORM)
  • Commit feat(db): add [tabela] table no GitHub

Como decidir os tipos antes de codar

O SaaS de recibos guarda recibos: cliente, valor, data, e o CPF ou CNPJ de quem recebe. Antes de escrever uma linha de código, decida o das tabelas. Trecho do plano (feat-recibos-foundation.md):

Key architectural decisions:

  • VARCHAR(14) no campo de documento: guarda CPF (11) e CNPJ (14) sem máscara; CNPJ alfanumérico chega em julho de 2026. Projete pra isso desde o dia um.
  • Valor em centavos (BIGINT), nunca float: dinheiro em float acumula erro de arredondamento.
  • criado_em como TIMESTAMPTZ: recibo tem data fiscal; fuso errado vira bug de relatório.

Cada decisão fechou uma porta de retrabalho futuro. Por exemplo: usar INTEGER no campo de documento teria parecido inocente em janeiro de 2026 e quebrado em julho, quando o governo passa a permitir letras no CNPJ. Migrar uma tabela cheia depois custa horas. Decidir agora custa 30 segundos.

Você vai tomar a mesma decisão na escala do seu produto.

A regra: schema é decisão de produto

Refatorar schema depois custa muito mais do que decidir bem agora. Adicionar coluna num banco com 1000 registros custa 30 segundos; renomear coluna nesse mesmo banco com 8 arquivos que usam ela leva 1-2 horas. Decidir agora poupa esse retrabalho.

A primeira tarefa que o /work propôs na L01 foi (provavelmente) o schema da tabela principal da fatia 1. Os passos abaixo vão do schema proposto até a aplicada no banco local.

Passo 1 — Revise o schema proposto antes de aprovar

Volta no /work do final da L01. Antes de digitar y, o Claude mostra o conteúdo do arquivo de schema que vai criar:

saída esperada
Vou criar o arquivo de schema (ex: db/schema/recibos.ts no Drizzle):

import { pgTable, serial, varchar, bigint, timestamp } from 'drizzle-orm/pg-core'

export const recibos = pgTable('recibos', {
id: serial('id').primaryKey(),
contador_id: integer('contador_id').notNull(),
cliente_cpf: varchar('cliente_cpf', { length: 11 }).notNull(),
valor_centavos: bigint('valor_centavos', { mode: 'number' }).notNull(),
numero_sequencial: integer('numero_sequencial').notNull(),
criado_em: timestamp('criado_em').defaultNow().notNull(),
})

Do you want to proceed? [y/n]

O exemplo usa Drizzle. Se seu projeto usa Prisma ou outro ORM, o output muda, mas as 5 perguntas abaixo valem igual.

Aplique as 5 perguntas brasileiras ao schema antes de aprovar:

  1. Tipos batem com o domínio brasileiro? cliente_cpf é varchar(11) (CPF tem 11 dígitos sem máscara), não int (perde zero à esquerda) nem varchar(14) (assume formato com pontos). Se vai aceitar CNPJ também, varchar(14) faz sentido. Decide agora.
  2. Valores monetários são , não float? Float em dinheiro é onde produto brasileiro pega bug fiscal. R$ 10,30 como 10.3 vira 10.299999... depois de duas multiplicações. Se o Claude propôs float ou decimal pra valor, peça pra trocar pra bigint.
  3. Cada FK tem o references() correto? No exemplo, contador_id deveria referenciar a tabela de contadores. Se ela ainda não existe, isso vai dar erro na migration. Solução: cria as duas no mesmo schema, ou deixa sem FK por enquanto e adiciona quando a outra tabela existir.
  4. Tem criado_em / atualizado_em em colunas timestamp? Quase todo produto eventualmente vai querer esses campos pra debug e auditoria. Adicionar depois requer migration. Adicionar agora é uma linha.
  5. Naming convention bate com o resto do projeto? Se o resto do código usa camelCase, não misture snake_case. Pegue uma convenção e fique nela.

Passo 2 — Aprove e gere a migration

Quando o schema passa nas 5 perguntas, aprova com y. Claude cria o arquivo de schema e depois propõe gerar a migration:

saída esperada
Arquivo criado. Agora vou gerar a migration.

Vou rodar:
npm run db:generate   (Drizzle Kit gera SQL a partir do schema)

Output esperado: arquivo em drizzle/migrations/0001_*.sql
com o CREATE TABLE.

Do you want to proceed? [y/n]

O comando exato muda conforme o ORM (npx prisma migrate dev, npx drizzle-kit generate). O CLAUDE.md do projeto deve ter documentado.

Aprove. Olhe o arquivo SQL gerado antes de aplicar no banco:

prompt · text
Mostra o conteúdo do arquivo de migration mais recente que você acabou de gerar. Quero conferir o SQL antes de aplicar.

Coisas que valem checar no SQL bruto:

  • NOT NULL nas colunas certas: esquecer em coluna que não deveria aceitar null é bug fácil de pegar agora, difícil de pegar em produção.
  • Tipos batem: BIGINT pra monetário, VARCHAR(11) pra CPF, TIMESTAMP WITH TIME ZONE se você espera lidar com múltiplos fusos.
  • Defaults: criado_em deve ter DEFAULT NOW(), não vir vazio.

Passo 3 — Aplique a migration

Antes de aplicar, garante que o banco local está rodando:

prompt · text
Confere se o Postgres do projeto está rodando localmente. Se não estiver, sobe ele (Docker ou serviço local, conforme o setup do projeto).

Quando estiver up:

prompt · text
Aplica a migration mais recente no banco local. Mostra o output do comando.

Sucesso típico:

saída esperada
> npm run db:migrate
> drizzle-kit push

[✓] Migration 0001_create_recibos.sql aplicada
[✓] 1 tabela criada: recibos

Confirma que a tabela existe:

prompt · text
Conecta ao banco local e mostra o output do comando que lista a estrutura da tabela "recibos" (\d no psql, ou equivalente).

O output deve mostrar as colunas que você definiu. Se algo está faltando, a migration não aplicou direito: peça pro Claude diagnosticar.

Passo 4 — Faça o commit

Comita os dois arquivos: schema (db/schema/recibos.ts) e migration (drizzle/migrations/0001_*.sql).

prompt · text
Faça commit dos arquivos de schema e migration que acabamos de criar. Stage os dois arquivos, com mensagem `feat(db): add recibos table` (substitua "recibos" pelo nome real). Mostre os comandos antes de executar.

Build Diary — o schema do CEAP foi decidido antes do código

O CEAP consome dados abertos da Câmara. A seção "Database Schema" do plano descreve as tabelas de despesa com tipos e relacionamentos completos. Trecho da tabela de despesas:

DESPESAS {
  varchar(7) id_deputado PK
  varchar(120) nome_deputado
  varchar(60) categoria
  decimal(14_2) valor_liquido
  date data_emissao
}

Duas decisões tomadas antes de qualquer SQL rodar:

  1. varchar(7) pra id_deputado (não int). IDs vêm com zeros à esquerda significativos: 0012345 é diferente de 12345. int apagaria os zeros.
  2. decimal(14, 2) pra valor_liquido, não float. O valor vem da Câmara já com vírgula. decimal preserva precisão; float arredonda. Aqui a escolha foi decimal em vez de bigint-em-centavos porque a fonte já decidiu o formato. Decisão consciente, registrada no plano.

A decisão de schema fica no plano antes da migration. Quando o /work propôs cada CREATE TABLE, dava pra checar contra o ERD documentado, sem retrabalho.

Takeaways

  • Schema é decisão de produto, não detalhe de implementação. Decide os tipos pensando nos dados que a tabela vai aceitar, antes do código rodar.
  • 5 perguntas brasileiras antes de aprovar: tipo bate com domínio? Valor monetário em bigint centavos? FK referencia a tabela certa? Tem timestamps? Naming convention bate?
  • Refatorar schema com 1000 registros + 8 arquivos = 1-2h. Decidir agora = 30s.
  • LGPD pra CPF/CNPJ: base legal, encryption-at-rest, retenção. Não bloqueia fatia 1, mas precisa estar respondido até o M4.

Você terminou quando

Você tem três coisas:

  1. Arquivo de schema commitado
  2. Arquivo de migration commitado
  3. Tabela existindo no banco local (confirmada via \d [tabela] ou equivalente)