Por que usar migrações de banco de dados
Todo projeto começa com alguém rodando um CREATE TABLE à mão. Funciona até a segunda pessoa entrar no time, ou até o primeiro deploy. A partir daí surgem as perguntas: qual coluna já existe em produção? Quem adicionou esse índice? Por que o staging tem uma tabela que o banco local não tem?
Migrações de banco de dados respondem a isso com uma regra simples: toda mudança de schema vira um arquivo, os arquivos ficam no repositório e são aplicados sempre na mesma ordem. O banco passa a ter histórico, como o código. Revisão de pull request, rollback de deploy e onboarding de pessoas novas ficam previsíveis.
No kastor o schema é escrito em TypeScript, e a CLI cuida de transformar diferenças entre esse schema e o banco em SQL. Você descreve o estado desejado, e a ferramenta escreve o passo para chegar lá.
O fluxo no dia a dia: migrate dev
O ciclo é sempre o mesmo. Você altera o arquivo de schema, roda um comando, revisa o SQL gerado e faz commit.
import { table, t, index } from 'kastor-orm'
export const users = table('users', {
id: t.id(),
email: t.string(255).unique(),
name: t.string().nullable(),
createdAt: t.datetime().defaultNow(),
})
// New table added in this change
export const posts = table('posts', {
id: t.uuid().primaryKey().defaultRandom(),
title: t.string(200),
published: t.boolean().default(false),
authorId: t.int().references(() => users.id, { onDelete: 'cascade' }),
}, { indexes: (p) => [index(p.authorId)] })npx kastor migrate dev --name add_postsO comando lê o kastor.config.ts, aplica antes as migrações pendentes (por exemplo, as que vieram da branch de um colega), compara o schema com o banco de desenvolvimento ao vivo, escreve kastor/migrations/<timestamp>_add_posts/migration.sql e aplica esse arquivo. O timestamp é em UTC, e o arquivo migration_lock.toml na mesma pasta registra para qual banco o SQL foi escrito. Com --create-only, o comando para depois de escrever o arquivo, para você editar o SQL antes de aplicar. O resultado é SQL comum, com um cabeçalho comentado que lista as mudanças, que você pode ler na revisão de código:
-- Migration 20261013120000_add_posts
-- Written by kastor 0.1.0 for postgres on 2026-10-13 12:00:00 UTC.
-- Applied by `kastor migrate deploy`. Edit it before it is applied if you need to;
-- after that, the checksum in _kastor_migrations must keep matching.
--
-- Changes:
-- + Create table posts
-- Create table posts
CREATE TABLE "posts" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"title" varchar(200) NOT NULL,
"published" boolean DEFAULT false NOT NULL,
"authorId" integer NOT NULL,
CONSTRAINT "posts_pkey" PRIMARY KEY ("id"),
CONSTRAINT "posts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
);
CREATE INDEX "posts_authorId_idx" ON "posts" ("authorId");Algumas boas práticas que evitam dor de cabeça:
- Dê nomes descritivos:
add_posts,users_add_role,drop_legacy_tokens. O nome aparece no histórico para sempre. - Faça uma mudança lógica por migração. Fica mais fácil revisar e entender o que deu errado.
- Faça commit da pasta
kastor/migrations, incluindo omigration_lock.toml, no mesmo pull request que altera o schema e o código que usa a mudança. - Nunca edite uma migração já aplicada. A tabela
_kastor_migrationsguarda o checksum de cada arquivo, e um arquivo alterado faz omigrate deployparar comMIGRATION_DRIFT. Crie uma nova.
Produção e CI: migrate deploy e migrate status
Em produção você não quer que nenhuma ferramenta invente SQL. Por isso existe um comando separado: migrate deploy só aplica migrações que já estão no repositório e ainda não rodaram naquele banco. Ele não compara schema, não pergunta nada e não gera arquivo.
O lugar certo para ele é uma etapa do pipeline antes de a nova versão da aplicação receber tráfego. Um exemplo com GitHub Actions:
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx kastor migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: npx kastor migrate status
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}O migrate status mostra quais migrações já foram aplicadas, quais estão pendentes e quais foram modificadas depois de aplicadas, e avisa quando o schema tem mudanças que nenhuma migração cobre. Ele termina com código 1 enquanto houver algo pendente, modificado ou faltando, por isso fica depois do migrate deploy: antes dele, derrubaria o job em todo release com migração nova. Com --json, o mesmo relatório sai em formato para scripts. Também é útil localmente, quando você puxa a branch de alguém e quer saber se o banco está atrasado.
Nota
Rode migrate deploy em um único lugar por deploy. Se cada instância da aplicação tentar migrar ao iniciar, várias vão competir pelo mesmo banco ao mesmo tempo.
Renomeações sem perder dados
Para uma ferramenta que compara dois estados, renomear uma coluna parece igual a apagar uma coluna e criar outra. Ferramentas ingênuas geram exatamente isso, e os dados da coluna antiga somem.
O migrate dev do kastor detecta esse padrão. Quando uma coluna desaparece e outra compatível aparece na mesma tabela, ele pergunta se é uma renomeação em vez de gerar o DROP. Escolha a opção de renomear e o arquivo terá um RENAME COLUMN, preservando os dados. O mesmo vale para tabelas. Sem um terminal para perguntar, por exemplo em um script, o comando para com erro, a menos que você passe a resposta com --rename users.name:fullName (ou --rename users:accounts para uma tabela).
Mudanças que apagam dados de verdade, como remover uma coluna, pedem confirmação antes de o arquivo ser escrito, tanto no migrate dev quanto no db push. Fora de um terminal, elas só passam com --accept-data-loss, e o cabeçalho do arquivo marca esses passos com [data loss].
Se você só quer mudar o nome no código e manter o nome no banco, nem precisa de migração: use .map('nome_antigo') na coluna. O TypeScript enxerga o nome novo e o banco continua com o antigo.
Mudanças destrutivas seguras: expandir e contrair
Renomear dentro de uma migração resolve o banco, mas não resolve o deploy. Durante alguns segundos ou minutos, a versão antiga da aplicação ainda está rodando e procura a coluna pelo nome antigo. Para mudanças que quebram compatibilidade, use o padrão expandir e contrair (expand and contract), em etapas separadas:
- Expandir. Adicione a coluna nova como
.nullable()ou com.default(). Gere a migração e faça deploy. Nada quebra, porque a versão antiga ignora a coluna. - Escrever nas duas. Publique código que grava na coluna antiga e na nova, e lê da antiga.
- Preencher. Copie os dados existentes em lotes, com um script que usa
db.$execute. Lotes pequenos evitam travar a tabela por muito tempo. - Trocar a leitura. Publique código que lê da coluna nova.
- Contrair. Remova a coluna antiga do schema, gere a migração e faça deploy. Se precisar, torne a coluna nova obrigatória nessa mesma etapa.
import { db } from '../src/db/client'
// Copy data in small batches until nothing is left to update
let affected = 0
do {
affected = await db.$execute`
update users set "displayName" = name
where id in (
select id from users
where "displayName" is null and name is not null
limit 1000
)
`
} while (affected > 0)
await db.$disconnect()Parece muito trabalho para trocar um nome, e para uma tabela pequena em um projeto pessoal talvez seja. Mas em tabelas grandes ou com várias instâncias da aplicação, é o que separa um deploy tranquilo de um incidente. A mesma lógica vale para trocar o tipo de uma coluna, dividir uma tabela ou tornar obrigatória uma coluna que era opcional.
Revise também o SQL gerado pensando em bloqueios. Criar um índice ou mudar o tipo de uma coluna em uma tabela com milhões de linhas pode travar escritas enquanto o banco trabalha. Teste essas migrações em uma cópia com volume parecido com o de produção e programe o deploy para um horário de pouco movimento.
db push, db pull e migrate reset
Três comandos completam o conjunto, cada um com um uso bem definido.
| Comando | Quando usar | Gera arquivo? |
|---|---|---|
db push | Protótipos e experimentos locais | Não |
db pull | Adotar o kastor em um banco que já existe | Gera o schema em TypeScript |
migrate reset | Recomeçar o banco local do zero | Não |
db push sincroniza o banco com o schema sem criar migração. É ótimo nas primeiras horas de um projeto, quando o modelo muda a cada dez minutos. Quando o schema estabilizar, lembre que o banco já está igual ao schema, então um migrate dev logo em seguida não teria nada a escrever. Rode antes migrate reset para voltar ao estado das migrações existentes, depois o primeiro migrate dev, e não use mais db push em bancos compartilhados.
db pull lê as tabelas, colunas, índices e chaves estrangeiras de um banco existente e escreve um arquivo de schema em TypeScript no caminho do schema do config (ou em --out; com --print só mostra o resultado). O arquivo já traz as relações deduzidas das chaves estrangeiras, exportadas como relations. Revise nomes e relações e siga com migrações normais: um migrate dev logo depois deve dizer que o banco já está em sincronia, e o histórico de migrações começa a partir daí. É o caminho para trazer um sistema legado para um ORM para TypeScript sem reescrever nada.
migrate reset pede confirmação, apaga todas as tabelas do banco de desenvolvimento, aplica todas as migrações de novo e, se o config tiver um seed, já roda o seed no final (use --skip-seed para pular). Assim o ambiente local volta a um estado conhecido com um comando só. Nunca aponte esse comando para produção.
Se você usa Next.js na Vercel, o guia de Next.js com PostgreSQL mostra onde encaixar o migrate deploy no build. A lista completa de opções da CLI está na referência da API.
Perguntas frequentes
Q1O que são migrações de banco de dados?
São arquivos versionados que descrevem, em ordem, cada mudança no schema do banco: criar tabela, adicionar coluna, criar índice. Ficam no repositório junto com o código e são aplicados na mesma ordem em desenvolvimento, CI e produção.
Q2Qual a diferença entre migrate dev e migrate deploy?
migrate dev compara o schema em TypeScript com o banco local, escreve um novo arquivo de migração e o aplica. migrate deploy não gera nada: só aplica as migrações pendentes que já estão no repositório, por isso é o comando para CI e produção.
Q3Quando usar db push em vez de migrações?
Use db push para protótipos e experimentos locais, quando o histórico ainda não importa. Assim que o projeto tiver um banco compartilhado ou de produção, passe a usar migrate dev para que cada mudança fique registrada em arquivo.
Q4Como renomear uma coluna sem perder dados?
Altere o nome no schema e rode migrate dev. O kastor detecta a possível renomeação e pergunta se deve renomear em vez de apagar e recriar. Fora de um terminal interativo, passe a resposta com --rename users.name:fullName. Em produção com várias instâncias rodando, prefira o padrão expandir e contrair descrito neste guia.
Q5Posso editar uma migração que já foi aplicada?
Não. O kastor guarda um checksum de cada arquivo aplicado, e migrate dev e migrate deploy param com o erro MIGRATION_DRIFT se o arquivo mudar depois. Para corrigir algo, crie uma nova migração. Se quiser editar o SQL antes de aplicar, gere o arquivo com migrate dev --create-only. Em desenvolvimento local, migrate reset recria o banco do zero se você precisar recomeçar.