Dia 4 — Migrações de banco
O problema de mudar tabela com dado dentro
O que quebra quando a tabela tem dado dentro
Uma migração de banco é a alteração de esquema que vem com o código, e a promessa dela é estreita: mudar a estrutura sem quebrar o que já funciona. O ALTER TABLE raramente falha. O que quebra é o depois: a coluna entra, o código novo já a lê, e o dado antigo não se ajusta ao que o código novo espera.
É por isso que um ALTER TABLE em produção é uma decisão de horário e não um
passo do desenvolvimento. E é por isso que a versão do esquema não pode ser um
palpite de quem escreve a rota: precisa de histórico de mudança, de um lugar no
banco que responda "isto já foi aplicado?". A aula 2 do dia escreve esse lugar.
O exemplo do dia parte da tabela do 2º trimestre — três itens com preço — e adiciona pc_desconto de três maneiras. Cada uma produz um resultado diferente no dado que já existia.
Estado 1: ADD COLUMN sem nada
ALTER TABLE tb_item ADD COLUMN pc_desconto INT NULL;
A coluna entra e o dado antigo fica como estava:
id 1 teclado desconto NULL id 2 mouse desconto NULL id 3 monitor desconto NULL soma dos descontos lidos: 0 — tres NULL viraram tres zeros sem erro nenhum
A última linha é a que importa. O programa lê Number(pc_desconto), e Number(null) vale 0. Não há exceção, não há log, não há status de erro: o preço de todo item antigo sai errado e ninguém é avisado.
Esse é o pior tipo de defeito — o que produz resultado plausível. Um NULL que vira 0 não denuncia nada.
Estado 2: ADD COLUMN com DEFAULT
ALTER TABLE tb_item ADD COLUMN pc_desconto INT NOT NULL DEFAULT 0;
O DEFAULT vale para as linhas novas e para as que já existem:
id 1 teclado desconto 0 (number) id 2 mouse desconto 0 (number) id 3 monitor desconto 0 (number) INSERT sem a coluna nova: desconto 0 — o DEFAULT age tambem na linha nova
Uma instrução resolve os dois lados: o dado antigo nasce coerente, e o INSERT seguinte que esquecer a coluna continua funcionando. O NOT NULL completa: a partir dali o próprio banco recusa o buraco.
Quando existe um valor único e óbvio para o dado antigo — zero, string vazia, false — esta é a resposta, e basta uma linha.
Estado 3: quando DEFAULT não serve
O DEFAULT 0 está errado quando o valor antigo não é zero. Um desconto que deveria ser calculado a partir do preço não pode virar 0 para todo mundo.
Aí o caminho tem três passos, e a ordem importa:
1. Sobe a coluna aceitando NULL. Ela precisa existir para receber dado.
2. Preenche com um UPDATE que aplica a regra.
UPDATE tb_item SET pc_desconto = FLOOR(vl_preco / 100) WHERE pc_desconto IS NULL;
O resultado sai do affectedRows, que é a contagem real de linha tocada:
linhas preenchidas pelo UPDATE: 3 id 1 teclado preco 120.00 desconto 1% id 2 mouse preco 80.50 desconto 0% id 3 monitor preco 950.00 desconto 9%
O WHERE pc_desconto IS NULL é o que torna o UPDATE idempotente: rodar de novo não acha linha nenhuma para preencher, e affectedRows devolve 0.
3. Só depois de COUNT zerado, endurece a coluna.
linhas ainda NULL: 0 a coluna virou NOT NULL: a partir de agora o banco impede o buraco definicao final: pc_desconto int(11) null=NO default=0
Inverter a ordem — pôr NOT NULL antes do UPDATE — falha na hora, porque o valor padrão já entra em conflito.
SHOW COLUMNS é a fonte da verdade
O código da aplicação acredita que a tabela tem três colunas. Quem responde o que existe de fato é o banco:
const [cs] = await c.query('SHOW COLUMNS FROM tb_item'); const nomes = cs.map((l) => l.Field);
Essa é a consulta que a aula de migração usa para decidir o que fazer, e é a que roda antes de qualquer ALTER. information_schema responde o mesmo, com mais filtro e sem depender do nome da tabela vir colado na string.
O custo que não aparece na tela
ADD COLUMN em tabela grande não é instantâneo. O banco reescreve a tabela e segura o lock; durante a janela, escrita nova espera. Some NOT NULL sem DEFAULT e o limite é pior: com o strict mode ligado, o MySQL recusa gravar linha que não manda valor para a coluna nova.
É por isso que migração não roda junto com deploy. Rodar é uma decisão de horário, não um passo do desenvolvimento.
| Etapa | Custo |
|---|---|
ADD COLUMN com DEFAULT | reescreve a tabela em alguns casos, segura lock |
UPDATE de backfill em lote | rewrite por linha, log cresce |
MODIFY COLUMN para NOT NULL | reescreve a tabela |
CREATE INDEX | ainda mais caro: reconstrói tudo |
Remover coluna é o caminho sem volta: o dado vai embora e
SHOW COLUMNSpara de listar. Por isso a migração de remoção tem duas etapas — primeiro para de usar a coluna no código, só depois derruba.
Uma migração idempotente é a que roda duas vezes sem estragar nada. É o que separa migração de script:
CREATE TABLE IF NOT EXISTSeADD COLUMNsó depois de perguntar se a coluna já existe. O exemplo do dia usaderrubaColuna, que consultaSHOW COLUMNSantes de derrubar —DROP COLUMN IF EXISTSnão existe no MySQL.
Exemplo
'use strict'; // Exemplo da aula 1 do dia 4: o que acontece quando a coluna nova chega com // dado antigo dentro. // // A situacao: a tabela `tb_item` existe, tem linhas, e a aplicacao precisa de // uma coluna `pc_desconto` que ainda nao existe. O caminho curto — `ALTER TABLE // ... ADD COLUMN` — funciona. O problema nao e ele falhar: e o que acontece // DEPOIS, e o que ja quebrou em producao antes. // // O exemplo percorre os quatro estados do mesmo dado: antes da coluna, com a // coluna vazia, com a coluna preenchida a mao, e com a coluna preenchida por // `DEFAULT` na propria alteracao. const { createConnection } = require('mysql2/promise'); // Remove a coluna se ela existir. A migracao precisa disso para ser // IDEMPOTENTE: rodar duas vezes tem que dar o mesmo resultado, e a segunda // execucao comeca com a coluna ja la. `DROP COLUMN IF EXISTS` nao existe no // MySQL, entao o caminho e perguntar antes e so entao derrubar. async function derrubaColuna(c, tabela, coluna) { const [cs] = await c.query('SHOW COLUMNS FROM ' + tabela); if (cs.some((l) => l.Field === coluna)) { await c.query('ALTER TABLE ' + tabela + ' DROP COLUMN ' + coluna); return true; } return false; } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); // ------------------------------------------------ o estado "antes" // A tabela do 2º trimestre: duas colunas e dados dentro. await c.query(` CREATE TABLE IF NOT EXISTS tb_item ( id INT AUTO_INCREMENT PRIMARY KEY, nm_item VARCHAR(40) NOT NULL, vl_preco DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); await c.query('TRUNCATE TABLE tb_item'); // `execute` e prepared statement: um `VALUES` com varias linhas e um comando // composto, e nao aceito. Sao tres `execute` de uma linha cada. await c.execute('INSERT INTO tb_item (nm_item, vl_preco) VALUES (?, ?)', ['teclado', 120.00]); await c.execute('INSERT INTO tb_item (nm_item, vl_preco) VALUES (?, ?)', ['mouse', 80.50]); await c.execute('INSERT INTO tb_item (nm_item, vl_preco) VALUES (?, ?)', ['monitor', 950.00]); // Mostra as colunas de verdade: e o `SHOW COLUMNS` que responde "o que // existe no banco agora", e nao o que a aplicacao acredita. const colunas = async (tabela) => { const [cs] = await c.query('SHOW COLUMNS FROM ' + tabela); return cs.map((l) => l.Field); }; console.log('--- 1. antes: a tabela do 2º trimestre ---'); console.log('colunas: ' + (await colunas('tb_item')).join(', ')); const [antes] = await c.query('SELECT * FROM tb_item ORDER BY id'); for (const l of antes) { console.log(' id ' + l.id + ' ' + l.nm_item.padEnd(9) + ' preco ' + l.vl_preco); } console.log('linhas com dado antigo dentro: ' + antes.length); // ----------------------------------------- 2. o ALTER que "sobrescreve" // Esta e a linha que o time roda na urgencia. Ela adiciona a coluna sem // DEFAULT: o que ja estava gravado continua gravado, e a consulta passa a // devolver `NULL` no lugar de um numero. console.log('\n--- 2. ADD COLUMN sem DEFAULT ---'); await derrubaColuna(c, 'tb_item', 'pc_desconto'); await c.query('ALTER TABLE tb_item ADD COLUMN pc_desconto INT NULL'); console.log('colunas: ' + (await colunas('tb_item')).join(', ')); const [depois] = await c.query('SELECT id, nm_item, pc_desconto FROM tb_item ORDER BY id'); for (const l of depois) { console.log(' id ' + l.id + ' ' + l.nm_item.padEnd(9) + ' desconto ' + (l.pc_desconto === null ? 'NULL' : l.pc_desconto)); } // E agora o programa que le o dado. `Number(null)` vale `0`, entao o // desconto de todo item antigo vira 0 silenciosamente — nao ha erro, o // preco so sai errado. const descontoDe = (linha) => Number(linha.pc_desconto); const somaDesconto = depois.reduce((s, l) => s + descontoDe(l), 0); console.log('soma dos descontos lidos: ' + somaDesconto + ' — tres NULL viraram tres zeros sem erro nenhum'); // ------------------------------- 3. o DEFAULT preenche o dado antigo // A forma que resolve: o DEFAULT vale para as linhas novas E para as que ja // existem. Uma so instrucao, e o dado antigo nasce coerente. console.log('\n--- 3. ADD COLUMN com DEFAULT (o caminho certo) ---'); await derrubaColuna(c, 'tb_item', 'pc_desconto'); await c.query('ALTER TABLE tb_item ADD COLUMN pc_desconto INT NOT NULL DEFAULT 0'); console.log('colunas: ' + (await colunas('tb_item')).join(', ')); const [preenchido] = await c.query( 'SELECT id, nm_item, pc_desconto FROM tb_item ORDER BY id'); for (const l of preenchido) { console.log(' id ' + l.id + ' ' + l.nm_item.padEnd(9) + ' desconto ' + (l.pc_desconto === null ? 'NULL' : l.pc_desconto) + ' (' + typeof l.pc_desconto + ')'); } console.log('todas as linhas antigas ganharam o DEFAULT: nenhum NULL sobrou'); // A coluna NOT NULL impede o proximo INSERT de forgetting de mandar o valor. try { await c.query('INSERT INTO tb_item (nm_item, vl_preco) VALUES (?, ?)', ['sem desconto', 10.00]); const [comDefault] = await c.query( 'SELECT pc_desconto FROM tb_item WHERE nm_item = ?', ['sem desconto']); console.log('INSERT sem a coluna nova: desconto ' + comDefault[0].pc_desconto + ' — o DEFAULT age tambem na linha nova'); await c.query('DELETE FROM tb_item WHERE nm_item = ?', ['sem desconto']); } catch (erro) { console.log('INSERT sem a coluna nova falhou com ' + erro.code); } // ---------------------------- 4. a alteracao que PRECISA de backfill // Quando o DEFAULT nao serve — a coluna nova nao tem um valor unico para o // dado antigo. Aqui o desconto de cada item antigo e uma conta sobre o // preco, e nao um zero. O DEFAULT continuaria dando a resposta errada. console.log('\n--- 4. quando o DEFAULT nao serve: backfill ---'); await derrubaColuna(c, 'tb_item', 'pc_desconto'); await c.query('ALTER TABLE tb_item ADD COLUMN pc_desconto INT NULL'); // Passo 1: o UPDATE que preenche o dado antigo a partir de uma regra. const [alterados] = await c.execute( 'UPDATE tb_item SET pc_desconto = FLOOR(vl_preco / 100) WHERE pc_desconto IS NULL'); console.log('linhas preenchidas pelo UPDATE: ' + alterados.affectedRows); const [backfill] = await c.query( 'SELECT id, nm_item, vl_preco, pc_desconto FROM tb_item ORDER BY id'); for (const l of backfill) { console.log(' id ' + l.id + ' ' + l.nm_item.padEnd(9) + ' preco ' + Number(l.vl_preco).toFixed(2) + ' desconto ' + l.pc_desconto + '%'); } // Passo 2: so depois que nao ha mais NULL, a coluna pode ficar NOT NULL. const [nulos] = await c.query( 'SELECT COUNT(*) AS n FROM tb_item WHERE pc_desconto IS NULL'); console.log('linhas ainda NULL: ' + nulos[0].n); if (nulos[0].n === 0) { await c.query( 'ALTER TABLE tb_item MODIFY COLUMN pc_desconto INT NOT NULL DEFAULT 0'); console.log('a coluna virou NOT NULL: a partir de agora o banco impede o buraco'); } const [final] = await c.query('SHOW COLUMNS FROM tb_item WHERE Field = ?', ['pc_desconto']); console.log('definicao final: ' + final[0].Field + ' ' + final[0].Type + ' null=' + final[0].Null + ' default=' + final[0].Default); // ------------------------------------------------ 5. o custo da alteracao // `ALTER TABLE` em tabela grande nao e instantaneo: o banco reescreve a // tabela e segura o lock. O `ALGORITHM` e o que escolhe o caminho. const [linhasTabela] = await c.query('SELECT COUNT(*) AS n FROM tb_item'); console.log('\nlinhas na tabela durante a alteracao: ' + linhasTabela[0].n); console.log('em tabela grande, ADD COLUMN reescreve a tabela e segura o lock;'); console.log('e por isso que migracao roda fora do horario de pico, e nao no deploy'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 1. antes: a tabela do 2º trimestre --- colunas: id, nm_item, vl_preco, pc_desconto id 1 teclado preco 120.00 id 2 mouse preco 80.50 id 3 monitor preco 950.00 linhas com dado antigo dentro: 3 --- 2. ADD COLUMN sem DEFAULT --- colunas: id, nm_item, vl_preco, pc_desconto id 1 teclado desconto NULL id 2 mouse desconto NULL id 3 monitor desconto NULL soma dos descontos lidos: 0 — tres NULL viraram tres zeros sem erro nenhum --- 3. ADD COLUMN com DEFAULT (o caminho certo) --- colunas: id, nm_item, vl_preco, pc_desconto id 1 teclado desconto 0 (number) id 2 mouse desconto 0 (number) id 3 monitor desconto 0 (number) todas as linhas antigas ganharam o DEFAULT: nenhum NULL sobrou INSERT sem a coluna nova: desconto 0 — o DEFAULT age tambem na linha nova --- 4. quando o DEFAULT nao serve: backfill --- linhas preenchidas pelo UPDATE: 3 id 1 teclado preco 120.00 desconto 1% id 2 mouse preco 80.50 desconto 0% id 3 monitor preco 950.00 desconto 9% linhas ainda NULL: 0 a coluna virou NOT NULL: a partir de agora o banco impede o buraco definicao final: pc_desconto int(11) null=NO default=0 linhas na tabela durante a alteracao: 3 em tabela grande, ADD COLUMN reescreve a tabela e segura o lock; e por isso que migracao roda fora do horario de pico, e nao no deploy
Escrever e versionar migrações
O arquivo de migração
Uma migração é um par de funções e um nome que ordena:
{ nome: '002_coluna_email_em_tb_cliente', async up(c) { /* aplica */ }, async down(c) { /* desfaz */ }, }
O nome com número na frente é o que garante a ordem das migrações. O runner não olha a data do arquivo: olha o nome, e 002 vem depois de 001 mesmo que 002 tenha sido escrito primeiro. Aplicar migração é percorrer essa lista em ordem e rodar o up do que ainda não está no diário; reverter migração é o caminho inverso, uma etapa por vez.
up e down não precisam ser simétricos. O down que desfaz um CREATE TABLE é um DROP TABLE e apaga tudo. O down de um ADD COLUMN é outro DROP COLUMN e só devolve o esquema — o dado da coluna que existia antes continua lá, e o dado que morrava na coluna nova vai com ela. É por isso que a migração que apaga dado é a única cujo down não existe: não há como reconstruir.
A tabela que registra o que rodou
tb_migracao é o diário do banco. Uma linha por migração aplicada, com o instante:
CREATE TABLE IF NOT EXISTS tb_migracao ( id INT AUTO_INCREMENT PRIMARY KEY, nm_migracao VARCHAR(120) NOT NULL, dt_aplicada DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_migracao (nm_migracao) );
O UNIQUE em nm_migracao não é enfeite: é ele que impede duas linhas iguais quando a mesma migração é registrada duas vezes.
Com essa tabela, a pergunta "isto já foi aplicado?" tem resposta no servidor. Sem ela, cada pessoa mantém uma lista no bloco de notas, e a diferença entre o banco de produção e o da máquina de quem estuda passa a ser uma lista de notas.
O status é o que se consulta antes de mexer no banco, e a saída do exemplo sai direto do diário:
aplicada 001_criar_tb_cliente pendente 002_coluna_email_em_tb_cliente
É a resposta do status da migração, linha a linha: cada nome do código aparece como aplicada ou pendente, e a diferença entre as duas listas é a medida do quanto falta para o esquema ficar inteiro. A função status(c) faz a comparação entre o que o código tem e o que o diário registra.
O banco de teste é onde essa lista é conferida antes de qualquer outro ambiente. Uma migração que passa no banco de teste pode falhar em produção pela única razão que o diário não explica: o banco de teste nasce do zero com TRUNCATE, e a produção tem três anos de dado, constraint e índice que ninguém lembra. Rodar a mesma migração nos dois, e na mesma ordem, é o que transforma "acho que funciona" em "funciona".
A ordem do registro importa mais que a ordem do SQL
O registro no diário acontece depois que a migração aplicou, nunca antes:
await m.up(c); // aplica await c.execute('INSERT INTO tb_migracao ...', [m.nome]); // registra
Se a ordem fosse invertida, uma migração que falhasse no meio ficaria registrada como aplicada e nunca mais rodaria — o esquema ficaria pela metade e o diário diria que está inteiro. O affectedRows do INSERT no diário também serve: o exemplo imprime quantas linhas entraram.
up idempotente, e a prova de que funciona
Rodar migrate duas vezes não pode fazer estrago. O exemplo faz isso de verdade:
--- 3. rodando migrate de novo (idempotencia) --- aplicadas agora: nenhuma o runner perguntou ao diario e nao encontrou pendencia registros no diario: 2 (001_criar_tb_cliente, 002_coluna_email_em_tb_cliente)
O diário tem duas linhas, não quatro. A idempotência tem duas camadas, e as duas são necessárias:
- no runner: ele pula o que já está no diário;
- na migração: ela pergunta ao banco antes de alterar. A
002fazSHOW COLUMNSe só adiciona a coluna se ela ainda não existir, porqueADD COLUMN IF NOT EXISTSnão existe no MySQL.
Sem a segunda camada, uma migração re-aplicada numa base já migrada falha com ER_DUP_FIELDNAME — e o erro parece um bug da migração quando é falta de idempotência.
A migração em branco é a forma dessa idempotência virada do outro lado: um up que não faz nada porque o passo já foi feito, e devolve { ja_existia: true } em vez de tentar o ALTER. É a 002 do exemplo — ela pergunta ao banco com SHOW COLUMNS, e quando a coluna já está lá o up sai sem tocar em nada. A migração em branco não é caso perdido: é o registro de que alguém já fez esse passo, e ela entra no diário do mesmo jeito.
down: uma etapa por vez
const [linhas] = await c.query( 'SELECT nm_migracao FROM tb_migracao ORDER BY nm_migracao DESC LIMIT 1');
Desfaz a última, na ordem inversa, uma por vez. O exemplo faz o ciclo inteiro e mostra o esquema antes e depois:
desfeita: 002_coluna_email_em_tb_cliente colunas de tb_cliente agora: id, nm_cliente, dt_criacao a coluna da 002 sumiu, e a 001 continua: o down desfaz UMA etapa
Desfazer duas de uma vez dobra o número de coisas que podem dar errado sem ninguém poder dizer qual delas foi. rollback de uma etapa é consertável; de três, vira reconstrução.
E de novo subir é seguro: o runner encontra a 002 pendente no diário e reaplica.
DATETIME volta como Date, e isso importa para a página
O dt_aplicada chega como objeto Date do driver. Imprimir o objeto cru sai no fuso de quem executou — a mesma migração produz duas linhas diferentes na página, conforme a máquina.
toISOString() resolve: sempre UTC, com o sufixo Z, igual em qualquer lugar.
001_criar_tb_cliente aplicada em 2026-09-30T03:36:32.000Z (UTC)
knex migrate:latestesequelize-cli db:migratefazem exatamente isto: guardam o estado numa tabela, aplicam o que falta e sabem desfazer. A aula escrever o runner à mão é para entender o que a biblioteca esconde — depois de escrever, usar a biblioteca é mais seguro, porque ela já resolve os casos que ninguém pensou.
Migração com dado dentro é a que trava.
ALTER TABLEsegura lock e oUPDATEde backfill escreve linha por linha: em tabela grande, migração de horas precisa ser dividida em lotes (WHERE id BETWEEN ? AND ? LIMIT 5000) para não trancar a aplicação.
Nunca edite uma migração que já entrou em produção. Ela já rodou em algum lugar, e quem corrigir o arquivo antigo não muda o banco de lá — cria uma migração nova. Corrigir no lugar funciona em desenvolvimento e quebra a história em qualquer outro ambiente.
Exemplo
'use strict'; // Exemplo da aula 2 do dia 4: a tabela de migracoes e o runner que a preenche. // // Um arquivo de migracao e um par: `up` (aplica) e `down` (desfaz). O runner // guarda o que ja rodou numa tabela do proprio banco, entao a pergunta "isto // ja foi aplicado?" tem resposta no servidor e nao na memoria de alguem. // // O exemplo implementa o runner minimo completo: cria a tabela de controle, // aplica duas migracoes em ordem, mostra o estado, roda o `down` da segunda, e // termina provando que rodar tudo de novo nao faz estrago — que e o requisito // de idempotencia que a aula 1 pediu. const { createConnection } = require('mysql2/promise'); // ============================================================== AS MIGRACOES // Cada migracao e um objeto com nome, `up` e `down`. O nome e a chave de // ordem: `001` antes de `002`, sempre, independente de quando o arquivo foi // escrito no disco. const MIGRACOES = [ { nome: '001_criar_tb_cliente', async up(c) { await c.query(` CREATE TABLE IF NOT EXISTS tb_cliente ( id INT AUTO_INCREMENT PRIMARY KEY, nm_cliente VARCHAR(80) NOT NULL, dt_criacao DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); }, async down(c) { await c.query('DROP TABLE IF EXISTS tb_cliente'); }, }, { nome: '002_coluna_email_em_tb_cliente', async up(c) { // Idempotente: perguntar antes de adicionar. `ADD COLUMN IF NOT EXISTS` // nao existe no MySQL, entao a pergunta vem de `SHOW COLUMNS`. const [cs] = await c.query('SHOW COLUMNS FROM tb_cliente'); if (cs.some((l) => l.Field === 'nm_email')) { return { ja_existia: true }; } await c.query( 'ALTER TABLE tb_cliente ADD COLUMN nm_email VARCHAR(80) NULL'); // Backfill: a coluna entra preenchida, e nao com buraco. await c.query("UPDATE tb_cliente SET nm_email = 'sem-email' " + 'WHERE nm_email IS NULL'); return { ja_existia: false }; }, async down(c) { const [cs] = await c.query('SHOW COLUMNS FROM tb_cliente'); if (cs.some((l) => l.Field === 'nm_email')) { await c.query('ALTER TABLE tb_cliente DROP COLUMN nm_email'); } }, }, ]; // ================================================================ O RUNNER // `tb_migracao` e o diario do banco: uma linha por migracao aplicada, com o // instante. E ela que responde "que versao do esquema esta no ar?". async function criarControle(c) { await c.query(` CREATE TABLE IF NOT EXISTS tb_migracao ( id INT AUTO_INCREMENT PRIMARY KEY, nm_migracao VARCHAR(120) NOT NULL, dt_aplicada DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_migracao (nm_migracao) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); } const aplicadas = async (c) => { const [linhas] = await c.query( 'SELECT nm_migracao FROM tb_migracao ORDER BY nm_migracao'); return linhas.map((l) => l.nm_migracao); }; // `up`: aplica tudo que falta, em ordem. Cada migracao e registrada SO depois // de aplicada — se ela falhar no meio, ela nao entra no diario e roda de novo // na proxima vez, que e o comportamento correto. // // O segundo parametro limita quantas migracoes considerar: e assim que o // exemplo sobe a 001, cria dado, e sobe a 002 depois — sem esse limite o // runner subiria as duas de uma vez e o backfill nao encontraria nada. async function migrarParaMaisRecente(c, lote = MIGRACOES) { const jaRodou = new Set(await aplicadas(c)); const feitas = []; for (const m of lote) { if (jaRodou.has(m.nome)) continue; await m.up(c); await c.execute('INSERT INTO tb_migracao (nm_migracao) VALUES (?)', [m.nome]); feitas.push(m.nome); } return feitas; } // `down`: desfaz a ultima aplicada, na ordem inversa. Uma por vez e o padrao: // desfazer duas de uma vez dobra o numero de coisas que podem dar errado sem // ninguem poder dizer qual delas foi. async function desfazerUltima(c) { const [linhas] = await c.query( 'SELECT nm_migracao FROM tb_migracao ORDER BY nm_migracao DESC LIMIT 1'); if (linhas.length === 0) return null; const nome = linhas[0].nm_migracao; const m = MIGRACOES.find((x) => x.nome === nome); if (!m) return { erro: 'migracao aplicada sem arquivo correspondente: ' + nome }; await m.down(c); await c.execute('DELETE FROM tb_migracao WHERE nm_migracao = ?', [nome]); return { desfeita: nome }; } // O `status` e o que a equipe pergunta antes de mexer no banco. async function status(c) { const noBanco = await aplicadas(c); const noCodigo = MIGRACOES.map((m) => m.nome); return noCodigo.map((nome) => ({ nome, aplicada: noBanco.includes(nome), estado: noBanco.includes(nome) ? 'aplicada' : 'pendente', })); } async function main() { const c = await createConnection({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, multipleStatements: true, }); await criarControle(c); await c.query('TRUNCATE TABLE tb_migracao'); await c.query('DROP TABLE IF EXISTS tb_cliente'); console.log('--- 1. estado inicial: nada aplicado ---'); for (const linha of await status(c)) { console.log(' ' + linha.estado.padEnd(9) + linha.nome); } // ---------------------------------------------------- primeiro `up` // Sobe SO a primeira migracao: e o estado em que a 002 vai encontrar // dado velho dentro, que e exatamente o caso que a aula 1 mostrou. console.log('\n--- 2. rodando migrate (sobe o que falta) ---'); const feitas = await migrarParaMaisRecente( c, MIGRACOES.slice(0, 1)); console.log('aplicadas agora: ' + (feitas.length ? feitas.join(', ') : 'nenhuma')); for (const linha of await status(c)) { console.log(' ' + linha.estado.padEnd(9) + linha.nome); } const [diario] = await c.query( 'SELECT nm_migracao, dt_aplicada FROM tb_migracao ORDER BY nm_migracao'); console.log('diario do banco: ' + diario.length + ' linha(s)'); for (const d of diario) { // `dt_aplicada` e DATETIME, e volta como objeto Date do driver. O // `toISOString` e o que deixa a saida igual em qualquer fuso: e UTC, com // o sufixo `Z`. Imprimir o Date cru sai no fuso de quem rodou, e a linha // da pagina deixa de bater com a de outra maquina. console.log(' ' + d.nm_migracao + ' aplicada em ' + new Date(d.dt_aplicada).toISOString() + ' (UTC)'); } // ---------------------------------- o que a migracao 002 fez com dado velho // O dado precisa EXISTIR antes da 002 para o backfill ter o que preencher. // Por isso o runner acima e chamado em duas etapas: primeiro a 001 cria a // tabela, os clientes entram, e so entao a 002 sobe e preenche. console.log('\n--- 2b. dado antigo antes da 002 ---'); const [antesDa002] = await c.query('SHOW COLUMNS FROM tb_cliente'); console.log('colunas antes da 002: ' + antesDa002.map((l) => l.Field).join(', ')); await c.execute("INSERT INTO tb_cliente (nm_cliente) VALUES (?)", ['ana']); await c.execute("INSERT INTO tb_cliente (nm_cliente) VALUES (?)", ['bruno']); const [velhos] = await c.query('SELECT id, nm_cliente FROM tb_cliente ORDER BY id'); console.log('clientes ja gravados, sem a coluna nm_email: ' + velhos.length + ' linha(s)'); // Agora sim: aplica so a 002, que tem de preencher o dado que ja existe. const r002 = await MIGRACOES[1].up(c); await c.execute('INSERT INTO tb_migracao (nm_migracao) VALUES (?)', [MIGRACOES[1].nome]); console.log('002 aplicada; a coluna ja existia? ' + r002.ja_existia); const [clientes] = await c.query( 'SELECT id, nm_cliente, nm_email FROM tb_cliente ORDER BY id'); console.log('depois da 002:'); for (const cli of clientes) { console.log(' ' + cli.nm_cliente.padEnd(7) + ' nm_email=' + cli.nm_email + ' <- preenchido pelo backfill'); } const [nulos] = await c.query( 'SELECT COUNT(*) AS n FROM tb_cliente WHERE nm_email IS NULL'); console.log('clientes com nm_email NULL: ' + nulos[0].n + ' — o backfill acertou todos'); // -------------------------------------------------- `up` de novo: no-op console.log('\n--- 3. rodando migrate de novo (idempotencia) ---'); const nada = await migrarParaMaisRecente(c); console.log('aplicadas agora: ' + (nada.length ? nada.join(', ') : 'nenhuma')); console.log('o runner perguntou ao diario e nao encontrou pendencia'); const diarioAgora = await aplicadas(c); console.log('registros no diario: ' + diarioAgora.length + ' (' + diarioAgora.join(', ') + ')'); console.log('rodar de novo nao duplicou linha nem reaplicou nada'); // ------------------------------------------------------ `down` da ultima console.log('\n--- 4. rollback (desfaz a ultima aplicada) ---'); const antes = await status(c); console.log('antes: ' + antes.map((l) => l.nome).join(', ')); const r = await desfazerUltima(c); console.log('desfeita: ' + (r && r.desfeita ? r.desfeita : r.erro)); const depois = await status(c); for (const linha of depois) { console.log(' ' + linha.estado.padEnd(9) + linha.nome); } const [cs] = await c.query('SHOW COLUMNS FROM tb_cliente'); console.log('colunas de tb_cliente agora: ' + cs.map((l) => l.Field).join(', ')); console.log('a coluna da 002 sumiu, e a 001 continua: o down desfaz UMA etapa'); // ------------------------------------------------- `up` de novo: recovers console.log('\n--- 5. subindo de novo depois do rollback ---'); const refeitas = await migrarParaMaisRecente(c); console.log('aplicadas agora: ' + (refeitas.length ? refeitas.join(', ') : 'nenhuma')); const [cs2] = await c.query('SHOW COLUMNS FROM tb_cliente'); console.log('colunas de tb_cliente: ' + cs2.map((l) => l.Field).join(', ')); console.log('a tabela continua de pe: o `down` da 001 nao foi chamado'); await c.end(); } main().catch((erro) => { console.error('falhou:', erro.code || erro.name, '-', erro.message); process.exit(1); });
Saída real
--- 1. estado inicial: nada aplicado --- pendente 001_criar_tb_cliente pendente 002_coluna_email_em_tb_cliente --- 2. rodando migrate (sobe o que falta) --- aplicadas agora: 001_criar_tb_cliente aplicada 001_criar_tb_cliente pendente 002_coluna_email_em_tb_cliente diario do banco: 1 linha(s) 001_criar_tb_cliente aplicada em 2026-09-30T15:15:46.000Z (UTC) --- 2b. dado antigo antes da 002 --- colunas antes da 002: id, nm_cliente, dt_criacao clientes ja gravados, sem a coluna nm_email: 2 linha(s) 002 aplicada; a coluna ja existia? false depois da 002: ana nm_email=sem-email <- preenchido pelo backfill bruno nm_email=sem-email <- preenchido pelo backfill clientes com nm_email NULL: 0 — o backfill acertou todos --- 3. rodando migrate de novo (idempotencia) --- aplicadas agora: nenhuma o runner perguntou ao diario e nao encontrou pendencia registros no diario: 2 (001_criar_tb_cliente, 002_coluna_email_em_tb_cliente) rodar de novo nao duplicou linha nem reaplicou nada --- 4. rollback (desfaz a ultima aplicada) --- antes: 001_criar_tb_cliente, 002_coluna_email_em_tb_cliente desfeita: 002_coluna_email_em_tb_cliente aplicada 001_criar_tb_cliente pendente 002_coluna_email_em_tb_cliente colunas de tb_cliente agora: id, nm_cliente, dt_criacao a coluna da 002 sumiu, e a 001 continua: o down desfaz UMA etapa --- 5. subindo de novo depois do rollback --- aplicadas agora: 002_coluna_email_em_tb_cliente colunas de tb_cliente: id, nm_cliente, dt_criacao, nm_email a tabela continua de pe: o `down` da 001 nao foi chamado