Dia 4 — Migrações de banco

Informatica · Conteudo · publicado em 30/09/2026
Dia 4 de 15

Migrações de banco

Aula 1

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.

EtapaCusto
ADD COLUMN com DEFAULTreescreve a tabela em alguns casos, segura lock
UPDATE de backfill em loterewrite por linha, log cresce
MODIFY COLUMN para NOT NULLreescreve a tabela
CREATE INDEXainda mais caro: reconstrói tudo

Remover coluna é o caminho sem volta: o dado vai embora e SHOW COLUMNS para 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 EXISTS e ADD COLUMN só depois de perguntar se a coluna já existe. O exemplo do dia usa derrubaColuna, que consulta SHOW COLUMNS antes de derrubar — DROP COLUMN IF EXISTS nã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
Aula 2

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 002 faz SHOW COLUMNS e só adiciona a coluna se ela ainda não existir, porque ADD COLUMN IF NOT EXISTS nã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:latest e sequelize-cli db:migrate fazem 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 TABLE segura lock e o UPDATE de 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