Voltar para o Blog
Construindo em Público7 / 10
Construindo em PúblicoParte 7 de 10

Migrações de banco sem quebrar produção

5 min de leitura
Compartilhar
Construindo em Público

Migração de banco é onde o "funciona na minha máquina" encontra a realidade mais rápido. Você adiciona uma coluna, roda local, tudo verde, faz o deploy — e descobre que o banco de produção já tinha aquela coluna com outro nome, ou que uma tabela crítica não bate com o que seu ORM acha que ela é. No ClubPetro, a gente lida com um banco Supabase com mais de 50 tabelas, migrations escritas à mão e um drift conhecido entre o schema TypeScript e o banco real. Este post é sobre como a gente evita quebrar produção quando o mapa (o schema no código) não é o território (o banco de verdade).

A verdade incômoda: o schema TS não reflete o banco

A primeira coisa que todo mundo que entra no time precisa internalizar: nunca assuma que o schema Drizzle reflete o banco. Existe drift, ele é conhecido, e ignorá-lo é a forma mais rápida de causar um incidente.

Alguns exemplos reais que viraram armadilha:

  • store_financial_records tem colunas de NFSe no banco (datas de vencimento, status fiscal) que o schema TS não descrevia direito. Rodar db:push nessa tabela sobrescreveria o que o banco realmente usa. Regra: essa tabela nunca leva db:push.
  • store_financial tem type com valores one_shot/mensal — não o enum revenue/expense que o nome sugere. Filtrar por type='revenue' retorna zero linhas e um relatório silenciosamente errado.
  • gente_one_on_ones tem colunas NOT NULL no banco que o TS tratava como opcionais.

O padrão aqui não é "conserte o drift de uma vez". É respeitar que ele existe e verificar o banco real antes de escrever qualquer coisa que dependa do schema. pnpm db:studio pra inspecionar, query direta pra confirmar. A regra da casa é literal: antes de criar qualquer dado ou tocar qualquer schema, você checa o que já existe.

Migrations à mão, _journal.json fica pra trás

Um detalhe que confunde quem chega: nossas migrations são SQL escrito à mão em packages/db/drizzle/NNNN_*.sql. O _journal.json do Drizzle está parado lá atrás — ele não é o mecanismo. db:migrate não é como aplicamos migrations em produção.

Isso tem um custo e um benefício. O custo é que você não ganha a automação do Drizzle Kit de graça. O benefício é controle total: numa base com drift, SQL à mão é o único jeito de escrever uma migração que respeita o estado real do banco em vez de brigar com ele.

  • Numeração é fonte de conflito. Como cada branch cria o próximo número, dois PRs simultâneos podem colidir no mesmo NNNN. Já tivemos colisão real (dois 0057, dois 0060) que só virou problema depois do merge. Aprendizado: renumerar em PR de limpeza, e checar o número mais alto na main antes de nomear a sua.
  • Aplicação e deploy são passos separados. A migration entrar na main não significa que ela rodou em produção. Vários dos nossos "pendências" são exatamente isso: código mergeado, migration ainda não aplicada. Confundir os dois gera telas que buscam colunas que não existem.

Toda tabela nasce com RLS

Migração no Supabase tem uma regra que não é negociável: toda tabela nova ativa Row Level Security no mesmo migration que a cria. O banco é PostgREST — a chave anon/authenticated expõe via API qualquer tabela sem RLS. Tabela sem RLS é vazamento de dados, ponto.

  • ALTER TABLE <tabela> ENABLE ROW LEVEL SECURITY; no mesmo arquivo SQL que faz o CREATE TABLE. Não "depois", não "quando der".
  • RLS sem policy é deny-all. Tabelas internas (cache, logs, filas de job) ativam RLS e ficam de propósito sem policy. Tabelas expostas à API ganham policies explícitas.
  • Review barra o que não tem. Um PR que cria tabela sem ENABLE ROW LEVEL SECURITY não passa. Já fizemos auditoria e achamos dezenas de tabelas antigas sem RLS — o custo de arrumar depois é muito maior que o de acertar na criação.

Essa disciplina é chata na hora de escrever a migration e salva você de uma manchete constrangedora depois.

Deploy que não vira roleta

Migração segura é tanto sobre o SQL quanto sobre a sequência. Algumas coisas que a gente faz de propósito:

  • Mudanças aditivas primeiro. Adicionar coluna nullable, backfill, só depois tornar NOT NULL. Nunca uma migração destrutiva no mesmo passo que o código que depende dela.
  • Índices grandes com cuidado. Já tivemos um índice que falhava porque usava uma função STABLE onde precisava de IMMUTABLE; o fix foi coordenar um cast na própria migration. Índice que trava numa tabela grande é downtime — dá pra criar concorrentemente quando o caso pede.
  • Ambiente é sagrado. Jamais rodar insert/seed contra hml ou prod, jamais db:push em prod. db:push é ferramenta de dev; em produção, migration versionada.

O que faríamos de novo

Manter SQL à mão foi a escolha certa pra um banco com drift — dá o controle que a automação não daria. O que a gente reforçaria mais cedo: tratar o drift como cidadão de primeira classe, documentado por tabela, em vez de conhecimento tribal na cabeça de quem já se queimou. Cada armadilha que virou nota escrita economizou o próximo incidente.

Se você é o tipo de pessoa que lê uma migration e já pensa "e o que acontece com quem está no meio de uma request quando isso roda?" — que trata o banco de produção com o respeito de um sistema vivo e não de um arquivo de texto — você vai se sentir em casa aqui. Migração sem quebrar produção não é sorte. É paranoia disciplinada, aplicada uma coluna de cada vez.

Curtiu como a gente pensa?

É assim que trabalhamos todo dia.

Se esse jeito de pensar é o seu, vem construir com a gente — dá uma olhada nas vagas abertas.

Ver vagas