Migrações de banco sem quebrar produção
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_recordstem colunas de NFSe no banco (datas de vencimento, status fiscal) que o schema TS não descrevia direito. Rodardb:pushnessa tabela sobrescreveria o que o banco realmente usa. Regra: essa tabela nunca levadb:push.store_financialtemtypecom valoresone_shot/mensal— não o enumrevenue/expenseque o nome sugere. Filtrar portype='revenue'retorna zero linhas e um relatório silenciosamente errado.gente_one_on_onestem colunasNOT NULLno 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 (dois0057, dois0060) 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 oCREATE 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 SECURITYnã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
STABLEonde precisava deIMMUTABLE; 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:pushem 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.